From 9b6202f9dc4df7ccd1ef8458be7d44b008d603b2 Mon Sep 17 00:00:00 2001 From: David Pokora Date: Tue, 31 Oct 2023 17:07:37 -0400 Subject: [PATCH] Initial commit --- .gitignore | 238 +++++ EchoRelay.App/EchoRelay.App.csproj | 30 + .../AccessControlListEditor.Designer.cs | 98 ++ .../Forms/Controls/AccessControlListEditor.cs | 82 ++ .../Controls/AccessControlListEditor.resx | 120 +++ .../AccessControlListRuleEditor.Designer.cs | 126 +++ .../Controls/AccessControlListRuleEditor.cs | 85 ++ .../Controls/AccessControlListRuleEditor.resx | 120 +++ .../Forms/Controls/AccountEditor.Designer.cs | 160 ++++ EchoRelay.App/Forms/Controls/AccountEditor.cs | 121 +++ .../Forms/Controls/AccountEditor.resx | 120 +++ .../Controls/AccountSelector.Designer.cs | 112 +++ .../Forms/Controls/AccountSelector.cs | 129 +++ .../Forms/Controls/AccountSelector.resx | 120 +++ .../Controls/ChannelInfoEditor.Designer.cs | 197 ++++ .../Forms/Controls/ChannelInfoEditor.cs | 153 +++ .../Forms/Controls/ChannelInfoEditor.resx | 120 +++ .../Controls/GameServersControl.Designer.cs | 298 ++++++ .../Forms/Controls/GameServersControl.cs | 192 ++++ .../Forms/Controls/GameServersControl.resx | 126 +++ .../Controls/LoginSettingsEditor.Designer.cs | 154 ++++ .../Forms/Controls/LoginSettingsEditor.cs | 97 ++ .../Forms/Controls/LoginSettingsEditor.resx | 120 +++ .../PeerConnectionsControl.Designer.cs | 118 +++ .../Forms/Controls/PeerConnectionsControl.cs | 65 ++ .../Controls/PeerConnectionsControl.resx | 120 +++ .../Controls/ServerInfoControl.Designer.cs | 278 ++++++ .../Forms/Controls/ServerInfoControl.cs | 61 ++ .../Forms/Controls/ServerInfoControl.resx | 120 +++ .../Forms/Controls/StorageEditorBase.cs | 89 ++ .../Dialogs/GameLauncherDialog.Designer.cs | 218 +++++ .../Forms/Dialogs/GameLauncherDialog.cs | 45 + .../Forms/Dialogs/GameLauncherDialog.resx | 120 +++ .../Forms/Dialogs/SettingsDialog.Designer.cs | 356 +++++++ EchoRelay.App/Forms/Dialogs/SettingsDialog.cs | 149 +++ .../Forms/Dialogs/SettingsDialog.resx | 120 +++ EchoRelay.App/Forms/MainWindow.Designer.cs | 744 +++++++++++++++ EchoRelay.App/Forms/MainWindow.cs | 486 ++++++++++ EchoRelay.App/Forms/MainWindow.resx | 132 +++ EchoRelay.App/Program.cs | 17 + .../Properties/Resources.Designer.cs | 133 +++ EchoRelay.App/Properties/Resources.resx | 142 +++ EchoRelay.App/README.md | 19 + .../Resources/launch_client_button_icon.png | Bin 0 -> 1047 bytes .../launch_client_noovr_button_icon.png | Bin 0 -> 882 bytes .../Resources/launch_server_button_icon.png | Bin 0 -> 736 bytes EchoRelay.App/Resources/play_button_icon.png | Bin 0 -> 684 bytes .../Resources/reload_button_icon.png | Bin 0 -> 1729 bytes .../Resources/reload_button_icon_small.png | Bin 0 -> 887 bytes EchoRelay.App/Resources/save_button_icon.png | Bin 0 -> 679 bytes EchoRelay.App/Resources/screenshot.png | Bin 0 -> 101164 bytes EchoRelay.App/Resources/stop_button_icon.png | Bin 0 -> 423 bytes EchoRelay.App/Resources/undo_button_icon.png | Bin 0 -> 1037 bytes EchoRelay.App/Settings/AppSettings.cs | 162 ++++ EchoRelay.App/Utils/ControlUtils.cs | 18 + .../EchoRelay.Core.Test.csproj | 29 + EchoRelay.Core.Test/Messages/ConfigTests.cs | 28 + .../Messages/PacketEncodingSettingsTest.cs | 33 + EchoRelay.Core.Test/Usings.cs | 1 + EchoRelay.Core.Test/Utils/CompressionTests.cs | 75 ++ EchoRelay.Core.Test/Utils/StreamIOTests.cs | 150 +++ EchoRelay.Core/EchoRelay.Core.csproj | 35 + EchoRelay.Core/Game/GameLauncher.cs | 55 ++ EchoRelay.Core/Game/Language.cs | 24 + EchoRelay.Core/Game/PlatformCode.cs | 125 +++ EchoRelay.Core/Game/ServiceConfig.cs | 91 ++ EchoRelay.Core/Game/TeamIndex.cs | 38 + EchoRelay.Core/Game/XPlatformId.cs | 179 ++++ .../Properties/Resources.Designer.cs | 63 ++ EchoRelay.Core/Properties/Resources.resx | 101 ++ EchoRelay.Core/README.md | 27 + .../Common/TcpConnectionUnrequireEvent.cs | 54 ++ .../Server/Messages/Config/ConfigFailurev2.cs | 116 +++ .../Server/Messages/Config/ConfigRequestv2.cs | 97 ++ .../Server/Messages/Config/ConfigSuccessv2.cs | 73 ++ .../Messages/Login/ChannelInfoRequest.cs | 47 + .../Messages/Login/ChannelInfoResponse.cs | 58 ++ .../Server/Messages/Login/DocumentFailure.cs | 68 ++ .../Messages/Login/DocumentRequestv2.cs | 65 ++ .../Server/Messages/Login/DocumentSuccess.cs | 66 ++ .../Login/LoggedInUserProfileFailure.cs | 81 ++ .../Login/LoggedInUserProfileRequest.cs | 76 ++ .../Login/LoggedInUserProfileSuccess.cs | 68 ++ .../Server/Messages/Login/LoginFailure.cs | 83 ++ .../Server/Messages/Login/LoginRequest.cs | 157 ++++ .../Server/Messages/Login/LoginSettings.cs | 58 ++ .../Server/Messages/Login/LoginSuccess.cs | 68 ++ .../Messages/Login/OtherUserProfileFailure.cs | 81 ++ .../Messages/Login/OtherUserProfileRequest.cs | 67 ++ .../Messages/Login/OtherUserProfileSuccess.cs | 68 ++ .../Server/Messages/Login/RemoteLogSetv3.cs | 165 ++++ .../Server/Messages/Login/UpdateProfile.cs | 77 ++ .../Messages/Login/UpdateProfileSuccess.cs | 59 ++ .../Login/UserServerProfileUpdateRequest.cs | 101 ++ .../Login/UserServerProfileUpdateSuccess.cs | 59 ++ .../Messages/Matching/FindServerRegionInfo.cs | 72 ++ .../Matching/LobbyCreateSessionRequestv9.cs | 153 +++ .../Matching/LobbyFindSessionRequestv11.cs | 133 +++ .../Matching/LobbyJoinSessionRequestv7.cs | 119 +++ .../Matching/LobbyMatchmakerStatus.cs | 55 ++ .../Matching/LobbyMatchmakerStatusRequest.cs | 55 ++ .../Matching/LobbyPendingSessionCancel.cs | 56 ++ .../Messages/Matching/LobbyPingRequestv3.cs | 161 ++++ .../Messages/Matching/LobbyPingResponse.cs | 118 +++ .../Matching/LobbyPlayerSessionsRequestv5.cs | 95 ++ .../LobbyPlayerSessionsSuccessUnk1.cs | 85 ++ .../Matching/LobbyPlayerSessionsSuccessv2.cs | 76 ++ .../Matching/LobbyPlayerSessionsSuccessv3.cs | 95 ++ .../Matching/LobbySessionFailureErrorCode.cs | 24 + .../Matching/LobbySessionFailurev1.cs | 66 ++ .../Matching/LobbySessionFailurev2.cs | 78 ++ .../Matching/LobbySessionFailurev3.cs | 93 ++ .../Matching/LobbySessionFailurev4.cs | 103 +++ .../Matching/LobbySessionSuccessv4.cs | 164 ++++ .../Matching/LobbySessionSuccessv5.cs | 170 ++++ .../Messages/Matching/LobbyStatusNotifyv2.cs | 115 +++ EchoRelay.Core/Server/Messages/Message.cs | 107 +++ .../Server/Messages/MessageTypes.cs | 162 ++++ EchoRelay.Core/Server/Messages/Packet.cs | 113 +++ .../ServerDB/ERGameServerAcceptPlayers.cs | 69 ++ .../ServerDB/ERGameServerChallengeRequest.cs | 59 ++ .../ServerDB/ERGameServerChallengeResponse.cs | 59 ++ .../ServerDB/ERGameServerEndSession.cs | 39 + .../ERGameServerPlayerSessionsLocked.cs | 39 + .../ERGameServerPlayerSessionsUnlocked.cs | 39 + .../ServerDB/ERGameServerPlayersAccepted.cs | 73 ++ .../ServerDB/ERGameServerPlayersRejected.cs | 103 +++ .../ERGameServerRegistrationRequest.cs | 78 ++ .../ServerDB/ERGameServerRemovePlayer.cs | 57 ++ .../ServerDB/ERGameServerSessionStarted.cs | 39 + .../ServerDB/ERGameServerStartSession.cs | 170 ++++ .../ServerDB/LobbyRegistrationFailure.cs | 82 ++ .../ServerDB/LobbyRegistrationSuccess.cs | 67 ++ .../Messages/Transaction/ReconcileIAP.cs | 69 ++ .../Transaction/ReconcileIAPResult.cs | 66 ++ EchoRelay.Core/Server/Server.cs | 345 +++++++ EchoRelay.Core/Server/ServerSettings.cs | 128 +++ .../Server/Services/Config/ConfigService.cs | 84 ++ .../Server/Services/Login/LoginService.cs | 440 +++++++++ .../Services/Matching/MatchingService.cs | 362 ++++++++ .../Services/Matching/MatchingSession.cs | 54 ++ EchoRelay.Core/Server/Services/Peer.cs | 214 +++++ .../Services/ServerDB/GameServerRegistry.cs | 122 +++ .../ServerDB/PacketEncoderSettings.cs | 85 ++ .../Services/ServerDB/RegisteredGameServer.cs | 507 ++++++++++ .../Services/ServerDB/ServerDBService.cs | 196 ++++ EchoRelay.Core/Server/Services/Service.cs | 229 +++++ .../Transaction/TransactionService.cs | 47 + .../Filesystem/FilesystemResourceProviders.cs | 208 +++++ .../Filesystem/FilesystemServerStorage.cs | 63 ++ .../Server/Storage/Filesystem/LRUFileCache.cs | 297 ++++++ .../Server/Storage/InitialDeployment.cs | 211 +++++ .../Server/Storage/ResourceProviders.cs | 169 ++++ .../Resources/AccessControlListResource.cs | 106 +++ .../Storage/Resources/AccountResource.cs | 869 ++++++++++++++++++ .../Storage/Resources/ChannelInfoResource.cs | 107 +++ .../Storage/Resources/ConfigResource.cs | 64 ++ .../Storage/Resources/DocumentResource.cs | 53 ++ .../DocumentTypes/EulaDocumentResource.cs | 114 +++ .../Storage/Resources/IKeyedResource.cs | 15 + .../Resources/LoginSettingsResource.cs | 162 ++++ .../Server/Storage/Resources/SymbolCache.cs | 263 ++++++ .../Server/Storage/ServerStorage.cs | 120 +++ EchoRelay.Core/Utils/AsyncLock.cs | 48 + EchoRelay.Core/Utils/Compression.cs | 51 + EchoRelay.Core/Utils/IPAddressUtils.cs | 100 ++ EchoRelay.Core/Utils/JsonUtils.cs | 91 ++ EchoRelay.Core/Utils/PathUtils.cs | 17 + EchoRelay.Core/Utils/SecureGuidGenerator.cs | 20 + EchoRelay.Core/Utils/StreamIO.cs | 864 +++++++++++++++++ .../EchoRelay.GameServer.vcxproj | 118 +++ .../EchoRelay.GameServer.vcxproj.filters | 37 + EchoRelay.GameServer/README.md | 17 + EchoRelay.GameServer/dllmain.cpp | 89 ++ EchoRelay.GameServer/exports.def | 14 + EchoRelay.GameServer/gameserver.cpp | 453 +++++++++ EchoRelay.GameServer/gameserver.h | 51 + EchoRelay.GameServer/messages.h | 100 ++ EchoRelay.Patch/EchoRelay.Patch.vcxproj | 123 +++ .../EchoRelay.Patch.vcxproj.filters | 37 + EchoRelay.Patch/README.md | 23 + EchoRelay.Patch/dllmain.cpp | 23 + EchoRelay.Patch/packages.config | 4 + EchoRelay.Patch/patches.cpp | 433 +++++++++ EchoRelay.Patch/patches.h | 4 + EchoRelay.Patch/processmem.h | 39 + EchoRelay.sln | 83 ++ README.md | 214 +++++ common/echovr.h | 377 ++++++++ common/echovrunexported.h | 172 ++++ common/pch.h | 23 + 191 files changed, 22631 insertions(+) create mode 100644 .gitignore create mode 100644 EchoRelay.App/EchoRelay.App.csproj create mode 100644 EchoRelay.App/Forms/Controls/AccessControlListEditor.Designer.cs create mode 100644 EchoRelay.App/Forms/Controls/AccessControlListEditor.cs create mode 100644 EchoRelay.App/Forms/Controls/AccessControlListEditor.resx create mode 100644 EchoRelay.App/Forms/Controls/AccessControlListRuleEditor.Designer.cs create mode 100644 EchoRelay.App/Forms/Controls/AccessControlListRuleEditor.cs create mode 100644 EchoRelay.App/Forms/Controls/AccessControlListRuleEditor.resx create mode 100644 EchoRelay.App/Forms/Controls/AccountEditor.Designer.cs create mode 100644 EchoRelay.App/Forms/Controls/AccountEditor.cs create mode 100644 EchoRelay.App/Forms/Controls/AccountEditor.resx create mode 100644 EchoRelay.App/Forms/Controls/AccountSelector.Designer.cs create mode 100644 EchoRelay.App/Forms/Controls/AccountSelector.cs create mode 100644 EchoRelay.App/Forms/Controls/AccountSelector.resx create mode 100644 EchoRelay.App/Forms/Controls/ChannelInfoEditor.Designer.cs create mode 100644 EchoRelay.App/Forms/Controls/ChannelInfoEditor.cs create mode 100644 EchoRelay.App/Forms/Controls/ChannelInfoEditor.resx create mode 100644 EchoRelay.App/Forms/Controls/GameServersControl.Designer.cs create mode 100644 EchoRelay.App/Forms/Controls/GameServersControl.cs create mode 100644 EchoRelay.App/Forms/Controls/GameServersControl.resx create mode 100644 EchoRelay.App/Forms/Controls/LoginSettingsEditor.Designer.cs create mode 100644 EchoRelay.App/Forms/Controls/LoginSettingsEditor.cs create mode 100644 EchoRelay.App/Forms/Controls/LoginSettingsEditor.resx create mode 100644 EchoRelay.App/Forms/Controls/PeerConnectionsControl.Designer.cs create mode 100644 EchoRelay.App/Forms/Controls/PeerConnectionsControl.cs create mode 100644 EchoRelay.App/Forms/Controls/PeerConnectionsControl.resx create mode 100644 EchoRelay.App/Forms/Controls/ServerInfoControl.Designer.cs create mode 100644 EchoRelay.App/Forms/Controls/ServerInfoControl.cs create mode 100644 EchoRelay.App/Forms/Controls/ServerInfoControl.resx create mode 100644 EchoRelay.App/Forms/Controls/StorageEditorBase.cs create mode 100644 EchoRelay.App/Forms/Dialogs/GameLauncherDialog.Designer.cs create mode 100644 EchoRelay.App/Forms/Dialogs/GameLauncherDialog.cs create mode 100644 EchoRelay.App/Forms/Dialogs/GameLauncherDialog.resx create mode 100644 EchoRelay.App/Forms/Dialogs/SettingsDialog.Designer.cs create mode 100644 EchoRelay.App/Forms/Dialogs/SettingsDialog.cs create mode 100644 EchoRelay.App/Forms/Dialogs/SettingsDialog.resx create mode 100644 EchoRelay.App/Forms/MainWindow.Designer.cs create mode 100644 EchoRelay.App/Forms/MainWindow.cs create mode 100644 EchoRelay.App/Forms/MainWindow.resx create mode 100644 EchoRelay.App/Program.cs create mode 100644 EchoRelay.App/Properties/Resources.Designer.cs create mode 100644 EchoRelay.App/Properties/Resources.resx create mode 100644 EchoRelay.App/README.md create mode 100644 EchoRelay.App/Resources/launch_client_button_icon.png create mode 100644 EchoRelay.App/Resources/launch_client_noovr_button_icon.png create mode 100644 EchoRelay.App/Resources/launch_server_button_icon.png create mode 100644 EchoRelay.App/Resources/play_button_icon.png create mode 100644 EchoRelay.App/Resources/reload_button_icon.png create mode 100644 EchoRelay.App/Resources/reload_button_icon_small.png create mode 100644 EchoRelay.App/Resources/save_button_icon.png create mode 100644 EchoRelay.App/Resources/screenshot.png create mode 100644 EchoRelay.App/Resources/stop_button_icon.png create mode 100644 EchoRelay.App/Resources/undo_button_icon.png create mode 100644 EchoRelay.App/Settings/AppSettings.cs create mode 100644 EchoRelay.App/Utils/ControlUtils.cs create mode 100644 EchoRelay.Core.Test/EchoRelay.Core.Test.csproj create mode 100644 EchoRelay.Core.Test/Messages/ConfigTests.cs create mode 100644 EchoRelay.Core.Test/Messages/PacketEncodingSettingsTest.cs create mode 100644 EchoRelay.Core.Test/Usings.cs create mode 100644 EchoRelay.Core.Test/Utils/CompressionTests.cs create mode 100644 EchoRelay.Core.Test/Utils/StreamIOTests.cs create mode 100644 EchoRelay.Core/EchoRelay.Core.csproj create mode 100644 EchoRelay.Core/Game/GameLauncher.cs create mode 100644 EchoRelay.Core/Game/Language.cs create mode 100644 EchoRelay.Core/Game/PlatformCode.cs create mode 100644 EchoRelay.Core/Game/ServiceConfig.cs create mode 100644 EchoRelay.Core/Game/TeamIndex.cs create mode 100644 EchoRelay.Core/Game/XPlatformId.cs create mode 100644 EchoRelay.Core/Properties/Resources.Designer.cs create mode 100644 EchoRelay.Core/Properties/Resources.resx create mode 100644 EchoRelay.Core/README.md create mode 100644 EchoRelay.Core/Server/Messages/Common/TcpConnectionUnrequireEvent.cs create mode 100644 EchoRelay.Core/Server/Messages/Config/ConfigFailurev2.cs create mode 100644 EchoRelay.Core/Server/Messages/Config/ConfigRequestv2.cs create mode 100644 EchoRelay.Core/Server/Messages/Config/ConfigSuccessv2.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/ChannelInfoRequest.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/ChannelInfoResponse.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/DocumentFailure.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/DocumentRequestv2.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/DocumentSuccess.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/LoggedInUserProfileFailure.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/LoggedInUserProfileRequest.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/LoggedInUserProfileSuccess.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/LoginFailure.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/LoginRequest.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/LoginSettings.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/LoginSuccess.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/OtherUserProfileFailure.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/OtherUserProfileRequest.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/OtherUserProfileSuccess.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/RemoteLogSetv3.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/UpdateProfile.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/UpdateProfileSuccess.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/UserServerProfileUpdateRequest.cs create mode 100644 EchoRelay.Core/Server/Messages/Login/UserServerProfileUpdateSuccess.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/FindServerRegionInfo.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbyCreateSessionRequestv9.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbyFindSessionRequestv11.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbyJoinSessionRequestv7.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbyMatchmakerStatus.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbyMatchmakerStatusRequest.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbyPendingSessionCancel.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbyPingRequestv3.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbyPingResponse.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsRequestv5.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsSuccessUnk1.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsSuccessv2.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsSuccessv3.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbySessionFailureErrorCode.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev1.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev2.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev3.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev4.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbySessionSuccessv4.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbySessionSuccessv5.cs create mode 100644 EchoRelay.Core/Server/Messages/Matching/LobbyStatusNotifyv2.cs create mode 100644 EchoRelay.Core/Server/Messages/Message.cs create mode 100644 EchoRelay.Core/Server/Messages/MessageTypes.cs create mode 100644 EchoRelay.Core/Server/Messages/Packet.cs create mode 100644 EchoRelay.Core/Server/Messages/ServerDB/ERGameServerAcceptPlayers.cs create mode 100644 EchoRelay.Core/Server/Messages/ServerDB/ERGameServerChallengeRequest.cs create mode 100644 EchoRelay.Core/Server/Messages/ServerDB/ERGameServerChallengeResponse.cs create mode 100644 EchoRelay.Core/Server/Messages/ServerDB/ERGameServerEndSession.cs create mode 100644 EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayerSessionsLocked.cs create mode 100644 EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayerSessionsUnlocked.cs create mode 100644 EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayersAccepted.cs create mode 100644 EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayersRejected.cs create mode 100644 EchoRelay.Core/Server/Messages/ServerDB/ERGameServerRegistrationRequest.cs create mode 100644 EchoRelay.Core/Server/Messages/ServerDB/ERGameServerRemovePlayer.cs create mode 100644 EchoRelay.Core/Server/Messages/ServerDB/ERGameServerSessionStarted.cs create mode 100644 EchoRelay.Core/Server/Messages/ServerDB/ERGameServerStartSession.cs create mode 100644 EchoRelay.Core/Server/Messages/ServerDB/LobbyRegistrationFailure.cs create mode 100644 EchoRelay.Core/Server/Messages/ServerDB/LobbyRegistrationSuccess.cs create mode 100644 EchoRelay.Core/Server/Messages/Transaction/ReconcileIAP.cs create mode 100644 EchoRelay.Core/Server/Messages/Transaction/ReconcileIAPResult.cs create mode 100644 EchoRelay.Core/Server/Server.cs create mode 100644 EchoRelay.Core/Server/ServerSettings.cs create mode 100644 EchoRelay.Core/Server/Services/Config/ConfigService.cs create mode 100644 EchoRelay.Core/Server/Services/Login/LoginService.cs create mode 100644 EchoRelay.Core/Server/Services/Matching/MatchingService.cs create mode 100644 EchoRelay.Core/Server/Services/Matching/MatchingSession.cs create mode 100644 EchoRelay.Core/Server/Services/Peer.cs create mode 100644 EchoRelay.Core/Server/Services/ServerDB/GameServerRegistry.cs create mode 100644 EchoRelay.Core/Server/Services/ServerDB/PacketEncoderSettings.cs create mode 100644 EchoRelay.Core/Server/Services/ServerDB/RegisteredGameServer.cs create mode 100644 EchoRelay.Core/Server/Services/ServerDB/ServerDBService.cs create mode 100644 EchoRelay.Core/Server/Services/Service.cs create mode 100644 EchoRelay.Core/Server/Services/Transaction/TransactionService.cs create mode 100644 EchoRelay.Core/Server/Storage/Filesystem/FilesystemResourceProviders.cs create mode 100644 EchoRelay.Core/Server/Storage/Filesystem/FilesystemServerStorage.cs create mode 100644 EchoRelay.Core/Server/Storage/Filesystem/LRUFileCache.cs create mode 100644 EchoRelay.Core/Server/Storage/InitialDeployment.cs create mode 100644 EchoRelay.Core/Server/Storage/ResourceProviders.cs create mode 100644 EchoRelay.Core/Server/Storage/Resources/AccessControlListResource.cs create mode 100644 EchoRelay.Core/Server/Storage/Resources/AccountResource.cs create mode 100644 EchoRelay.Core/Server/Storage/Resources/ChannelInfoResource.cs create mode 100644 EchoRelay.Core/Server/Storage/Resources/ConfigResource.cs create mode 100644 EchoRelay.Core/Server/Storage/Resources/DocumentResource.cs create mode 100644 EchoRelay.Core/Server/Storage/Resources/DocumentTypes/EulaDocumentResource.cs create mode 100644 EchoRelay.Core/Server/Storage/Resources/IKeyedResource.cs create mode 100644 EchoRelay.Core/Server/Storage/Resources/LoginSettingsResource.cs create mode 100644 EchoRelay.Core/Server/Storage/Resources/SymbolCache.cs create mode 100644 EchoRelay.Core/Server/Storage/ServerStorage.cs create mode 100644 EchoRelay.Core/Utils/AsyncLock.cs create mode 100644 EchoRelay.Core/Utils/Compression.cs create mode 100644 EchoRelay.Core/Utils/IPAddressUtils.cs create mode 100644 EchoRelay.Core/Utils/JsonUtils.cs create mode 100644 EchoRelay.Core/Utils/PathUtils.cs create mode 100644 EchoRelay.Core/Utils/SecureGuidGenerator.cs create mode 100644 EchoRelay.Core/Utils/StreamIO.cs create mode 100644 EchoRelay.GameServer/EchoRelay.GameServer.vcxproj create mode 100644 EchoRelay.GameServer/EchoRelay.GameServer.vcxproj.filters create mode 100644 EchoRelay.GameServer/README.md create mode 100644 EchoRelay.GameServer/dllmain.cpp create mode 100644 EchoRelay.GameServer/exports.def create mode 100644 EchoRelay.GameServer/gameserver.cpp create mode 100644 EchoRelay.GameServer/gameserver.h create mode 100644 EchoRelay.GameServer/messages.h create mode 100644 EchoRelay.Patch/EchoRelay.Patch.vcxproj create mode 100644 EchoRelay.Patch/EchoRelay.Patch.vcxproj.filters create mode 100644 EchoRelay.Patch/README.md create mode 100644 EchoRelay.Patch/dllmain.cpp create mode 100644 EchoRelay.Patch/packages.config create mode 100644 EchoRelay.Patch/patches.cpp create mode 100644 EchoRelay.Patch/patches.h create mode 100644 EchoRelay.Patch/processmem.h create mode 100644 EchoRelay.sln create mode 100644 README.md create mode 100644 common/echovr.h create mode 100644 common/echovrunexported.h create mode 100644 common/pch.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..187ba28 --- /dev/null +++ b/.gitignore @@ -0,0 +1,238 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Pycharm +.idea/ + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# Nuget package cache +packages/ diff --git a/EchoRelay.App/EchoRelay.App.csproj b/EchoRelay.App/EchoRelay.App.csproj new file mode 100644 index 0000000..670bff0 --- /dev/null +++ b/EchoRelay.App/EchoRelay.App.csproj @@ -0,0 +1,30 @@ + + + + WinExe + net7.0-windows + enable + true + enable + + + + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + \ No newline at end of file diff --git a/EchoRelay.App/Forms/Controls/AccessControlListEditor.Designer.cs b/EchoRelay.App/Forms/Controls/AccessControlListEditor.Designer.cs new file mode 100644 index 0000000..abe4fa4 --- /dev/null +++ b/EchoRelay.App/Forms/Controls/AccessControlListEditor.Designer.cs @@ -0,0 +1,98 @@ +namespace EchoRelay.App.Forms.Controls +{ + partial class AccessControlListEditor + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + allowRulesEditor = new AccessControlListRuleEditor(); + disallowRulesEditor = new AccessControlListRuleEditor(); + splitContainer1 = new SplitContainer(); + ((System.ComponentModel.ISupportInitialize)splitContainer1).BeginInit(); + splitContainer1.Panel1.SuspendLayout(); + splitContainer1.Panel2.SuspendLayout(); + splitContainer1.SuspendLayout(); + SuspendLayout(); + // + // allowRulesEditor + // + allowRulesEditor.Dock = DockStyle.Fill; + allowRulesEditor.Location = new Point(0, 0); + allowRulesEditor.Name = "allowRulesEditor"; + allowRulesEditor.RuleSetName = "Allow Rules"; + allowRulesEditor.Size = new Size(914, 379); + allowRulesEditor.TabIndex = 0; + allowRulesEditor.RuleSetChanged += rulesEditor_RuleSetChanged; + // + // disallowRulesEditor + // + disallowRulesEditor.Dock = DockStyle.Fill; + disallowRulesEditor.Location = new Point(0, 0); + disallowRulesEditor.Name = "disallowRulesEditor"; + disallowRulesEditor.RuleSetName = "Disallow Rules"; + disallowRulesEditor.Size = new Size(914, 375); + disallowRulesEditor.TabIndex = 1; + disallowRulesEditor.RuleSetChanged += rulesEditor_RuleSetChanged; + // + // splitContainer1 + // + splitContainer1.Dock = DockStyle.Fill; + splitContainer1.Location = new Point(0, 0); + splitContainer1.Name = "splitContainer1"; + splitContainer1.Orientation = Orientation.Horizontal; + // + // splitContainer1.Panel1 + // + splitContainer1.Panel1.Controls.Add(allowRulesEditor); + // + // splitContainer1.Panel2 + // + splitContainer1.Panel2.Controls.Add(disallowRulesEditor); + splitContainer1.Size = new Size(914, 758); + splitContainer1.SplitterDistance = 379; + splitContainer1.TabIndex = 2; + // + // AccessControlListEditor + // + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + Controls.Add(splitContainer1); + Name = "AccessControlListEditor"; + Size = new Size(914, 758); + splitContainer1.Panel1.ResumeLayout(false); + splitContainer1.Panel2.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)splitContainer1).EndInit(); + splitContainer1.ResumeLayout(false); + ResumeLayout(false); + } + + #endregion + + private AccessControlListRuleEditor allowRulesEditor; + private AccessControlListRuleEditor disallowRulesEditor; + private SplitContainer splitContainer1; + } +} diff --git a/EchoRelay.App/Forms/Controls/AccessControlListEditor.cs b/EchoRelay.App/Forms/Controls/AccessControlListEditor.cs new file mode 100644 index 0000000..fe18303 --- /dev/null +++ b/EchoRelay.App/Forms/Controls/AccessControlListEditor.cs @@ -0,0 +1,82 @@ +using EchoRelay.App.Utils; +using EchoRelay.Core.Server.Storage; +using EchoRelay.Core.Server.Storage.Types; + +namespace EchoRelay.App.Forms.Controls +{ + public partial class AccessControlListEditor : StorageEditorBase + { + private AccessControlListResource? _accessControlList; + public AccessControlListEditor() + { + InitializeComponent(); + } + + private void RefreshACL() + { + allowRulesEditor.RuleSet = _accessControlList?.AllowRules.ToArray() ?? Array.Empty(); + disallowRulesEditor.RuleSet = _accessControlList?.DisallowRules.ToArray() ?? Array.Empty(); + Changed = false; + } + + protected override void OnStorageLoaded(ServerStorage storage) + { + // Subscribe to ACL changes + storage.AccessControlList.OnChanged += AccessControlList_OnChanged; + + // Load the ACL + _accessControlList = storage.AccessControlList.Get(); + + // Refresh UI changes + RefreshACL(); + } + + + protected override void OnStorageUnloaded(ServerStorage storage) + { + // Unsubscribe from ACL changes + storage.AccessControlList.OnChanged -= AccessControlList_OnChanged; + + // Unload the ACL + _accessControlList = null; + + // Refresh UI changes + } + + private void AccessControlList_OnChanged(ServerStorage storage, AccessControlListResource resource, StorageChangeType changeType) + { + this.InvokeUIThread(() => + { + if (changeType == StorageChangeType.Set) + _accessControlList = resource; + else if (changeType == StorageChangeType.Deleted) + _accessControlList = null; + + RefreshACL(); + }); + } + + public override void SaveChanges() + { + // If there are no changes, stop + if (!Changed || _accessControlList == null) + return; + + // Update our rule sets + _accessControlList.AllowRules = new HashSet(allowRulesEditor.RuleSet); + _accessControlList.DisallowRules = new HashSet(disallowRulesEditor.RuleSet); + Storage?.AccessControlList.Set(_accessControlList); + Changed = false; + } + + public override void RevertChanges() + { + RefreshACL(); + } + + private void rulesEditor_RuleSetChanged(object sender, EventArgs e) + { + Changed = true; + } + } +} diff --git a/EchoRelay.App/Forms/Controls/AccessControlListEditor.resx b/EchoRelay.App/Forms/Controls/AccessControlListEditor.resx new file mode 100644 index 0000000..a395bff --- /dev/null +++ b/EchoRelay.App/Forms/Controls/AccessControlListEditor.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/EchoRelay.App/Forms/Controls/AccessControlListRuleEditor.Designer.cs b/EchoRelay.App/Forms/Controls/AccessControlListRuleEditor.Designer.cs new file mode 100644 index 0000000..6ca81b0 --- /dev/null +++ b/EchoRelay.App/Forms/Controls/AccessControlListRuleEditor.Designer.cs @@ -0,0 +1,126 @@ +namespace EchoRelay.App.Forms.Controls +{ + partial class AccessControlListRuleEditor + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + lblRulesHeader = new Label(); + txtRule = new TextBox(); + listRules = new ListBox(); + btnUpdate = new Button(); + btnAdd = new Button(); + btnRemove = new Button(); + SuspendLayout(); + // + // lblRulesHeader + // + lblRulesHeader.AutoSize = true; + lblRulesHeader.Location = new Point(3, 6); + lblRulesHeader.Name = "lblRulesHeader"; + lblRulesHeader.Size = new Size(38, 15); + lblRulesHeader.TabIndex = 10; + lblRulesHeader.Text = "Rules:"; + // + // txtRule + // + txtRule.Anchor = AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; + txtRule.Location = new Point(137, 136); + txtRule.Name = "txtRule"; + txtRule.Size = new Size(314, 23); + txtRule.TabIndex = 11; + // + // listRules + // + listRules.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; + listRules.FormattingEnabled = true; + listRules.ItemHeight = 15; + listRules.Location = new Point(137, 6); + listRules.Name = "listRules"; + listRules.Size = new Size(385, 124); + listRules.Sorted = true; + listRules.TabIndex = 13; + listRules.SelectedIndexChanged += listRules_SelectedIndexChanged; + // + // btnUpdate + // + btnUpdate.Anchor = AnchorStyles.Bottom | AnchorStyles.Right; + btnUpdate.Location = new Point(457, 136); + btnUpdate.Name = "btnUpdate"; + btnUpdate.Size = new Size(65, 23); + btnUpdate.TabIndex = 14; + btnUpdate.Text = "Update"; + btnUpdate.UseVisualStyleBackColor = true; + btnUpdate.Click += btnUpdate_Click; + // + // btnAdd + // + btnAdd.Anchor = AnchorStyles.Top | AnchorStyles.Right; + btnAdd.Location = new Point(528, 6); + btnAdd.Name = "btnAdd"; + btnAdd.Size = new Size(25, 25); + btnAdd.TabIndex = 15; + btnAdd.Text = "+"; + btnAdd.UseVisualStyleBackColor = true; + btnAdd.Click += btnAdd_Click; + // + // btnRemove + // + btnRemove.Anchor = AnchorStyles.Top | AnchorStyles.Right; + btnRemove.Location = new Point(528, 37); + btnRemove.Name = "btnRemove"; + btnRemove.Size = new Size(25, 25); + btnRemove.TabIndex = 16; + btnRemove.Text = "-"; + btnRemove.UseVisualStyleBackColor = true; + btnRemove.Click += btnRemove_Click; + // + // AccessControlListRuleEditor + // + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + Controls.Add(btnRemove); + Controls.Add(btnAdd); + Controls.Add(btnUpdate); + Controls.Add(listRules); + Controls.Add(txtRule); + Controls.Add(lblRulesHeader); + Name = "AccessControlListRuleEditor"; + Size = new Size(617, 168); + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private Label lblRulesHeader; + private TextBox txtRule; + private ListBox listRules; + private Button btnUpdate; + private Button btnAdd; + private Button btnRemove; + } +} diff --git a/EchoRelay.App/Forms/Controls/AccessControlListRuleEditor.cs b/EchoRelay.App/Forms/Controls/AccessControlListRuleEditor.cs new file mode 100644 index 0000000..ddad5bf --- /dev/null +++ b/EchoRelay.App/Forms/Controls/AccessControlListRuleEditor.cs @@ -0,0 +1,85 @@ +using System.Text.RegularExpressions; + +namespace EchoRelay.App.Forms.Controls +{ + public partial class AccessControlListRuleEditor : UserControl + { + public string RuleSetName + { + get + { + if (lblRulesHeader.Text.Length == 0) + return ""; + + return lblRulesHeader.Text.Substring(0, lblRulesHeader.Text.Length - 1); + } + set + { + lblRulesHeader.Text = value + ":"; + } + } + public string[] RuleSet + { + get + { + return listRules.Items.OfType().ToArray(); + } + set + { + listRules.Items.Clear(); + listRules.Items.AddRange(value); + } + } + public Regex ruleRegex = new Regex("^[\\.\\:\\*0-9A-Fa-f]+$"); + + public event EventHandler? RuleSetChanged; + + public AccessControlListRuleEditor() + { + InitializeComponent(); + } + + private bool ValidateRule() + { + bool match = ruleRegex.IsMatch(txtRule.Text); + if (!match) + MessageBox.Show("Invalid IP address filter. You must provide a string representing IPv4/IPv6 form (wildcards (\"*\") are allowed)."); + return match; + } + + private void listRules_SelectedIndexChanged(object sender, EventArgs e) + { + txtRule.Text = listRules.SelectedItem?.ToString() ?? ""; + } + + private void btnUpdate_Click(object sender, EventArgs e) + { + if (listRules.SelectedItem != null) + { + if (!ValidateRule()) return; + string ruleText = txtRule.Text; + listRules.Items.Remove(listRules.SelectedItem); + listRules.SelectedIndex = listRules.Items.Add(ruleText); + RuleSetChanged?.Invoke(this, EventArgs.Empty); + } + } + + private void btnAdd_Click(object sender, EventArgs e) + { + if (!ValidateRule()) return; + listRules.SelectedIndex = listRules.Items.Add(txtRule.Text); + RuleSetChanged?.Invoke(this, EventArgs.Empty); + } + + private void btnRemove_Click(object sender, EventArgs e) + { + if (listRules.SelectedItem != null) + { + int index = listRules.SelectedIndex; + listRules.Items.Remove(listRules.SelectedItem); + listRules.SelectedIndex = Math.Min(index, listRules.Items.Count - 1); + RuleSetChanged?.Invoke(this, EventArgs.Empty); + } + } + } +} diff --git a/EchoRelay.App/Forms/Controls/AccessControlListRuleEditor.resx b/EchoRelay.App/Forms/Controls/AccessControlListRuleEditor.resx new file mode 100644 index 0000000..a395bff --- /dev/null +++ b/EchoRelay.App/Forms/Controls/AccessControlListRuleEditor.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/EchoRelay.App/Forms/Controls/AccountEditor.Designer.cs b/EchoRelay.App/Forms/Controls/AccountEditor.Designer.cs new file mode 100644 index 0000000..9d54b9f --- /dev/null +++ b/EchoRelay.App/Forms/Controls/AccountEditor.Designer.cs @@ -0,0 +1,160 @@ +namespace EchoRelay.App.Forms.Controls +{ + partial class AccountEditor + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + lblUserIdHeader = new Label(); + txtDisplayName = new TextBox(); + lblDisplayNameHeader = new Label(); + chkBanned = new CheckBox(); + chkModerator = new CheckBox(); + bannedUntilDatePicker = new DateTimePicker(); + chkDisableAFKTimeout = new CheckBox(); + btnClearAccountLock = new Button(); + txtUserId = new TextBox(); + SuspendLayout(); + // + // lblUserIdHeader + // + lblUserIdHeader.AutoSize = true; + lblUserIdHeader.Location = new Point(7, 6); + lblUserIdHeader.Name = "lblUserIdHeader"; + lblUserIdHeader.Size = new Size(83, 15); + lblUserIdHeader.TabIndex = 4; + lblUserIdHeader.Text = "User Identifier:"; + // + // txtDisplayName + // + txtDisplayName.Location = new Point(141, 32); + txtDisplayName.Name = "txtDisplayName"; + txtDisplayName.Size = new Size(385, 23); + txtDisplayName.TabIndex = 7; + txtDisplayName.TextChanged += onAnyValueChanged; + // + // lblDisplayNameHeader + // + lblDisplayNameHeader.AutoSize = true; + lblDisplayNameHeader.Location = new Point(7, 35); + lblDisplayNameHeader.Name = "lblDisplayNameHeader"; + lblDisplayNameHeader.Size = new Size(83, 15); + lblDisplayNameHeader.TabIndex = 8; + lblDisplayNameHeader.Text = "Display Name:"; + // + // chkBanned + // + chkBanned.AutoSize = true; + chkBanned.Location = new Point(7, 63); + chkBanned.Name = "chkBanned"; + chkBanned.Size = new Size(128, 19); + chkBanned.TabIndex = 9; + chkBanned.Text = "Banned until (UTC):"; + chkBanned.UseVisualStyleBackColor = true; + chkBanned.CheckedChanged += onAnyValueChanged; + // + // chkModerator + // + chkModerator.AutoSize = true; + chkModerator.Location = new Point(7, 90); + chkModerator.Name = "chkModerator"; + chkModerator.Size = new Size(135, 19); + chkModerator.TabIndex = 10; + chkModerator.Text = "Moderator privileges"; + chkModerator.UseVisualStyleBackColor = true; + chkModerator.CheckedChanged += onAnyValueChanged; + // + // bannedUntilDatePicker + // + bannedUntilDatePicker.CustomFormat = "MM/dd/yyyy @ hh:mm:ss tt"; + bannedUntilDatePicker.Format = DateTimePickerFormat.Custom; + bannedUntilDatePicker.Location = new Point(141, 61); + bannedUntilDatePicker.Name = "bannedUntilDatePicker"; + bannedUntilDatePicker.Size = new Size(385, 23); + bannedUntilDatePicker.TabIndex = 11; + bannedUntilDatePicker.ValueChanged += onAnyValueChanged; + // + // chkDisableAFKTimeout + // + chkDisableAFKTimeout.AutoSize = true; + chkDisableAFKTimeout.Location = new Point(7, 115); + chkDisableAFKTimeout.Name = "chkDisableAFKTimeout"; + chkDisableAFKTimeout.Size = new Size(192, 19); + chkDisableAFKTimeout.TabIndex = 12; + chkDisableAFKTimeout.Text = "Disable AFK (inactivity) timeout"; + chkDisableAFKTimeout.UseVisualStyleBackColor = true; + chkDisableAFKTimeout.CheckedChanged += onAnyValueChanged; + // + // btnClearAccountLock + // + btnClearAccountLock.Location = new Point(6, 140); + btnClearAccountLock.Name = "btnClearAccountLock"; + btnClearAccountLock.Size = new Size(520, 23); + btnClearAccountLock.TabIndex = 13; + btnClearAccountLock.Text = "Clear password/account lock"; + btnClearAccountLock.UseVisualStyleBackColor = true; + btnClearAccountLock.Click += btnClearAccountLock_Click; + // + // txtUserId + // + txtUserId.Location = new Point(141, 3); + txtUserId.Name = "txtUserId"; + txtUserId.ReadOnly = true; + txtUserId.Size = new Size(385, 23); + txtUserId.TabIndex = 14; + // + // AccountEditor + // + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + Controls.Add(txtUserId); + Controls.Add(btnClearAccountLock); + Controls.Add(chkDisableAFKTimeout); + Controls.Add(bannedUntilDatePicker); + Controls.Add(chkModerator); + Controls.Add(chkBanned); + Controls.Add(lblDisplayNameHeader); + Controls.Add(txtDisplayName); + Controls.Add(lblUserIdHeader); + Name = "AccountEditor"; + Size = new Size(529, 179); + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private Label lblUserIdHeader; + private TextBox txtDisplayName; + private Label lblDisplayNameHeader; + private CheckBox chkBanned; + private CheckBox chkModerator; + private DateTimePicker bannedUntilDatePicker; + private CheckBox chkDisableAFKTimeout; + private Button btnClearAccountLock; + private TextBox txtUserId; + } +} diff --git a/EchoRelay.App/Forms/Controls/AccountEditor.cs b/EchoRelay.App/Forms/Controls/AccountEditor.cs new file mode 100644 index 0000000..c4f7f46 --- /dev/null +++ b/EchoRelay.App/Forms/Controls/AccountEditor.cs @@ -0,0 +1,121 @@ +using EchoRelay.Core.Server.Storage.Types; + +namespace EchoRelay.App.Forms.Controls +{ + public partial class AccountEditor : StorageEditorBase + { + private AccountResource? _account; + public AccountResource? Account + { + get + { + return _account; + } + set + { + // Check if the account is the same + bool sameAccount = value?.Key() == _account?.Key(); + + // Update the account. + _account = value; + + // If it's the same account, we don't refresh UI, but only set the resource above. + // This lets changes be retained even if the account is updated by the server, but they will + // be saved over the new account object. + if (!sameAccount) + RefreshUI(); + } + } + public AccountEditor() + { + InitializeComponent(); + chkBanned.CheckedChanged += chkBanned_CheckedChanged; + RefreshUI(); + } + + private void RefreshUI() + { + if (Account == null) + { + txtUserId.Text = ""; + txtDisplayName.Text = ""; + txtDisplayName.ReadOnly = true; + chkBanned.Checked = false; + bannedUntilDatePicker.Value = DateTime.UtcNow; + chkModerator.Checked = false; + chkDisableAFKTimeout.Checked = false; + } + else + { + txtUserId.Text = Account.AccountIdentifier.ToString(); + txtDisplayName.Text = Account.Profile.Server.DisplayName; + txtDisplayName.ReadOnly = false; + chkBanned.Checked = Account.Banned; + if (chkBanned.Checked) + bannedUntilDatePicker.Value = Account.BannedUntil!.Value; + chkModerator.Checked = Account.IsModerator; + chkDisableAFKTimeout.Checked = Account.Profile.Server.Developer?.DisableAfkTimeout ?? false; + } + Changed = false; + } + + public override void SaveChanges() + { + // If we have no account, there are no changes to be made + if (Account == null) return; + + // Validate the display name. + if (txtDisplayName.Text.Length == 0) + { + MessageBox.Show("Account display name cannot be saved as a blank name. Account changes have not been saved."); + return; + } + + // Update the fields in our account resource + Account.Profile.SetDisplayName(txtDisplayName.Text); + + // Set our privileges + Account.IsModerator = chkModerator.Checked; + Account.BannedUntil = chkBanned.Checked ? bannedUntilDatePicker.Value : null; + + // Set the AFK timeout field in developer settings. + if (chkDisableAFKTimeout.Checked) + Account.Profile.Server.Developer = new AccountResource.AccountServerProfile.DeveloperSettings(accountId: Account.AccountIdentifier, disableAfkTimeout: true); + else + Account.Profile.Server.Developer = null; + + // Set our account if it is non-null and we have changes + if (Changed && Account != null) + Storage?.Accounts.Set(Account); + Changed = false; + } + public override void RevertChanges() + { + RefreshUI(); + } + + private void chkBanned_CheckedChanged(object? sender, EventArgs e) + { + bannedUntilDatePicker.Enabled = Account != null && chkBanned.Checked; + } + + private void onAnyValueChanged(object sender, EventArgs e) + { + Changed = true; + } + + private void btnClearAccountLock_Click(object sender, EventArgs e) + { + // If we have no account, there are no changes to be made + if (Account == null) return; + + // Clear the account lock and mark the account changed, if requestd + if (MessageBox.Show("This will unlock the account, making it available to be locked with a new password. Would you like to continue?", "Echo Relay: Warning", + MessageBoxButtons.YesNo) == DialogResult.Yes) + { + Account.ClearAccountLock(); + Changed = true; + } + } + } +} diff --git a/EchoRelay.App/Forms/Controls/AccountEditor.resx b/EchoRelay.App/Forms/Controls/AccountEditor.resx new file mode 100644 index 0000000..a395bff --- /dev/null +++ b/EchoRelay.App/Forms/Controls/AccountEditor.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/EchoRelay.App/Forms/Controls/AccountSelector.Designer.cs b/EchoRelay.App/Forms/Controls/AccountSelector.Designer.cs new file mode 100644 index 0000000..4e8a23b --- /dev/null +++ b/EchoRelay.App/Forms/Controls/AccountSelector.Designer.cs @@ -0,0 +1,112 @@ +namespace EchoRelay.App.Forms.Controls +{ + partial class AccountSelector + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + splitContainer1 = new SplitContainer(); + listAccounts = new ListView(); + columnHeaderUserId = new ColumnHeader(); + accountEditor = new AccountEditor(); + ((System.ComponentModel.ISupportInitialize)splitContainer1).BeginInit(); + splitContainer1.Panel1.SuspendLayout(); + splitContainer1.Panel2.SuspendLayout(); + splitContainer1.SuspendLayout(); + SuspendLayout(); + // + // splitContainer1 + // + splitContainer1.Dock = DockStyle.Fill; + splitContainer1.FixedPanel = FixedPanel.Panel1; + splitContainer1.Location = new Point(0, 0); + splitContainer1.Name = "splitContainer1"; + // + // splitContainer1.Panel1 + // + splitContainer1.Panel1.Controls.Add(listAccounts); + // + // splitContainer1.Panel2 + // + splitContainer1.Panel2.Controls.Add(accountEditor); + splitContainer1.Size = new Size(829, 474); + splitContainer1.SplitterDistance = 176; + splitContainer1.TabIndex = 0; + // + // listAccounts + // + listAccounts.Columns.AddRange(new ColumnHeader[] { columnHeaderUserId }); + listAccounts.Dock = DockStyle.Fill; + listAccounts.FullRowSelect = true; + listAccounts.GridLines = true; + listAccounts.HeaderStyle = ColumnHeaderStyle.Nonclickable; + listAccounts.LabelWrap = false; + listAccounts.Location = new Point(0, 0); + listAccounts.MultiSelect = false; + listAccounts.Name = "listAccounts"; + listAccounts.Size = new Size(176, 474); + listAccounts.Sorting = SortOrder.Ascending; + listAccounts.TabIndex = 2; + listAccounts.UseCompatibleStateImageBehavior = false; + listAccounts.View = View.Details; + listAccounts.SelectedIndexChanged += listAccounts_SelectedIndexChanged; + // + // columnHeaderUserId + // + columnHeaderUserId.Text = "User Identifier"; + columnHeaderUserId.Width = 150; + // + // accountEditor + // + accountEditor.Account = null; + accountEditor.Dock = DockStyle.Top; + accountEditor.Location = new Point(0, 0); + accountEditor.Name = "accountEditor"; + accountEditor.Size = new Size(649, 177); + accountEditor.TabIndex = 0; + // + // AccountSelector + // + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + Controls.Add(splitContainer1); + Name = "AccountSelector"; + Size = new Size(829, 474); + splitContainer1.Panel1.ResumeLayout(false); + splitContainer1.Panel2.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)splitContainer1).EndInit(); + splitContainer1.ResumeLayout(false); + ResumeLayout(false); + } + + #endregion + + private SplitContainer splitContainer1; + private ListView listAccounts; + private ColumnHeader columnHeaderUserId; + private AccountEditor accountEditor; + } +} diff --git a/EchoRelay.App/Forms/Controls/AccountSelector.cs b/EchoRelay.App/Forms/Controls/AccountSelector.cs new file mode 100644 index 0000000..4a1bfe1 --- /dev/null +++ b/EchoRelay.App/Forms/Controls/AccountSelector.cs @@ -0,0 +1,129 @@ +using EchoRelay.App.Utils; +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Storage; +using EchoRelay.Core.Server.Storage.Types; + +namespace EchoRelay.App.Forms.Controls +{ + public partial class AccountSelector : StorageEditorBase + { + public AccountEditor AccountEditor + { + get { return accountEditor; } + } + private Dictionary _items; + + public AccountSelector() + { + InitializeComponent(); + _items = new Dictionary(); + accountEditor.OnUnsavedChangesStateChange += AccountEditor_OnUnsavedChangesStateChange; + } + + private void AccountEditor_OnUnsavedChangesStateChange(StorageEditorBase storageEditor, bool hasUnsavedChanges) + { + // Forward the changed state from the editor to the selector, so state/events propagate upwards. + Changed = hasUnsavedChanges; + } + + private void UpdateItem(XPlatformId accountId) + { + // If we don't have an item for this account, create one. + if (!_items.TryGetValue(accountId, out _)) + { + ListViewItem item = new ListViewItem(accountId.ToString()); + item.Tag = accountId; + listAccounts.Items.Add(item); + _items[accountId] = item; + } + } + + private void DeleteItem(XPlatformId accountId) + { + // If we don't have an item for this account, create one. + if (_items.TryGetValue(accountId, out ListViewItem? listItem)) + { + listAccounts.Items.Remove(listItem); + _items.Remove(accountId); + } + } + + protected override void OnStorageLoaded(ServerStorage storage) + { + // Set the storage for our child editor control + accountEditor.Storage = storage; + + // Subscribe to account changes + storage.Accounts.OnChanged += Accounts_OnChanged; + + // Clear our list. + listAccounts.Items.Clear(); + + // Verify our storage is not null + if (Storage == null) + return; + + // Obtain the list of user accounts. + XPlatformId[] accountIds = Storage.Accounts.Keys() ?? Array.Empty(); + + // Load our lookup with it. + listAccounts.Items.Clear(); + foreach (XPlatformId accountId in accountIds) + UpdateItem(accountId); + + // If no item is selected, select the first item, if available. + if(listAccounts.SelectedItems.Count == 0 && listAccounts.Items.Count > 0) + { + listAccounts.Items[0].Selected = true; + listAccounts.Select(); + } + } + + protected override void OnStorageUnloaded(ServerStorage storage) + { + // Unsubscribe from account changes + storage.Accounts.OnChanged -= Accounts_OnChanged; + + // Update UI + listAccounts.Items.Clear(); + _items.Clear(); + accountEditor.Account = null; + } + + public override void SaveChanges() + { + accountEditor.SaveChanges(); + } + + public override void RevertChanges() + { + accountEditor.RevertChanges(); + } + + + + private void Accounts_OnChanged(ServerStorage storage, AccountResource resource, StorageChangeType changeType) + { + this.InvokeUIThread(() => + { + // Update the UI with our change. + if (changeType == StorageChangeType.Set) + UpdateItem(resource.Key()); + else if (changeType == StorageChangeType.Deleted) + DeleteItem(resource.Key()); + + // If the account changed was the one selected, update the account editor's account reference. + if (listAccounts.SelectedItems.Count > 0 && (XPlatformId)listAccounts.SelectedItems[0].Tag == resource.Key()) + accountEditor.Account = resource; + }); + } + + private void listAccounts_SelectedIndexChanged(object sender, EventArgs e) + { + if (listAccounts.SelectedItems.Count <= 0) + accountEditor.Account = null; + else + accountEditor.Account = Storage?.Accounts.Get((XPlatformId)listAccounts.SelectedItems[0].Tag); + } + } +} diff --git a/EchoRelay.App/Forms/Controls/AccountSelector.resx b/EchoRelay.App/Forms/Controls/AccountSelector.resx new file mode 100644 index 0000000..a395bff --- /dev/null +++ b/EchoRelay.App/Forms/Controls/AccountSelector.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/EchoRelay.App/Forms/Controls/ChannelInfoEditor.Designer.cs b/EchoRelay.App/Forms/Controls/ChannelInfoEditor.Designer.cs new file mode 100644 index 0000000..ec5fea0 --- /dev/null +++ b/EchoRelay.App/Forms/Controls/ChannelInfoEditor.Designer.cs @@ -0,0 +1,197 @@ +namespace EchoRelay.App.Forms.Controls +{ + partial class ChannelInfoEditor + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + lblChannelHeader = new Label(); + comboBoxChannel = new ComboBox(); + lblNameHeader = new Label(); + txtName = new TextBox(); + lblDescriptionHeader = new Label(); + txtDescription = new RichTextBox(); + txtRules = new RichTextBox(); + lblRules = new Label(); + lblLinkHeader = new Label(); + txtLink = new TextBox(); + lblRulesVersionHeader = new Label(); + numRulesVersion = new NumericUpDown(); + ((System.ComponentModel.ISupportInitialize)numRulesVersion).BeginInit(); + SuspendLayout(); + // + // lblChannelHeader + // + lblChannelHeader.AutoSize = true; + lblChannelHeader.Location = new Point(3, 9); + lblChannelHeader.Name = "lblChannelHeader"; + lblChannelHeader.Size = new Size(54, 15); + lblChannelHeader.TabIndex = 0; + lblChannelHeader.Text = "Channel:"; + // + // comboBoxChannel + // + comboBoxChannel.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + comboBoxChannel.DropDownStyle = ComboBoxStyle.DropDownList; + comboBoxChannel.FormattingEnabled = true; + comboBoxChannel.Location = new Point(114, 6); + comboBoxChannel.Name = "comboBoxChannel"; + comboBoxChannel.Size = new Size(345, 23); + comboBoxChannel.TabIndex = 1; + comboBoxChannel.SelectedIndexChanged += comboBoxChannel_SelectedIndexChanged; + // + // lblNameHeader + // + lblNameHeader.AutoSize = true; + lblNameHeader.Location = new Point(3, 38); + lblNameHeader.Name = "lblNameHeader"; + lblNameHeader.Size = new Size(42, 15); + lblNameHeader.TabIndex = 2; + lblNameHeader.Text = "Name:"; + // + // txtName + // + txtName.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + txtName.Location = new Point(114, 35); + txtName.Name = "txtName"; + txtName.Size = new Size(345, 23); + txtName.TabIndex = 3; + txtName.TextChanged += onAnyValueChanged; + // + // lblDescriptionHeader + // + lblDescriptionHeader.AutoSize = true; + lblDescriptionHeader.Location = new Point(3, 67); + lblDescriptionHeader.Name = "lblDescriptionHeader"; + lblDescriptionHeader.Size = new Size(70, 15); + lblDescriptionHeader.TabIndex = 4; + lblDescriptionHeader.Text = "Description:"; + // + // txtDescription + // + txtDescription.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + txtDescription.Location = new Point(114, 67); + txtDescription.Name = "txtDescription"; + txtDescription.Size = new Size(345, 96); + txtDescription.TabIndex = 5; + txtDescription.Text = ""; + txtDescription.TextChanged += onAnyValueChanged; + // + // txtRules + // + txtRules.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + txtRules.Location = new Point(114, 169); + txtRules.Name = "txtRules"; + txtRules.Size = new Size(345, 96); + txtRules.TabIndex = 7; + txtRules.Text = ""; + txtRules.TextChanged += onAnyValueChanged; + // + // lblRules + // + lblRules.AutoSize = true; + lblRules.Location = new Point(3, 169); + lblRules.Name = "lblRules"; + lblRules.Size = new Size(38, 15); + lblRules.TabIndex = 6; + lblRules.Text = "Rules:"; + // + // lblLinkHeader + // + lblLinkHeader.AutoSize = true; + lblLinkHeader.Location = new Point(3, 303); + lblLinkHeader.Name = "lblLinkHeader"; + lblLinkHeader.Size = new Size(32, 15); + lblLinkHeader.TabIndex = 8; + lblLinkHeader.Text = "Link:"; + // + // txtLink + // + txtLink.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + txtLink.Location = new Point(114, 300); + txtLink.Name = "txtLink"; + txtLink.Size = new Size(345, 23); + txtLink.TabIndex = 9; + txtLink.TextChanged += onAnyValueChanged; + // + // lblRulesVersionHeader + // + lblRulesVersionHeader.AutoSize = true; + lblRulesVersionHeader.Location = new Point(3, 273); + lblRulesVersionHeader.Name = "lblRulesVersionHeader"; + lblRulesVersionHeader.Size = new Size(79, 15); + lblRulesVersionHeader.TabIndex = 10; + lblRulesVersionHeader.Text = "Rules Version:"; + // + // numRulesVersion + // + numRulesVersion.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + numRulesVersion.Location = new Point(114, 271); + numRulesVersion.Maximum = new decimal(new int[] { -1, -1, 0, 0 }); + numRulesVersion.Name = "numRulesVersion"; + numRulesVersion.Size = new Size(345, 23); + numRulesVersion.TabIndex = 11; + numRulesVersion.ValueChanged += onAnyValueChanged; + // + // ChannelInfoEditor + // + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + Controls.Add(numRulesVersion); + Controls.Add(lblRulesVersionHeader); + Controls.Add(txtLink); + Controls.Add(lblLinkHeader); + Controls.Add(txtRules); + Controls.Add(lblRules); + Controls.Add(txtDescription); + Controls.Add(lblDescriptionHeader); + Controls.Add(txtName); + Controls.Add(lblNameHeader); + Controls.Add(comboBoxChannel); + Controls.Add(lblChannelHeader); + Name = "ChannelInfoEditor"; + Size = new Size(462, 331); + ((System.ComponentModel.ISupportInitialize)numRulesVersion).EndInit(); + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private Label lblChannelHeader; + private ComboBox comboBoxChannel; + private Label lblNameHeader; + private TextBox txtName; + private Label lblDescriptionHeader; + private RichTextBox txtDescription; + private RichTextBox txtRules; + private Label lblRules; + private Label lblLinkHeader; + private TextBox txtLink; + private Label lblRulesVersionHeader; + private NumericUpDown numRulesVersion; + } +} diff --git a/EchoRelay.App/Forms/Controls/ChannelInfoEditor.cs b/EchoRelay.App/Forms/Controls/ChannelInfoEditor.cs new file mode 100644 index 0000000..c07e9d0 --- /dev/null +++ b/EchoRelay.App/Forms/Controls/ChannelInfoEditor.cs @@ -0,0 +1,153 @@ +using EchoRelay.App.Utils; +using EchoRelay.Core.Server.Storage; +using EchoRelay.Core.Server.Storage.Types; + +namespace EchoRelay.App.Forms.Controls +{ + public partial class ChannelInfoEditor : StorageEditorBase + { + /// + /// The channel info the UI control should bind to. + /// + private ChannelInfoResource? _channelInfo; + private int _lastIndex = -1; + + public ChannelInfoEditor() + { + InitializeComponent(); + } + + private void RefreshChannelInfo() + { + // Reset our state + Changed = false; + _lastIndex = -1; + + // Update the UI elements from channel info properties. + comboBoxChannel.Items.Clear(); + if (_channelInfo != null) + { + foreach (ChannelInfoResource.Channel channel in _channelInfo.Group) + { + comboBoxChannel.Items.Add(channel.ChannelUUID); + } + } + + // Select the item to trigger its refreshing. + comboBoxChannel.SelectedIndex = Math.Min(0, comboBoxChannel.Items.Count - 1); + } + + protected override void OnStorageLoaded(ServerStorage storage) + { + // Subscribe to channel info changes + storage.ChannelInfo.OnChanged += ChannelInfo_OnChanged; + + // Load the channel info resource + _channelInfo = storage.ChannelInfo.Get(); + + // Refresh UI changes + RefreshChannelInfo(); + } + + + protected override void OnStorageUnloaded(ServerStorage storage) + { + // Unsubscribe from channel info changes + storage.ChannelInfo.OnChanged -= ChannelInfo_OnChanged; + + // Unload the channel info resource + _channelInfo = null; + + // Refresh UI changes + } + + private void ChannelInfo_OnChanged(ServerStorage storage, ChannelInfoResource resource, StorageChangeType changeType) + { + this.InvokeUIThread(() => + { + if (changeType == StorageChangeType.Set) + _channelInfo = resource; + else if (changeType == StorageChangeType.Deleted) + _channelInfo = null; + + RefreshChannelInfo(); + }); + } + + public override void SaveChanges() + { + // Verify channel info changes were made. + if (_channelInfo == null || comboBoxChannel.SelectedIndex < 0 || !Changed) + return; + + // Obtain the channel. + ChannelInfoResource.Channel channel = _channelInfo.Group[comboBoxChannel.SelectedIndex]; + + // Update the channel info. + channel.Name = txtName.Text; + channel.Description = txtDescription.Text; + channel.Rules = txtRules.Text; + channel.RulesVersion = (ulong)numRulesVersion.Value; + channel.Link = txtLink.Text; + + // Update the channel info in storage, if storage was provided. + Storage?.ChannelInfo.Set(_channelInfo); + Changed = false; + } + + public override void RevertChanges() + { + // Update the UI elements from channel info properties. + if (_channelInfo == null || comboBoxChannel.SelectedIndex == -1) + { + txtName.Text = ""; + txtDescription.Text = ""; + txtRules.Text = ""; + numRulesVersion.Value = 0; + txtLink.Text = ""; + } + else + { + // Obtain the channel + ChannelInfoResource.Channel channel = _channelInfo.Group[comboBoxChannel.SelectedIndex]; + txtName.Text = channel.Name; + txtDescription.Text = channel.Description; + txtRules.Text = channel.Rules; + numRulesVersion.Value = channel.RulesVersion; + txtLink.Text = channel.Link; + } + Changed = false; + } + + private void comboBoxChannel_SelectedIndexChanged(object sender, EventArgs e) + { + // If we have changes on this current channel, but the user is trying to view another channel, ask them to save changes. + // If they wish to, we restore the selected index from before, if not, we allow it to change. + if (Changed) + { + // If we just rolled back to the index we restored, do nothing (do not revert changes, leave them as they are, we don't want to reset any UI value state). + if (comboBoxChannel.SelectedIndex == _lastIndex) + return; + + // Warn the user if they'd like to proceed. If they do, we simply set our index and reload changes. If they don't, we restore our previous selected item (the if statement above will avoid reloading changes + // when this event is triggered again). + if (MessageBox.Show("Unsaved changes for the current channel will be lost if you proceed without saving. Would you like to proceed?", "Echo Relay: Warning", MessageBoxButtons.YesNo) == DialogResult.No) + { + comboBoxChannel.SelectedIndex = _lastIndex; + return; + } + } + + // Store the last selected index so the mechanism used to cancel channel item selection when you have unsaved changes can avoid reloading values when the index is reset to this value. + _lastIndex = comboBoxChannel.SelectedIndex; + + // Reload changes for the selected item. + RevertChanges(); + } + + private void onAnyValueChanged(object sender, EventArgs e) + { + Changed = true; + } + } +} diff --git a/EchoRelay.App/Forms/Controls/ChannelInfoEditor.resx b/EchoRelay.App/Forms/Controls/ChannelInfoEditor.resx new file mode 100644 index 0000000..a395bff --- /dev/null +++ b/EchoRelay.App/Forms/Controls/ChannelInfoEditor.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/EchoRelay.App/Forms/Controls/GameServersControl.Designer.cs b/EchoRelay.App/Forms/Controls/GameServersControl.Designer.cs new file mode 100644 index 0000000..de2cb42 --- /dev/null +++ b/EchoRelay.App/Forms/Controls/GameServersControl.Designer.cs @@ -0,0 +1,298 @@ +namespace EchoRelay.App.Forms.Controls +{ + partial class GameServersControl + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + listGameServers = new ListView(); + columnHeaderServerId = new ColumnHeader(); + columnHeaderIP = new ColumnHeader(); + columnHeaderBroadcastPort = new ColumnHeader(); + columnHeaderGametype = new ColumnHeader(); + columnHeaderLevel = new ColumnHeader(); + columnHeaderPlayerCount = new ColumnHeader(); + columnHeaderLobbyType = new ColumnHeader(); + columnHeaderLocked = new ColumnHeader(); + columnHeaderChannel = new ColumnHeader(); + columnHeaderSessionId = new ColumnHeader(); + splitContainer1 = new SplitContainer(); + groupBoxGameServers = new GroupBox(); + groupBoxPlayers = new GroupBox(); + listPlayers = new ListView(); + columnHeaderUserId = new ColumnHeader(); + columnHeaderUserDisplayName = new ColumnHeader(); + columnHeaderUserIP = new ColumnHeader(); + columnHeaderPlayerSession = new ColumnHeader(); + contextMenuPlayers = new ContextMenuStrip(components); + copyUserIdToolStripMenuItem = new ToolStripMenuItem(); + copyIPAddressToolStripMenuItem = new ToolStripMenuItem(); + toolStripSeparator1 = new ToolStripSeparator(); + kickToolStripMenuItem = new ToolStripMenuItem(); + contextMenuGameServers = new ContextMenuStrip(components); + copySessionLobbyIdToolStripMenuItem = new ToolStripMenuItem(); + ((System.ComponentModel.ISupportInitialize)splitContainer1).BeginInit(); + splitContainer1.Panel1.SuspendLayout(); + splitContainer1.Panel2.SuspendLayout(); + splitContainer1.SuspendLayout(); + groupBoxGameServers.SuspendLayout(); + groupBoxPlayers.SuspendLayout(); + contextMenuPlayers.SuspendLayout(); + contextMenuGameServers.SuspendLayout(); + SuspendLayout(); + // + // listGameServers + // + listGameServers.Columns.AddRange(new ColumnHeader[] { columnHeaderServerId, columnHeaderIP, columnHeaderBroadcastPort, columnHeaderGametype, columnHeaderLevel, columnHeaderPlayerCount, columnHeaderLobbyType, columnHeaderLocked, columnHeaderChannel, columnHeaderSessionId }); + listGameServers.Dock = DockStyle.Fill; + listGameServers.FullRowSelect = true; + listGameServers.GridLines = true; + listGameServers.Location = new Point(3, 19); + listGameServers.MultiSelect = false; + listGameServers.Name = "listGameServers"; + listGameServers.Size = new Size(1276, 386); + listGameServers.TabIndex = 1; + listGameServers.UseCompatibleStateImageBehavior = false; + listGameServers.View = View.Details; + listGameServers.SelectedIndexChanged += listGameServers_SelectedIndexChanged; + // + // columnHeaderServerId + // + columnHeaderServerId.Text = "Server Identifier"; + columnHeaderServerId.Width = 150; + // + // columnHeaderIP + // + columnHeaderIP.Text = "IP"; + columnHeaderIP.Width = 110; + // + // columnHeaderBroadcastPort + // + columnHeaderBroadcastPort.Text = "Port (UDP)"; + columnHeaderBroadcastPort.Width = 75; + // + // columnHeaderGametype + // + columnHeaderGametype.Text = "Gametype"; + columnHeaderGametype.Width = 150; + // + // columnHeaderLevel + // + columnHeaderLevel.Text = "Level"; + columnHeaderLevel.Width = 150; + // + // columnHeaderPlayerCount + // + columnHeaderPlayerCount.Text = "Players"; + // + // columnHeaderLobbyType + // + columnHeaderLobbyType.Text = "Type"; + columnHeaderLobbyType.Width = 80; + // + // columnHeaderLocked + // + columnHeaderLocked.Text = "Is Locked"; + columnHeaderLocked.Width = 80; + // + // columnHeaderChannel + // + columnHeaderChannel.Text = "Channel"; + columnHeaderChannel.Width = 120; + // + // columnHeaderSessionId + // + columnHeaderSessionId.Text = "Session/Lobby Identifier"; + columnHeaderSessionId.Width = 300; + // + // splitContainer1 + // + splitContainer1.Dock = DockStyle.Fill; + splitContainer1.Location = new Point(0, 0); + splitContainer1.Name = "splitContainer1"; + splitContainer1.Orientation = Orientation.Horizontal; + // + // splitContainer1.Panel1 + // + splitContainer1.Panel1.Controls.Add(groupBoxGameServers); + // + // splitContainer1.Panel2 + // + splitContainer1.Panel2.Controls.Add(groupBoxPlayers); + splitContainer1.Size = new Size(1282, 816); + splitContainer1.SplitterDistance = 408; + splitContainer1.TabIndex = 2; + // + // groupBoxGameServers + // + groupBoxGameServers.Controls.Add(listGameServers); + groupBoxGameServers.Dock = DockStyle.Fill; + groupBoxGameServers.Location = new Point(0, 0); + groupBoxGameServers.Name = "groupBoxGameServers"; + groupBoxGameServers.Size = new Size(1282, 408); + groupBoxGameServers.TabIndex = 2; + groupBoxGameServers.TabStop = false; + groupBoxGameServers.Text = "Game servers"; + // + // groupBoxPlayers + // + groupBoxPlayers.Controls.Add(listPlayers); + groupBoxPlayers.Dock = DockStyle.Fill; + groupBoxPlayers.Location = new Point(0, 0); + groupBoxPlayers.Name = "groupBoxPlayers"; + groupBoxPlayers.Size = new Size(1282, 404); + groupBoxPlayers.TabIndex = 3; + groupBoxPlayers.TabStop = false; + groupBoxPlayers.Text = "Players in selected game server"; + // + // listPlayers + // + listPlayers.Columns.AddRange(new ColumnHeader[] { columnHeaderUserId, columnHeaderUserDisplayName, columnHeaderUserIP, columnHeaderPlayerSession }); + listPlayers.Dock = DockStyle.Fill; + listPlayers.FullRowSelect = true; + listPlayers.GridLines = true; + listPlayers.Location = new Point(3, 19); + listPlayers.Name = "listPlayers"; + listPlayers.Size = new Size(1276, 382); + listPlayers.TabIndex = 2; + listPlayers.UseCompatibleStateImageBehavior = false; + listPlayers.View = View.Details; + listPlayers.SelectedIndexChanged += listPlayers_SelectedIndexChanged; + // + // columnHeaderUserId + // + columnHeaderUserId.Text = "User Id"; + columnHeaderUserId.Width = 200; + // + // columnHeaderUserDisplayName + // + columnHeaderUserDisplayName.Text = "User Display Name"; + columnHeaderUserDisplayName.Width = 200; + // + // columnHeaderUserIP + // + columnHeaderUserIP.Text = "IP"; + columnHeaderUserIP.Width = 120; + // + // columnHeaderPlayerSession + // + columnHeaderPlayerSession.Text = "Player Session"; + columnHeaderPlayerSession.Width = 300; + // + // contextMenuPlayers + // + contextMenuPlayers.Items.AddRange(new ToolStripItem[] { copyUserIdToolStripMenuItem, copyIPAddressToolStripMenuItem, toolStripSeparator1, kickToolStripMenuItem }); + contextMenuPlayers.Name = "contextMenuPlayers"; + contextMenuPlayers.Size = new Size(161, 76); + // + // copyUserIdToolStripMenuItem + // + copyUserIdToolStripMenuItem.Name = "copyUserIdToolStripMenuItem"; + copyUserIdToolStripMenuItem.Size = new Size(160, 22); + copyUserIdToolStripMenuItem.Text = "Copy User Id"; + copyUserIdToolStripMenuItem.Click += copyUserIdToolStripMenuItem_Click; + // + // copyIPAddressToolStripMenuItem + // + copyIPAddressToolStripMenuItem.Name = "copyIPAddressToolStripMenuItem"; + copyIPAddressToolStripMenuItem.Size = new Size(160, 22); + copyIPAddressToolStripMenuItem.Text = "Copy IP Address"; + copyIPAddressToolStripMenuItem.Click += copyIPAddressToolStripMenuItem_Click; + // + // toolStripSeparator1 + // + toolStripSeparator1.Name = "toolStripSeparator1"; + toolStripSeparator1.Size = new Size(157, 6); + // + // kickToolStripMenuItem + // + kickToolStripMenuItem.Name = "kickToolStripMenuItem"; + kickToolStripMenuItem.Size = new Size(160, 22); + kickToolStripMenuItem.Text = "Kick"; + kickToolStripMenuItem.Click += kickToolStripMenuItem_Click; + // + // contextMenuGameServers + // + contextMenuGameServers.Items.AddRange(new ToolStripItem[] { copySessionLobbyIdToolStripMenuItem }); + contextMenuGameServers.Name = "contextMenuGameServers"; + contextMenuGameServers.Size = new Size(202, 26); + // + // copySessionLobbyIdToolStripMenuItem + // + copySessionLobbyIdToolStripMenuItem.Name = "copySessionLobbyIdToolStripMenuItem"; + copySessionLobbyIdToolStripMenuItem.Size = new Size(201, 22); + copySessionLobbyIdToolStripMenuItem.Text = "Copy Session / Lobby Id"; + copySessionLobbyIdToolStripMenuItem.Click += copySessionLobbyIdToolStripMenuItem_Click; + // + // GameServersControl + // + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + Controls.Add(splitContainer1); + Name = "GameServersControl"; + Size = new Size(1282, 816); + splitContainer1.Panel1.ResumeLayout(false); + splitContainer1.Panel2.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)splitContainer1).EndInit(); + splitContainer1.ResumeLayout(false); + groupBoxGameServers.ResumeLayout(false); + groupBoxPlayers.ResumeLayout(false); + contextMenuPlayers.ResumeLayout(false); + contextMenuGameServers.ResumeLayout(false); + ResumeLayout(false); + } + + #endregion + + private ListView listGameServers; + private ColumnHeader columnHeaderIP; + private ColumnHeader columnHeaderGametype; + private ColumnHeader columnHeaderLevel; + private SplitContainer splitContainer1; + private ColumnHeader columnHeaderBroadcastPort; + private ColumnHeader columnHeaderServerId; + private ColumnHeader columnHeaderPlayerCount; + private ColumnHeader columnHeaderLobbyType; + private ColumnHeader columnHeaderSessionId; + private ListView listPlayers; + private ColumnHeader columnHeaderUserId; + private ColumnHeader columnHeaderUserDisplayName; + private ColumnHeader columnHeaderUserIP; + private ColumnHeader columnHeaderPlayerSession; + private GroupBox groupBoxGameServers; + private GroupBox groupBoxPlayers; + private ContextMenuStrip contextMenuPlayers; + private ToolStripMenuItem kickToolStripMenuItem; + private ColumnHeader columnHeaderLocked; + private ColumnHeader columnHeaderChannel; + private ContextMenuStrip contextMenuGameServers; + private ToolStripMenuItem copySessionLobbyIdToolStripMenuItem; + private ToolStripMenuItem copyUserIdToolStripMenuItem; + private ToolStripMenuItem copyIPAddressToolStripMenuItem; + private ToolStripSeparator toolStripSeparator1; + } +} diff --git a/EchoRelay.App/Forms/Controls/GameServersControl.cs b/EchoRelay.App/Forms/Controls/GameServersControl.cs new file mode 100644 index 0000000..b1a0806 --- /dev/null +++ b/EchoRelay.App/Forms/Controls/GameServersControl.cs @@ -0,0 +1,192 @@ +using EchoRelay.Core.Server.Services; +using EchoRelay.Core.Server.Services.ServerDB; + +namespace EchoRelay.App.Forms.Controls +{ + public partial class GameServersControl : UserControl + { + /// + /// A lookup of to s used in this control. + /// + private Dictionary _items; + + /// + /// The total amount of peer connections across all services. + /// hold on + public int GameServerCount + { + get + { + return listGameServers.Items.Count; + } + } + + public GameServersControl() + { + InitializeComponent(); + _items = new Dictionary(); + } + + public void AddOrUpdateGameServer(RegisteredGameServer gameServer) + { + // Obtain an existing list view item for this game server, or create one. + ListViewItem? listItem = null; + if (!_items.TryGetValue(gameServer.ServerId, out listItem)) + { + listItem = new ListViewItem(); + for (int i = 0; i < 9; i++) + listItem.SubItems.Add(""); + listGameServers.Items.Add(listItem); + _items[gameServer.ServerId] = listItem; + } + + // Update the subitems of the list. + listItem.SubItems[0].Text = gameServer.ServerId.ToString(); + listItem.SubItems[1].Text = gameServer.ExternalAddress.ToString(); + listItem.SubItems[2].Text = gameServer.Port.ToString(); + if (gameServer.SessionGameTypeSymbol != null) + { + listItem.SubItems[3].Text = gameServer.Peer.Service.Server.SymbolCache.GetName(gameServer.SessionGameTypeSymbol.Value) ?? $"unknown({gameServer.SessionGameTypeSymbol.Value})"; + } + else + { + listItem.SubItems[3].Text = "-"; + } + if (gameServer.SessionLevelSymbol != null) + { + listItem.SubItems[4].Text = gameServer.Peer.Service.Server.SymbolCache.GetName(gameServer.SessionLevelSymbol.Value) ?? $"unknown({gameServer.SessionLevelSymbol.Value})"; + } + else + { + listItem.SubItems[4].Text = "-"; + } + listItem.SubItems[5].Text = $"{gameServer.SessionPlayerCount}/{gameServer.SessionPlayerLimit}"; + listItem.SubItems[6].Text = gameServer.SessionLobbyType.ToString(); + listItem.SubItems[7].Text = gameServer.SessionLocked.ToString(); + listItem.SubItems[8].Text = gameServer.SessionChannel?.ToString() ?? "-"; + listItem.SubItems[9].Text = gameServer.SessionId?.ToString() ?? "-"; + listItem.Tag = gameServer; + + // Update the selected item + RefreshSelectedGameServer(); + } + + public void RemoveGameServer(RegisteredGameServer gameServer) + { + // If we have a list item for this game server, remove it. + if (_items.TryGetValue(gameServer.ServerId, out var listItem)) + { + listGameServers.Items.Remove(listItem!); + _items.Remove(gameServer.ServerId); + } + } + + private void listGameServers_SelectedIndexChanged(object sender, EventArgs e) + { + // Refresh the game server UI + RefreshSelectedGameServer(); + + // If we have no selected items, remove the context menu. Otherwise, set it appropriately. + listGameServers.ContextMenuStrip = listGameServers.SelectedItems.Count > 0 ? contextMenuGameServers : null; + } + + private void RefreshSelectedGameServer() + { + // Obtain the selected item, if any. + ListViewItem? selectedItem = null; + if (listGameServers.SelectedItems.Count > 0) + selectedItem = listGameServers.SelectedItems[0]; + + // Clear our players table information. + listPlayers.Items.Clear(); + + // Obtain the game server associated with the list item + if (selectedItem != null) + { + // Create items for every player in the game server. + RegisteredGameServer selectedGameServer = (RegisteredGameServer)selectedItem.Tag; + var playersInfo = selectedGameServer.GetPlayers().Result; + foreach (var playerInfo in playersInfo) + { + // Create a list item for this player + ListViewItem playerListItem = new ListViewItem(playerInfo.Peer?.UserId?.ToString() ?? "-"); + playerListItem.SubItems.Add(playerInfo.Peer?.UserDisplayName?.ToString() ?? "-"); + playerListItem.SubItems.Add(playerInfo.Peer?.Address.ToString() ?? "-"); + playerListItem.SubItems.Add(playerInfo.PlayerSession.ToString()); + + // Set the tag as the player session identifier. + playerListItem.Tag = playerInfo.PlayerSession; + + // Add the item to our players list + listPlayers.Items.Add(playerListItem); + } + } + } + + private void listPlayers_SelectedIndexChanged(object sender, EventArgs e) + { + // If we have no selected items, remove the context menu. Otherwise, set it appropriately. + listPlayers.ContextMenuStrip = listPlayers.SelectedItems.Count > 0 ? contextMenuPlayers : null; + } + + private async void kickToolStripMenuItem_Click(object sender, EventArgs e) + { + // Obtain the selected game server, if any. + if (listGameServers.SelectedItems.Count <= 0) + return; + RegisteredGameServer selectedGameServer = (RegisteredGameServer)listGameServers.SelectedItems[0].Tag; + + // Loop for all selected players + foreach (ListViewItem item in listPlayers.SelectedItems) + { + // Obtain the player session + Guid playerSession = (Guid)item.Tag; + + // Try to send a rejection message for this player session to the game server. + await selectedGameServer.KickPlayer(playerSession); + } + } + + private void copySessionLobbyIdToolStripMenuItem_Click(object sender, EventArgs e) + { + // Obtain the selected game server, if any. + if (listGameServers.SelectedItems.Count <= 0) + return; + RegisteredGameServer selectedGameServer = (RegisteredGameServer)listGameServers.SelectedItems[0].Tag; + + // Set the copied text if the have a lobby identifier. + if (selectedGameServer.SessionId != null) + Clipboard.SetText(selectedGameServer.SessionId.Value.ToString()); + } + + private async void copyUserIdToolStripMenuItem_Click(object sender, EventArgs e) + { + // Obtain the selected game server, if any. + if (listGameServers.SelectedItems.Count <= 0 || listPlayers.SelectedItems.Count <= 0) + return; + RegisteredGameServer selectedGameServer = (RegisteredGameServer)listGameServers.SelectedItems[0].Tag; + + // Obtain the selected peer + Peer? selectedPeer = await selectedGameServer.GetPeer((Guid)listPlayers.SelectedItems[0].Tag); + + // If the field exists, set it to the clipboard. + if (selectedPeer?.UserId != null) + Clipboard.SetText(selectedPeer.UserId.ToString()); + } + + private async void copyIPAddressToolStripMenuItem_Click(object sender, EventArgs e) + { + // Obtain the selected game server, if any. + if (listGameServers.SelectedItems.Count <= 0 || listPlayers.SelectedItems.Count <= 0) + return; + RegisteredGameServer selectedGameServer = (RegisteredGameServer)listGameServers.SelectedItems[0].Tag; + + // Obtain the selected peer + Peer? selectedPeer = await selectedGameServer.GetPeer((Guid)listPlayers.SelectedItems[0].Tag); + + // If the peer exists, set it to the clipboard. + if (selectedPeer != null) + Clipboard.SetText(selectedPeer.Address.ToString()); + } + } +} diff --git a/EchoRelay.App/Forms/Controls/GameServersControl.resx b/EchoRelay.App/Forms/Controls/GameServersControl.resx new file mode 100644 index 0000000..6b6793d --- /dev/null +++ b/EchoRelay.App/Forms/Controls/GameServersControl.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 17, 17 + + + 179, 17 + + \ No newline at end of file diff --git a/EchoRelay.App/Forms/Controls/LoginSettingsEditor.Designer.cs b/EchoRelay.App/Forms/Controls/LoginSettingsEditor.Designer.cs new file mode 100644 index 0000000..493e68d --- /dev/null +++ b/EchoRelay.App/Forms/Controls/LoginSettingsEditor.Designer.cs @@ -0,0 +1,154 @@ +namespace EchoRelay.App.Forms.Controls +{ + partial class LoginSettingsEditor + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + chkIAPUnlocked = new CheckBox(); + chkRemoteLogSocial = new CheckBox(); + chkRemoteLogWarnings = new CheckBox(); + chkRemoteLogErrors = new CheckBox(); + chkRemoteLogRichPresence = new CheckBox(); + chkRemoteLogMetrics = new CheckBox(); + lblEnvironmentHeader = new Label(); + txtEnvironment = new TextBox(); + SuspendLayout(); + // + // chkIAPUnlocked + // + chkIAPUnlocked.AutoSize = true; + chkIAPUnlocked.Location = new Point(128, 39); + chkIAPUnlocked.Name = "chkIAPUnlocked"; + chkIAPUnlocked.Size = new Size(198, 19); + chkIAPUnlocked.TabIndex = 0; + chkIAPUnlocked.Text = "In-app purchases (IAP) unlocked"; + chkIAPUnlocked.UseVisualStyleBackColor = true; + chkIAPUnlocked.CheckedChanged += onAnyValueChanged; + // + // chkRemoteLogSocial + // + chkRemoteLogSocial.AutoSize = true; + chkRemoteLogSocial.Location = new Point(128, 64); + chkRemoteLogSocial.Name = "chkRemoteLogSocial"; + chkRemoteLogSocial.Size = new Size(127, 19); + chkRemoteLogSocial.TabIndex = 1; + chkRemoteLogSocial.Text = "Remote Log: Social"; + chkRemoteLogSocial.UseVisualStyleBackColor = true; + chkRemoteLogSocial.CheckedChanged += onAnyValueChanged; + // + // chkRemoteLogWarnings + // + chkRemoteLogWarnings.AutoSize = true; + chkRemoteLogWarnings.Location = new Point(128, 89); + chkRemoteLogWarnings.Name = "chkRemoteLogWarnings"; + chkRemoteLogWarnings.Size = new Size(146, 19); + chkRemoteLogWarnings.TabIndex = 2; + chkRemoteLogWarnings.Text = "Remote Log: Warnings"; + chkRemoteLogWarnings.UseVisualStyleBackColor = true; + chkRemoteLogWarnings.CheckedChanged += onAnyValueChanged; + // + // chkRemoteLogErrors + // + chkRemoteLogErrors.AutoSize = true; + chkRemoteLogErrors.Location = new Point(128, 114); + chkRemoteLogErrors.Name = "chkRemoteLogErrors"; + chkRemoteLogErrors.Size = new Size(126, 19); + chkRemoteLogErrors.TabIndex = 3; + chkRemoteLogErrors.Text = "Remote Log: Errors"; + chkRemoteLogErrors.UseVisualStyleBackColor = true; + chkRemoteLogErrors.CheckedChanged += onAnyValueChanged; + // + // chkRemoteLogRichPresence + // + chkRemoteLogRichPresence.AutoSize = true; + chkRemoteLogRichPresence.Location = new Point(128, 139); + chkRemoteLogRichPresence.Name = "chkRemoteLogRichPresence"; + chkRemoteLogRichPresence.Size = new Size(169, 19); + chkRemoteLogRichPresence.TabIndex = 4; + chkRemoteLogRichPresence.Text = "Remote Log: Rich Presence"; + chkRemoteLogRichPresence.UseVisualStyleBackColor = true; + chkRemoteLogRichPresence.CheckedChanged += onAnyValueChanged; + // + // chkRemoteLogMetrics + // + chkRemoteLogMetrics.AutoSize = true; + chkRemoteLogMetrics.Location = new Point(128, 164); + chkRemoteLogMetrics.Name = "chkRemoteLogMetrics"; + chkRemoteLogMetrics.Size = new Size(135, 19); + chkRemoteLogMetrics.TabIndex = 5; + chkRemoteLogMetrics.Text = "Remote Log: Metrics"; + chkRemoteLogMetrics.UseVisualStyleBackColor = true; + chkRemoteLogMetrics.CheckedChanged += onAnyValueChanged; + // + // lblEnvironmentHeader + // + lblEnvironmentHeader.AutoSize = true; + lblEnvironmentHeader.Location = new Point(3, 13); + lblEnvironmentHeader.Name = "lblEnvironmentHeader"; + lblEnvironmentHeader.Size = new Size(119, 15); + lblEnvironmentHeader.TabIndex = 6; + lblEnvironmentHeader.Text = "Environment (name):"; + // + // txtEnvironment + // + txtEnvironment.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + txtEnvironment.Location = new Point(128, 10); + txtEnvironment.Name = "txtEnvironment"; + txtEnvironment.Size = new Size(375, 23); + txtEnvironment.TabIndex = 7; + txtEnvironment.TextChanged += onAnyValueChanged; + // + // LoginSettingsEditor + // + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + Controls.Add(txtEnvironment); + Controls.Add(lblEnvironmentHeader); + Controls.Add(chkRemoteLogMetrics); + Controls.Add(chkRemoteLogRichPresence); + Controls.Add(chkRemoteLogErrors); + Controls.Add(chkRemoteLogWarnings); + Controls.Add(chkRemoteLogSocial); + Controls.Add(chkIAPUnlocked); + Name = "LoginSettingsEditor"; + Size = new Size(506, 193); + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private CheckBox chkIAPUnlocked; + private CheckBox chkRemoteLogSocial; + private CheckBox chkRemoteLogWarnings; + private CheckBox chkRemoteLogErrors; + private CheckBox chkRemoteLogRichPresence; + private CheckBox chkRemoteLogMetrics; + private Label lblEnvironmentHeader; + private TextBox txtEnvironment; + } +} diff --git a/EchoRelay.App/Forms/Controls/LoginSettingsEditor.cs b/EchoRelay.App/Forms/Controls/LoginSettingsEditor.cs new file mode 100644 index 0000000..e2c70a0 --- /dev/null +++ b/EchoRelay.App/Forms/Controls/LoginSettingsEditor.cs @@ -0,0 +1,97 @@ +using EchoRelay.App.Utils; +using EchoRelay.Core.Server.Storage; +using EchoRelay.Core.Server.Storage.Types; + +namespace EchoRelay.App.Forms.Controls +{ + public partial class LoginSettingsEditor : StorageEditorBase + { + /// + /// The login settings the UI control should bind to. + /// + private LoginSettingsResource? _settings; + + public LoginSettingsEditor() + { + InitializeComponent(); + } + + + protected override void OnStorageLoaded(ServerStorage storage) + { + // Subscribe to login settings changes + storage.LoginSettings.OnChanged += LoginSettings_OnChanged; + + // Load the login settings resource + _settings = storage.LoginSettings.Get(); + + // Refresh UI changes + RevertChanges(); + } + + + protected override void OnStorageUnloaded(ServerStorage storage) + { + // Unsubscribe from login settings changes + storage.LoginSettings.OnChanged -= LoginSettings_OnChanged; + + // Unload the login settings resource + _settings = null; + + // Refresh UI changes + RevertChanges(); + } + + private void LoginSettings_OnChanged(ServerStorage storage, LoginSettingsResource resource, StorageChangeType changeType) + { + this.InvokeUIThread(() => + { + if (changeType == StorageChangeType.Set) + _settings = resource; + else if (changeType == StorageChangeType.Deleted) + _settings = null; + + RevertChanges(); + }); + } + + public override void SaveChanges() + { + // Verify settings were set. + if (_settings == null || !Changed) + return; + + // Update the properties of the settings. + _settings.Environment = txtEnvironment.Text; + _settings.IAPUnlocked = chkIAPUnlocked.Checked; + _settings.RemoteLogSocial = chkRemoteLogSocial.Checked; + _settings.RemoteLogWarnings = chkRemoteLogWarnings.Checked; + _settings.RemoteLogErrors = chkRemoteLogErrors.Checked; + _settings.RemoteLogRichPresence = chkRemoteLogRichPresence.Checked; + _settings.RemoteLogMetrics = chkRemoteLogMetrics.Checked; + + // Update the login settings in storage, if storage was provided. + Storage?.LoginSettings.Set(_settings); + Changed = false; + } + + public override void RevertChanges() + { + // Update the UI elements from settings properties. + txtEnvironment.Text = _settings?.Environment ?? ""; + chkIAPUnlocked.Checked = _settings?.IAPUnlocked ?? false; + chkRemoteLogSocial.Checked = _settings?.RemoteLogSocial ?? false; + chkRemoteLogWarnings.Checked = _settings?.RemoteLogWarnings ?? false; + chkRemoteLogErrors.Checked = _settings?.RemoteLogErrors ?? false; + chkRemoteLogRichPresence.Checked = _settings?.RemoteLogRichPresence ?? false; + chkRemoteLogMetrics.Checked = _settings?.RemoteLogMetrics ?? false; + + Changed = false; + } + + private void onAnyValueChanged(object sender, EventArgs e) + { + Changed = true; + } + } +} diff --git a/EchoRelay.App/Forms/Controls/LoginSettingsEditor.resx b/EchoRelay.App/Forms/Controls/LoginSettingsEditor.resx new file mode 100644 index 0000000..a395bff --- /dev/null +++ b/EchoRelay.App/Forms/Controls/LoginSettingsEditor.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/EchoRelay.App/Forms/Controls/PeerConnectionsControl.Designer.cs b/EchoRelay.App/Forms/Controls/PeerConnectionsControl.Designer.cs new file mode 100644 index 0000000..e2efd5f --- /dev/null +++ b/EchoRelay.App/Forms/Controls/PeerConnectionsControl.Designer.cs @@ -0,0 +1,118 @@ +namespace EchoRelay.App.Forms.Controls +{ + partial class PeerConnectionsControl + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + listPeers = new ListView(); + columnHeaderService = new ColumnHeader(); + columnHeaderSourceIP = new ColumnHeader(); + columnHeaderSourcePort = new ColumnHeader(); + columnHeaderUserId = new ColumnHeader(); + columnHeaderInitiatedTime = new ColumnHeader(); + groupBoxConnections = new GroupBox(); + columnHeaderUserDisplayName = new ColumnHeader(); + groupBoxConnections.SuspendLayout(); + SuspendLayout(); + // + // listPeers + // + listPeers.Columns.AddRange(new ColumnHeader[] { columnHeaderService, columnHeaderSourceIP, columnHeaderSourcePort, columnHeaderUserId, columnHeaderUserDisplayName, columnHeaderInitiatedTime }); + listPeers.Dock = DockStyle.Fill; + listPeers.FullRowSelect = true; + listPeers.GridLines = true; + listPeers.Location = new Point(3, 19); + listPeers.Name = "listPeers"; + listPeers.Size = new Size(1276, 794); + listPeers.TabIndex = 0; + listPeers.UseCompatibleStateImageBehavior = false; + listPeers.View = View.Details; + // + // columnHeaderService + // + columnHeaderService.Text = "Service"; + columnHeaderService.Width = 100; + // + // columnHeaderSourceIP + // + columnHeaderSourceIP.Text = "Source IP"; + columnHeaderSourceIP.Width = 150; + // + // columnHeaderSourcePort + // + columnHeaderSourcePort.Text = "Source Port (TCP)"; + columnHeaderSourcePort.Width = 110; + // + // columnHeaderUserId + // + columnHeaderUserId.Text = "User Id"; + columnHeaderUserId.Width = 200; + // + // columnHeaderInitiatedTime + // + columnHeaderInitiatedTime.Text = "Initiated Time (UTC)"; + columnHeaderInitiatedTime.Width = 200; + // + // groupBoxConnections + // + groupBoxConnections.Controls.Add(listPeers); + groupBoxConnections.Dock = DockStyle.Fill; + groupBoxConnections.Location = new Point(0, 0); + groupBoxConnections.Name = "groupBoxConnections"; + groupBoxConnections.Size = new Size(1282, 816); + groupBoxConnections.TabIndex = 1; + groupBoxConnections.TabStop = false; + groupBoxConnections.Text = "Peer connections"; + // + // columnHeaderUserDisplayName + // + columnHeaderUserDisplayName.Text = "User Display Name"; + columnHeaderUserDisplayName.Width = 150; + // + // PeerConnectionsControl + // + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + Controls.Add(groupBoxConnections); + Name = "PeerConnectionsControl"; + Size = new Size(1282, 816); + groupBoxConnections.ResumeLayout(false); + ResumeLayout(false); + } + + #endregion + + private ListView listPeers; + private ColumnHeader columnHeaderService; + private ColumnHeader columnHeaderSourceIP; + private ColumnHeader columnHeaderUserId; + private ColumnHeader columnHeaderInitiatedTime; + private ColumnHeader columnHeaderSourcePort; + private GroupBox groupBoxConnections; + private ColumnHeader columnHeaderUserDisplayName; + } +} diff --git a/EchoRelay.App/Forms/Controls/PeerConnectionsControl.cs b/EchoRelay.App/Forms/Controls/PeerConnectionsControl.cs new file mode 100644 index 0000000..e9d7ab8 --- /dev/null +++ b/EchoRelay.App/Forms/Controls/PeerConnectionsControl.cs @@ -0,0 +1,65 @@ +using EchoRelay.Core.Server.Services; +using System.Globalization; + +namespace EchoRelay.App.Forms.Controls +{ + public partial class PeerConnectionsControl : UserControl + { + /// + /// A lookup of to s used in this control. + /// + private Dictionary _items; + + /// + /// The total amount of peer connections across all services. + /// + public int PeerCount + { + get + { + return listPeers.Items.Count; + } + } + + public PeerConnectionsControl() + { + InitializeComponent(); + _items = new Dictionary(); + } + + public void AddOrUpdatePeer(Peer peer) + { + // Obtain an existing list view item for this peer, or create one. + ListViewItem? listItem = null; + if (!_items.TryGetValue(peer.Id, out listItem)) + { + listItem = new ListViewItem(); + listItem.SubItems.Add(""); + listItem.SubItems.Add(""); + listItem.SubItems.Add(""); + listItem.SubItems.Add(""); + listItem.SubItems.Add(""); + listPeers.Items.Add(listItem); + _items[peer.Id] = listItem; + } + + // Update the subitems of the list. + listItem.SubItems[0].Text = peer.Service.Name; + listItem.SubItems[1].Text = peer.Address.ToString(); + listItem.SubItems[2].Text = peer.Port.ToString(); + listItem.SubItems[3].Text = peer.UserId?.ToString() ?? "-"; + listItem.SubItems[4].Text = peer.UserDisplayName?.ToString() ?? "-"; + listItem.SubItems[5].Text = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture); + } + + public void RemovePeer(Peer peer) + { + // If we have a list item for this peer, remove it. + if (_items.TryGetValue(peer.Id, out var listItem)) + { + listPeers.Items.Remove(listItem!); + _items.Remove(peer.Id); + } + } + } +} diff --git a/EchoRelay.App/Forms/Controls/PeerConnectionsControl.resx b/EchoRelay.App/Forms/Controls/PeerConnectionsControl.resx new file mode 100644 index 0000000..a395bff --- /dev/null +++ b/EchoRelay.App/Forms/Controls/PeerConnectionsControl.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/EchoRelay.App/Forms/Controls/ServerInfoControl.Designer.cs b/EchoRelay.App/Forms/Controls/ServerInfoControl.Designer.cs new file mode 100644 index 0000000..0efd594 --- /dev/null +++ b/EchoRelay.App/Forms/Controls/ServerInfoControl.Designer.cs @@ -0,0 +1,278 @@ +namespace EchoRelay.App.Forms.Controls +{ + partial class ServerInfoControl + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Component Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + lblConfigPeersHeader = new Label(); + lblLoginPeersHeader = new Label(); + lblMatchingPeersHeader = new Label(); + lblServerDBPeersHeader = new Label(); + lblTransactionPeersHeader = new Label(); + lblConfigPeers = new Label(); + lblLoginPeers = new Label(); + lblMatchingPeers = new Label(); + lblServerDBPeers = new Label(); + lblTransactionPeers = new Label(); + rtbGeneratedServiceConfig = new RichTextBox(); + lblServerPort = new Label(); + lblServerPortHeader = new Label(); + lblServiceConfigHeader = new Label(); + lblServerStatus = new Label(); + lblServerStatusHeader = new Label(); + groupBoxServerInfo = new GroupBox(); + btnCopyServiceConfig = new Button(); + btnSaveServiceConfig = new Button(); + groupBoxServerInfo.SuspendLayout(); + SuspendLayout(); + // + // lblConfigPeersHeader + // + lblConfigPeersHeader.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblConfigPeersHeader.Location = new Point(6, 65); + lblConfigPeersHeader.Name = "lblConfigPeersHeader"; + lblConfigPeersHeader.Size = new Size(147, 23); + lblConfigPeersHeader.TabIndex = 1; + lblConfigPeersHeader.Text = "Config Peers:"; + // + // lblLoginPeersHeader + // + lblLoginPeersHeader.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblLoginPeersHeader.Location = new Point(6, 88); + lblLoginPeersHeader.Name = "lblLoginPeersHeader"; + lblLoginPeersHeader.Size = new Size(147, 23); + lblLoginPeersHeader.TabIndex = 3; + lblLoginPeersHeader.Text = "Login Peers:"; + // + // lblMatchingPeersHeader + // + lblMatchingPeersHeader.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblMatchingPeersHeader.Location = new Point(6, 111); + lblMatchingPeersHeader.Name = "lblMatchingPeersHeader"; + lblMatchingPeersHeader.Size = new Size(147, 23); + lblMatchingPeersHeader.TabIndex = 5; + lblMatchingPeersHeader.Text = "Matching Peers:"; + // + // lblServerDBPeersHeader + // + lblServerDBPeersHeader.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblServerDBPeersHeader.Location = new Point(6, 134); + lblServerDBPeersHeader.Name = "lblServerDBPeersHeader"; + lblServerDBPeersHeader.Size = new Size(147, 23); + lblServerDBPeersHeader.TabIndex = 7; + lblServerDBPeersHeader.Text = "ServerDB Peers:"; + // + // lblTransactionPeersHeader + // + lblTransactionPeersHeader.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblTransactionPeersHeader.Location = new Point(6, 157); + lblTransactionPeersHeader.Name = "lblTransactionPeersHeader"; + lblTransactionPeersHeader.Size = new Size(147, 23); + lblTransactionPeersHeader.TabIndex = 8; + lblTransactionPeersHeader.Text = "Transaction Peers:"; + // + // lblConfigPeers + // + lblConfigPeers.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblConfigPeers.Location = new Point(159, 65); + lblConfigPeers.Name = "lblConfigPeers"; + lblConfigPeers.Size = new Size(147, 23); + lblConfigPeers.TabIndex = 10; + lblConfigPeers.Text = "0"; + // + // lblLoginPeers + // + lblLoginPeers.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblLoginPeers.Location = new Point(159, 88); + lblLoginPeers.Name = "lblLoginPeers"; + lblLoginPeers.Size = new Size(147, 23); + lblLoginPeers.TabIndex = 11; + lblLoginPeers.Text = "0"; + // + // lblMatchingPeers + // + lblMatchingPeers.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblMatchingPeers.Location = new Point(159, 111); + lblMatchingPeers.Name = "lblMatchingPeers"; + lblMatchingPeers.Size = new Size(147, 23); + lblMatchingPeers.TabIndex = 12; + lblMatchingPeers.Text = "0"; + // + // lblServerDBPeers + // + lblServerDBPeers.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblServerDBPeers.Location = new Point(159, 134); + lblServerDBPeers.Name = "lblServerDBPeers"; + lblServerDBPeers.Size = new Size(147, 23); + lblServerDBPeers.TabIndex = 13; + lblServerDBPeers.Text = "0"; + // + // lblTransactionPeers + // + lblTransactionPeers.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblTransactionPeers.Location = new Point(159, 157); + lblTransactionPeers.Name = "lblTransactionPeers"; + lblTransactionPeers.Size = new Size(147, 23); + lblTransactionPeers.TabIndex = 14; + lblTransactionPeers.Text = "0"; + // + // rtbGeneratedServiceConfig + // + rtbGeneratedServiceConfig.BorderStyle = BorderStyle.None; + rtbGeneratedServiceConfig.DetectUrls = false; + rtbGeneratedServiceConfig.Location = new Point(6, 206); + rtbGeneratedServiceConfig.Name = "rtbGeneratedServiceConfig"; + rtbGeneratedServiceConfig.ReadOnly = true; + rtbGeneratedServiceConfig.Size = new Size(786, 148); + rtbGeneratedServiceConfig.TabIndex = 9; + rtbGeneratedServiceConfig.Text = ""; + // + // lblServerPort + // + lblServerPort.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblServerPort.Location = new Point(159, 42); + lblServerPort.Name = "lblServerPort"; + lblServerPort.Size = new Size(147, 23); + lblServerPort.TabIndex = 16; + // + // lblServerPortHeader + // + lblServerPortHeader.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblServerPortHeader.Location = new Point(6, 42); + lblServerPortHeader.Name = "lblServerPortHeader"; + lblServerPortHeader.Size = new Size(147, 23); + lblServerPortHeader.TabIndex = 15; + lblServerPortHeader.Text = "Server Port (TCP):"; + // + // lblServiceConfigHeader + // + lblServiceConfigHeader.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblServiceConfigHeader.Location = new Point(6, 180); + lblServiceConfigHeader.Name = "lblServiceConfigHeader"; + lblServiceConfigHeader.Size = new Size(161, 23); + lblServiceConfigHeader.TabIndex = 19; + lblServiceConfigHeader.Text = "Service Config (generated):"; + // + // lblServerStatus + // + lblServerStatus.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblServerStatus.Location = new Point(159, 19); + lblServerStatus.Name = "lblServerStatus"; + lblServerStatus.Size = new Size(147, 23); + lblServerStatus.TabIndex = 21; + lblServerStatus.Text = "Not started"; + // + // lblServerStatusHeader + // + lblServerStatusHeader.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblServerStatusHeader.Location = new Point(6, 19); + lblServerStatusHeader.Name = "lblServerStatusHeader"; + lblServerStatusHeader.Size = new Size(147, 23); + lblServerStatusHeader.TabIndex = 20; + lblServerStatusHeader.Text = "Server Status:"; + // + // groupBoxServerInfo + // + groupBoxServerInfo.Controls.Add(btnCopyServiceConfig); + groupBoxServerInfo.Controls.Add(btnSaveServiceConfig); + groupBoxServerInfo.Controls.Add(lblServerStatusHeader); + groupBoxServerInfo.Controls.Add(lblServerStatus); + groupBoxServerInfo.Controls.Add(lblConfigPeersHeader); + groupBoxServerInfo.Controls.Add(lblLoginPeersHeader); + groupBoxServerInfo.Controls.Add(rtbGeneratedServiceConfig); + groupBoxServerInfo.Controls.Add(lblMatchingPeersHeader); + groupBoxServerInfo.Controls.Add(lblServiceConfigHeader); + groupBoxServerInfo.Controls.Add(lblServerDBPeersHeader); + groupBoxServerInfo.Controls.Add(lblServerPort); + groupBoxServerInfo.Controls.Add(lblTransactionPeersHeader); + groupBoxServerInfo.Controls.Add(lblServerPortHeader); + groupBoxServerInfo.Controls.Add(lblConfigPeers); + groupBoxServerInfo.Controls.Add(lblTransactionPeers); + groupBoxServerInfo.Controls.Add(lblLoginPeers); + groupBoxServerInfo.Controls.Add(lblServerDBPeers); + groupBoxServerInfo.Controls.Add(lblMatchingPeers); + groupBoxServerInfo.Dock = DockStyle.Fill; + groupBoxServerInfo.Location = new Point(0, 0); + groupBoxServerInfo.Name = "groupBoxServerInfo"; + groupBoxServerInfo.Size = new Size(854, 366); + groupBoxServerInfo.TabIndex = 22; + groupBoxServerInfo.TabStop = false; + groupBoxServerInfo.Text = "Server Info"; + // + // btnCopyServiceConfig + // + btnCopyServiceConfig.Location = new Point(798, 206); + btnCopyServiceConfig.Name = "btnCopyServiceConfig"; + btnCopyServiceConfig.Size = new Size(50, 23); + btnCopyServiceConfig.TabIndex = 23; + btnCopyServiceConfig.Text = "Copy"; + btnCopyServiceConfig.UseVisualStyleBackColor = true; + btnCopyServiceConfig.Click += btnCopyServiceConfig_Click; + // + // btnSaveServiceConfig + // + btnSaveServiceConfig.Location = new Point(798, 235); + btnSaveServiceConfig.Name = "btnSaveServiceConfig"; + btnSaveServiceConfig.Size = new Size(50, 23); + btnSaveServiceConfig.TabIndex = 22; + btnSaveServiceConfig.Text = "Save"; + btnSaveServiceConfig.UseVisualStyleBackColor = true; + btnSaveServiceConfig.Click += btnSaveServiceConfig_Click; + // + // ServerInfoControl + // + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + Controls.Add(groupBoxServerInfo); + Name = "ServerInfoControl"; + Size = new Size(854, 366); + groupBoxServerInfo.ResumeLayout(false); + ResumeLayout(false); + } + + #endregion + private Label lblConfigPeersHeader; + private Label lblLoginPeersHeader; + private Label lblMatchingPeersHeader; + private Label lblServerDBPeersHeader; + private Label lblTransactionPeersHeader; + private Label lblConfigPeers; + private Label lblLoginPeers; + private Label lblMatchingPeers; + private Label lblServerDBPeers; + private Label lblTransactionPeers; + private RichTextBox rtbGeneratedServiceConfig; + private Label lblServerPort; + private Label lblServerPortHeader; + private Label lblServiceConfigHeader; + private Label lblServerStatus; + private Label lblServerStatusHeader; + private GroupBox groupBoxServerInfo; + private Button btnCopyServiceConfig; + private Button btnSaveServiceConfig; + } +} diff --git a/EchoRelay.App/Forms/Controls/ServerInfoControl.cs b/EchoRelay.App/Forms/Controls/ServerInfoControl.cs new file mode 100644 index 0000000..ee9324f --- /dev/null +++ b/EchoRelay.App/Forms/Controls/ServerInfoControl.cs @@ -0,0 +1,61 @@ +using EchoRelay.Core.Server; +using Newtonsoft.Json; + +namespace EchoRelay.App.Forms.Controls +{ + public partial class ServerInfoControl : UserControl + { + public ServerInfoControl() + { + InitializeComponent(); + UpdateServerInfo(null, false); + } + + public void UpdateServerInfo(Server? server, bool updateServiceConfig) + { + // Set all of our connection related label text. + lblServerStatus.Text = server != null ? "Running" : "Not started"; + lblServerPort.Text = server?.Settings?.Port.ToString() ?? "Not started"; + lblConfigPeers.Text = (server?.ConfigService.Peers.Count ?? 0).ToString(); + lblLoginPeers.Text = (server?.LoginService.Peers.Count ?? 0).ToString(); + lblMatchingPeers.Text = (server?.MatchingService.Peers.Count ?? 0).ToString(); + lblServerDBPeers.Text = (server?.ServerDBService.Peers.Count ?? 0).ToString(); + lblTransactionPeers.Text = (server?.TransactionService.Peers.Count ?? 0).ToString(); + + // If we wished to update the service config, set it now. We always update if no server is provided (to clear out the textbox). + if (updateServiceConfig || server == null) + { + if (server != null) + { + string hostName = server.PublicIPAddress?.ToString() ?? "localhost"; + rtbGeneratedServiceConfig.Text = JsonConvert.SerializeObject(server.Settings.GenerateServiceConfig(hostName), Formatting.Indented); + } + else + { + rtbGeneratedServiceConfig.Text = ""; + } + } + } + + private void btnCopyServiceConfig_Click(object sender, EventArgs e) + { + if (string.IsNullOrEmpty(rtbGeneratedServiceConfig.Text)) + return; + + Clipboard.SetText(rtbGeneratedServiceConfig.Text); + } + + private void btnSaveServiceConfig_Click(object sender, EventArgs e) + { + if (string.IsNullOrEmpty(rtbGeneratedServiceConfig.Text)) + return; + + SaveFileDialog saveFileDialog = new SaveFileDialog(); + saveFileDialog.Filter = "Service config (.json)|*.json"; + if (saveFileDialog.ShowDialog() == DialogResult.OK) + { + File.WriteAllText(saveFileDialog.FileName, rtbGeneratedServiceConfig.Text); + } + } + } +} diff --git a/EchoRelay.App/Forms/Controls/ServerInfoControl.resx b/EchoRelay.App/Forms/Controls/ServerInfoControl.resx new file mode 100644 index 0000000..a395bff --- /dev/null +++ b/EchoRelay.App/Forms/Controls/ServerInfoControl.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/EchoRelay.App/Forms/Controls/StorageEditorBase.cs b/EchoRelay.App/Forms/Controls/StorageEditorBase.cs new file mode 100644 index 0000000..2ce50a7 --- /dev/null +++ b/EchoRelay.App/Forms/Controls/StorageEditorBase.cs @@ -0,0 +1,89 @@ +using EchoRelay.App.Utils; +using EchoRelay.Core.Server.Storage; +using System.ComponentModel; + +namespace EchoRelay.App.Forms.Controls +{ + public class StorageEditorBase : UserControl + { + private ServerStorage? _storage; + public ServerStorage? Storage + { + get + { + return _storage; + } + set + { + // Unsubscribe from previous storage events. + if (_storage != null) + { + _storage.OnStorageOpened -= OnStorageLoaded; + _storage.OnStorageClosed -= OnStorageUnloaded; + + // Signal storage unloaded + this.InvokeUIThread(() => + { + OnStorageUnloaded(_storage); + }); + } + + // Set the new storage + _storage = value; + + // Subscribe to new storage events + if (_storage != null) + { + _storage.OnStorageOpened += OnStorageLoaded; + _storage.OnStorageClosed += OnStorageUnloaded; + + // Signal storage loaded + this.InvokeUIThread(() => + { + OnStorageLoaded(_storage); + }); + } + } + } + + private bool _changed; + public bool Changed + { + get + { + return _changed; + } + set + { + // Record whether unsaved changes state changed + bool unsavedChangesStateChanged = _changed != value; + + // Update the change state. + _changed = value; + + // If our unsaved changes state changed, fire an event. + if (unsavedChangesStateChanged) + OnUnsavedChangesStateChange?.Invoke(this, _changed); + } + } + + /// + /// Event for a storage editor now having unsaved changes or no longer having them. + /// + /// The which changed its state. + /// Indicates whether the editor now has unsaved changes or not. + public delegate void UnsavedChangesStateChange(StorageEditorBase storageEditor, bool hasUnsavedChanges); + /// + /// Event for a storage editor now having unsaved changes or no longer having them. + /// + [Browsable(true)] + [Category("Changes")] + [Description("Invoked when unsaved changes are made or cleared.")] + public event UnsavedChangesStateChange? OnUnsavedChangesStateChange; + + protected virtual void OnStorageLoaded(ServerStorage storage) { } + protected virtual void OnStorageUnloaded(ServerStorage storage) { } + public virtual void SaveChanges() { } + public virtual void RevertChanges() { } + } +} diff --git a/EchoRelay.App/Forms/Dialogs/GameLauncherDialog.Designer.cs b/EchoRelay.App/Forms/Dialogs/GameLauncherDialog.Designer.cs new file mode 100644 index 0000000..fc3cb9f --- /dev/null +++ b/EchoRelay.App/Forms/Dialogs/GameLauncherDialog.Designer.cs @@ -0,0 +1,218 @@ +namespace EchoRelay.App.Forms.Dialogs +{ + partial class GameLauncherDialog + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + label1 = new Label(); + cmbGametype = new ComboBox(); + lblGametype = new Label(); + cmbLevel = new ComboBox(); + lblLevel = new Label(); + chkSpectatorStream = new CheckBox(); + chkModerator = new CheckBox(); + chkNoOVR = new CheckBox(); + btnLaunchGame = new Button(); + radioRoleClient = new RadioButton(); + radioRoleServer = new RadioButton(); + radioRoleOffline = new RadioButton(); + chkWindowed = new CheckBox(); + SuspendLayout(); + // + // label1 + // + label1.AutoSize = true; + label1.Location = new Point(13, 15); + label1.Name = "label1"; + label1.Size = new Size(33, 15); + label1.TabIndex = 0; + label1.Text = "Role:"; + // + // cmbGametype + // + cmbGametype.DropDownStyle = ComboBoxStyle.DropDownList; + cmbGametype.Enabled = false; + cmbGametype.FormattingEnabled = true; + cmbGametype.Location = new Point(83, 41); + cmbGametype.Name = "cmbGametype"; + cmbGametype.Size = new Size(305, 23); + cmbGametype.TabIndex = 3; + // + // lblGametype + // + lblGametype.AutoSize = true; + lblGametype.Location = new Point(13, 44); + lblGametype.Name = "lblGametype"; + lblGametype.Size = new Size(64, 15); + lblGametype.TabIndex = 2; + lblGametype.Text = "Gametype:"; + // + // cmbLevel + // + cmbLevel.DropDownStyle = ComboBoxStyle.DropDownList; + cmbLevel.Enabled = false; + cmbLevel.FormattingEnabled = true; + cmbLevel.Location = new Point(83, 70); + cmbLevel.Name = "cmbLevel"; + cmbLevel.Size = new Size(305, 23); + cmbLevel.TabIndex = 5; + // + // lblLevel + // + lblLevel.AutoSize = true; + lblLevel.Location = new Point(13, 73); + lblLevel.Name = "lblLevel"; + lblLevel.Size = new Size(37, 15); + lblLevel.TabIndex = 4; + lblLevel.Text = "Level:"; + // + // chkSpectatorStream + // + chkSpectatorStream.AutoSize = true; + chkSpectatorStream.Location = new Point(12, 124); + chkSpectatorStream.Name = "chkSpectatorStream"; + chkSpectatorStream.Size = new Size(76, 19); + chkSpectatorStream.TabIndex = 6; + chkSpectatorStream.Text = "Spectator"; + chkSpectatorStream.UseVisualStyleBackColor = true; + // + // chkModerator + // + chkModerator.AutoSize = true; + chkModerator.Location = new Point(12, 149); + chkModerator.Name = "chkModerator"; + chkModerator.Size = new Size(82, 19); + chkModerator.TabIndex = 7; + chkModerator.Text = "Moderator"; + chkModerator.UseVisualStyleBackColor = true; + // + // chkNoOVR + // + chkNoOVR.AutoSize = true; + chkNoOVR.Location = new Point(12, 174); + chkNoOVR.Name = "chkNoOVR"; + chkNoOVR.Size = new Size(147, 19); + chkNoOVR.TabIndex = 8; + chkNoOVR.Text = "No OVR (demo profile)"; + chkNoOVR.UseVisualStyleBackColor = true; + // + // btnLaunchGame + // + btnLaunchGame.Location = new Point(12, 199); + btnLaunchGame.Name = "btnLaunchGame"; + btnLaunchGame.Size = new Size(375, 23); + btnLaunchGame.TabIndex = 9; + btnLaunchGame.Text = "Launch Game"; + btnLaunchGame.UseVisualStyleBackColor = true; + btnLaunchGame.Click += btnLaunchGame_Click; + // + // radioRoleClient + // + radioRoleClient.AutoSize = true; + radioRoleClient.Checked = true; + radioRoleClient.Location = new Point(83, 13); + radioRoleClient.Name = "radioRoleClient"; + radioRoleClient.Size = new Size(56, 19); + radioRoleClient.TabIndex = 10; + radioRoleClient.TabStop = true; + radioRoleClient.Text = "Client"; + radioRoleClient.UseVisualStyleBackColor = true; + // + // radioRoleServer + // + radioRoleServer.AutoSize = true; + radioRoleServer.Location = new Point(145, 13); + radioRoleServer.Name = "radioRoleServer"; + radioRoleServer.Size = new Size(57, 19); + radioRoleServer.TabIndex = 11; + radioRoleServer.Text = "Server"; + radioRoleServer.UseVisualStyleBackColor = true; + // + // radioRoleOffline + // + radioRoleOffline.AutoSize = true; + radioRoleOffline.Location = new Point(208, 13); + radioRoleOffline.Name = "radioRoleOffline"; + radioRoleOffline.Size = new Size(61, 19); + radioRoleOffline.TabIndex = 12; + radioRoleOffline.Text = "Offline"; + radioRoleOffline.UseVisualStyleBackColor = true; + // + // chkWindowed + // + chkWindowed.AutoSize = true; + chkWindowed.Checked = true; + chkWindowed.CheckState = CheckState.Checked; + chkWindowed.Location = new Point(13, 99); + chkWindowed.Name = "chkWindowed"; + chkWindowed.Size = new Size(83, 19); + chkWindowed.TabIndex = 13; + chkWindowed.Text = "Windowed"; + chkWindowed.UseVisualStyleBackColor = true; + // + // GameLauncherDialog + // + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(399, 230); + Controls.Add(chkWindowed); + Controls.Add(radioRoleOffline); + Controls.Add(radioRoleServer); + Controls.Add(radioRoleClient); + Controls.Add(btnLaunchGame); + Controls.Add(chkNoOVR); + Controls.Add(chkModerator); + Controls.Add(chkSpectatorStream); + Controls.Add(cmbLevel); + Controls.Add(lblLevel); + Controls.Add(cmbGametype); + Controls.Add(lblGametype); + Controls.Add(label1); + FormBorderStyle = FormBorderStyle.FixedToolWindow; + Name = "GameLauncherDialog"; + Text = "Game Launcher"; + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private Label label1; + private ComboBox cmbGametype; + private Label lblGametype; + private ComboBox cmbLevel; + private Label lblLevel; + private CheckBox chkSpectatorStream; + private CheckBox chkModerator; + private CheckBox chkNoOVR; + private Button btnLaunchGame; + private RadioButton radioRoleClient; + private RadioButton radioRoleServer; + private RadioButton radioRoleOffline; + private CheckBox chkWindowed; + } +} \ No newline at end of file diff --git a/EchoRelay.App/Forms/Dialogs/GameLauncherDialog.cs b/EchoRelay.App/Forms/Dialogs/GameLauncherDialog.cs new file mode 100644 index 0000000..ef1e6d9 --- /dev/null +++ b/EchoRelay.App/Forms/Dialogs/GameLauncherDialog.cs @@ -0,0 +1,45 @@ +using EchoRelay.App.Settings; +using EchoRelay.Core.Game; + +namespace EchoRelay.App.Forms.Dialogs +{ + public partial class GameLauncherDialog : Form + { + /// + /// The application settings containing the executable path information used to launch the game. + /// + public AppSettings Settings { get; } + + public GameLauncherDialog(AppSettings settings) + { + InitializeComponent(); + + // Set our settings + Settings = settings; + + // Set the dialog result to cancelled, this way only if we launch the process do we return OK. + DialogResult = DialogResult.Cancel; + } + + private void btnLaunchGame_Click(object sender, EventArgs e) + { + + // Obtain the role from the drop down selection. + var launchRole = radioRoleClient.Checked ? GameLauncher.LaunchRole.Client : (radioRoleServer.Checked ? GameLauncher.LaunchRole.Server : GameLauncher.LaunchRole.Offline); + + // Launch the game with the provided arguments. + GameLauncher.Launch( + executableFilePath: Settings.GameExecutableFilePath, + role: launchRole, + windowed: chkWindowed.Checked, + spectatorStream: chkSpectatorStream.Checked, + moderator: chkModerator.Checked, + noOVR: chkNoOVR.Checked + ); + + // Close the dialog + DialogResult = DialogResult.OK; + Close(); + } + } +} diff --git a/EchoRelay.App/Forms/Dialogs/GameLauncherDialog.resx b/EchoRelay.App/Forms/Dialogs/GameLauncherDialog.resx new file mode 100644 index 0000000..a395bff --- /dev/null +++ b/EchoRelay.App/Forms/Dialogs/GameLauncherDialog.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/EchoRelay.App/Forms/Dialogs/SettingsDialog.Designer.cs b/EchoRelay.App/Forms/Dialogs/SettingsDialog.Designer.cs new file mode 100644 index 0000000..e348172 --- /dev/null +++ b/EchoRelay.App/Forms/Dialogs/SettingsDialog.Designer.cs @@ -0,0 +1,356 @@ +namespace EchoRelay.App.Forms.Dialogs +{ + partial class SettingsDialog + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + lblDatabaseType = new Label(); + radioDbTypeFilesystem = new RadioButton(); + radioButton1 = new RadioButton(); + groupBoxGame = new GroupBox(); + btnOpenGameFolder = new Button(); + txtExecutablePath = new TextBox(); + label1 = new Label(); + groupBoxServer = new GroupBox(); + chkStartServerOnStartup = new CheckBox(); + numericTCPPort = new NumericUpDown(); + label2 = new Label(); + btnOpenDbFolder = new Button(); + txtDbFolder = new TextBox(); + lblDbFolder = new Label(); + btnSaveSettings = new Button(); + groupBoxMatching = new GroupBox(); + chkForceIntoAnySession = new CheckBox(); + chkPopulationOverPing = new CheckBox(); + groupBox1 = new GroupBox(); + btnRegenerateAPIKey = new Button(); + txtServerDBApiKey = new TextBox(); + chkUseServerDBApiKeys = new CheckBox(); + groupBoxGame.SuspendLayout(); + groupBoxServer.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)numericTCPPort).BeginInit(); + groupBoxMatching.SuspendLayout(); + groupBox1.SuspendLayout(); + SuspendLayout(); + // + // lblDatabaseType + // + lblDatabaseType.AutoSize = true; + lblDatabaseType.Font = new Font("Segoe UI", 9F, FontStyle.Regular, GraphicsUnit.Point); + lblDatabaseType.Location = new Point(6, 52); + lblDatabaseType.Name = "lblDatabaseType"; + lblDatabaseType.Size = new Size(85, 15); + lblDatabaseType.TabIndex = 0; + lblDatabaseType.Text = "Database Type:"; + // + // radioDbTypeFilesystem + // + radioDbTypeFilesystem.AutoSize = true; + radioDbTypeFilesystem.Checked = true; + radioDbTypeFilesystem.Location = new Point(106, 50); + radioDbTypeFilesystem.Name = "radioDbTypeFilesystem"; + radioDbTypeFilesystem.Size = new Size(80, 19); + radioDbTypeFilesystem.TabIndex = 2; + radioDbTypeFilesystem.TabStop = true; + radioDbTypeFilesystem.Text = "Filesystem"; + radioDbTypeFilesystem.UseVisualStyleBackColor = true; + // + // radioButton1 + // + radioButton1.AutoSize = true; + radioButton1.Enabled = false; + radioButton1.Location = new Point(192, 50); + radioButton1.Name = "radioButton1"; + radioButton1.Size = new Size(79, 19); + radioButton1.TabIndex = 3; + radioButton1.Text = "MongoDB"; + radioButton1.UseVisualStyleBackColor = true; + // + // groupBoxGame + // + groupBoxGame.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + groupBoxGame.Controls.Add(btnOpenGameFolder); + groupBoxGame.Controls.Add(txtExecutablePath); + groupBoxGame.Controls.Add(label1); + groupBoxGame.Location = new Point(12, 12); + groupBoxGame.Name = "groupBoxGame"; + groupBoxGame.Size = new Size(500, 61); + groupBoxGame.TabIndex = 4; + groupBoxGame.TabStop = false; + groupBoxGame.Text = "Game Settings"; + // + // btnOpenGameFolder + // + btnOpenGameFolder.Anchor = AnchorStyles.Top | AnchorStyles.Right; + btnOpenGameFolder.Location = new Point(464, 22); + btnOpenGameFolder.Name = "btnOpenGameFolder"; + btnOpenGameFolder.Size = new Size(30, 23); + btnOpenGameFolder.TabIndex = 6; + btnOpenGameFolder.Text = "..."; + btnOpenGameFolder.UseVisualStyleBackColor = true; + btnOpenGameFolder.Click += btnOpenGameFolder_Click; + // + // txtExecutablePath + // + txtExecutablePath.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + txtExecutablePath.Location = new Point(106, 22); + txtExecutablePath.Name = "txtExecutablePath"; + txtExecutablePath.ReadOnly = true; + txtExecutablePath.Size = new Size(352, 23); + txtExecutablePath.TabIndex = 8; + // + // label1 + // + label1.AutoSize = true; + label1.Location = new Point(6, 25); + label1.Name = "label1"; + label1.Size = new Size(67, 15); + label1.TabIndex = 7; + label1.Text = "Executable:"; + // + // groupBoxServer + // + groupBoxServer.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + groupBoxServer.Controls.Add(chkStartServerOnStartup); + groupBoxServer.Controls.Add(numericTCPPort); + groupBoxServer.Controls.Add(label2); + groupBoxServer.Controls.Add(btnOpenDbFolder); + groupBoxServer.Controls.Add(txtDbFolder); + groupBoxServer.Controls.Add(lblDbFolder); + groupBoxServer.Controls.Add(radioDbTypeFilesystem); + groupBoxServer.Controls.Add(lblDatabaseType); + groupBoxServer.Controls.Add(radioButton1); + groupBoxServer.Location = new Point(12, 79); + groupBoxServer.Name = "groupBoxServer"; + groupBoxServer.Size = new Size(500, 137); + groupBoxServer.TabIndex = 5; + groupBoxServer.TabStop = false; + groupBoxServer.Text = "Server Settings"; + // + // chkStartServerOnStartup + // + chkStartServerOnStartup.AutoSize = true; + chkStartServerOnStartup.Checked = true; + chkStartServerOnStartup.CheckState = CheckState.Checked; + chkStartServerOnStartup.Location = new Point(6, 108); + chkStartServerOnStartup.Name = "chkStartServerOnStartup"; + chkStartServerOnStartup.Size = new Size(141, 19); + chkStartServerOnStartup.TabIndex = 8; + chkStartServerOnStartup.Text = "Start server on startup"; + chkStartServerOnStartup.UseVisualStyleBackColor = true; + // + // numericTCPPort + // + numericTCPPort.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + numericTCPPort.Location = new Point(106, 21); + numericTCPPort.Maximum = new decimal(new int[] { 65535, 0, 0, 0 }); + numericTCPPort.Name = "numericTCPPort"; + numericTCPPort.Size = new Size(352, 23); + numericTCPPort.TabIndex = 7; + // + // label2 + // + label2.AutoSize = true; + label2.Location = new Point(6, 23); + label2.Name = "label2"; + label2.Size = new Size(55, 15); + label2.TabIndex = 6; + label2.Text = "TCP Port:"; + // + // btnOpenDbFolder + // + btnOpenDbFolder.Anchor = AnchorStyles.Top | AnchorStyles.Right; + btnOpenDbFolder.Location = new Point(464, 79); + btnOpenDbFolder.Name = "btnOpenDbFolder"; + btnOpenDbFolder.Size = new Size(30, 23); + btnOpenDbFolder.TabIndex = 0; + btnOpenDbFolder.Text = "..."; + btnOpenDbFolder.UseVisualStyleBackColor = true; + btnOpenDbFolder.Click += btnOpenDbFolder_Click; + // + // txtDbFolder + // + txtDbFolder.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + txtDbFolder.Location = new Point(106, 79); + txtDbFolder.Name = "txtDbFolder"; + txtDbFolder.ReadOnly = true; + txtDbFolder.Size = new Size(352, 23); + txtDbFolder.TabIndex = 5; + // + // lblDbFolder + // + lblDbFolder.AutoSize = true; + lblDbFolder.Location = new Point(6, 81); + lblDbFolder.Name = "lblDbFolder"; + lblDbFolder.Size = new Size(94, 15); + lblDbFolder.TabIndex = 4; + lblDbFolder.Text = "Database Folder:"; + // + // btnSaveSettings + // + btnSaveSettings.Anchor = AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right; + btnSaveSettings.Location = new Point(12, 378); + btnSaveSettings.Name = "btnSaveSettings"; + btnSaveSettings.Size = new Size(500, 23); + btnSaveSettings.TabIndex = 6; + btnSaveSettings.Text = "Save Settings"; + btnSaveSettings.UseVisualStyleBackColor = true; + btnSaveSettings.Click += btnSaveSettings_Click; + // + // groupBoxMatching + // + groupBoxMatching.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + groupBoxMatching.Controls.Add(chkForceIntoAnySession); + groupBoxMatching.Controls.Add(chkPopulationOverPing); + groupBoxMatching.Location = new Point(12, 293); + groupBoxMatching.Name = "groupBoxMatching"; + groupBoxMatching.Size = new Size(500, 79); + groupBoxMatching.TabIndex = 7; + groupBoxMatching.TabStop = false; + groupBoxMatching.Text = "Matching Settings"; + // + // chkForceIntoAnySession + // + chkForceIntoAnySession.AutoSize = true; + chkForceIntoAnySession.Checked = true; + chkForceIntoAnySession.CheckState = CheckState.Checked; + chkForceIntoAnySession.Location = new Point(6, 47); + chkForceIntoAnySession.Name = "chkForceIntoAnySession"; + chkForceIntoAnySession.Size = new Size(432, 19); + chkForceIntoAnySession.TabIndex = 1; + chkForceIntoAnySession.Text = "Force into most populated session on matching failure / lack of game servers"; + chkForceIntoAnySession.UseVisualStyleBackColor = true; + // + // chkPopulationOverPing + // + chkPopulationOverPing.AutoSize = true; + chkPopulationOverPing.Checked = true; + chkPopulationOverPing.CheckState = CheckState.Checked; + chkPopulationOverPing.Location = new Point(6, 22); + chkPopulationOverPing.Name = "chkPopulationOverPing"; + chkPopulationOverPing.Size = new Size(275, 19); + chkPopulationOverPing.TabIndex = 0; + chkPopulationOverPing.Text = "Prioritize population over ping (recommended)"; + chkPopulationOverPing.UseVisualStyleBackColor = true; + // + // groupBox1 + // + groupBox1.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + groupBox1.Controls.Add(btnRegenerateAPIKey); + groupBox1.Controls.Add(txtServerDBApiKey); + groupBox1.Controls.Add(chkUseServerDBApiKeys); + groupBox1.Location = new Point(12, 222); + groupBox1.Name = "groupBox1"; + groupBox1.Size = new Size(500, 65); + groupBox1.TabIndex = 8; + groupBox1.TabStop = false; + groupBox1.Text = "ServerDB Settings"; + // + // btnRegenerateAPIKey + // + btnRegenerateAPIKey.Anchor = AnchorStyles.Top | AnchorStyles.Right; + btnRegenerateAPIKey.BackgroundImageLayout = ImageLayout.Stretch; + btnRegenerateAPIKey.Location = new Point(428, 20); + btnRegenerateAPIKey.Name = "btnRegenerateAPIKey"; + btnRegenerateAPIKey.Size = new Size(66, 23); + btnRegenerateAPIKey.TabIndex = 10; + btnRegenerateAPIKey.Text = "Generate"; + btnRegenerateAPIKey.UseVisualStyleBackColor = true; + btnRegenerateAPIKey.Click += btnRegenerateAPIKey_Click; + // + // txtServerDBApiKey + // + txtServerDBApiKey.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right; + txtServerDBApiKey.Location = new Point(182, 20); + txtServerDBApiKey.Name = "txtServerDBApiKey"; + txtServerDBApiKey.Size = new Size(240, 23); + txtServerDBApiKey.TabIndex = 9; + // + // chkUseServerDBApiKeys + // + chkUseServerDBApiKeys.AutoSize = true; + chkUseServerDBApiKeys.Checked = true; + chkUseServerDBApiKeys.CheckState = CheckState.Checked; + chkUseServerDBApiKeys.Location = new Point(6, 22); + chkUseServerDBApiKeys.Name = "chkUseServerDBApiKeys"; + chkUseServerDBApiKeys.Size = new Size(170, 19); + chkUseServerDBApiKeys.TabIndex = 0; + chkUseServerDBApiKeys.Text = "Use API key authentication:"; + chkUseServerDBApiKeys.UseVisualStyleBackColor = true; + chkUseServerDBApiKeys.CheckedChanged += chkUseServerDBApiKeys_CheckedChanged; + // + // SettingsDialog + // + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(524, 409); + Controls.Add(groupBox1); + Controls.Add(groupBoxMatching); + Controls.Add(btnSaveSettings); + Controls.Add(groupBoxServer); + Controls.Add(groupBoxGame); + FormBorderStyle = FormBorderStyle.SizableToolWindow; + Name = "SettingsDialog"; + Text = "Application Settings"; + groupBoxGame.ResumeLayout(false); + groupBoxGame.PerformLayout(); + groupBoxServer.ResumeLayout(false); + groupBoxServer.PerformLayout(); + ((System.ComponentModel.ISupportInitialize)numericTCPPort).EndInit(); + groupBoxMatching.ResumeLayout(false); + groupBoxMatching.PerformLayout(); + groupBox1.ResumeLayout(false); + groupBox1.PerformLayout(); + ResumeLayout(false); + } + + #endregion + + private Label lblDatabaseType; + private RadioButton radioDbTypeFilesystem; + private RadioButton radioButton1; + private GroupBox groupBoxGame; + private Button btnOpenDbFolder; + private GroupBox groupBoxServer; + private TextBox txtDbFolder; + private Label lblDbFolder; + private Button btnOpenGameFolder; + private TextBox txtExecutablePath; + private Label label1; + private Button btnSaveSettings; + private Label label2; + private NumericUpDown numericTCPPort; + private GroupBox groupBoxMatching; + private CheckBox chkForceIntoAnySession; + private CheckBox chkPopulationOverPing; + private CheckBox chkStartServerOnStartup; + private GroupBox groupBox1; + private TextBox txtServerDBApiKey; + private CheckBox chkUseServerDBApiKeys; + private Button btnRegenerateAPIKey; + } +} \ No newline at end of file diff --git a/EchoRelay.App/Forms/Dialogs/SettingsDialog.cs b/EchoRelay.App/Forms/Dialogs/SettingsDialog.cs new file mode 100644 index 0000000..4915777 --- /dev/null +++ b/EchoRelay.App/Forms/Dialogs/SettingsDialog.cs @@ -0,0 +1,149 @@ +using EchoRelay.App.Settings; +using System.Security.Cryptography; + +namespace EchoRelay.App.Forms.Dialogs +{ + public partial class SettingsDialog : Form + { + /// + /// The application settings that are presented for modification. + /// + public AppSettings Settings { get; } + /// + /// The file path to which the should be saved. + /// + public string SettingsFilePath { get; } + + /// + /// Initializes a new . + /// + /// The application settings to be presented for modification. + public SettingsDialog(AppSettings settings, string settingsFilePath) + { + InitializeComponent(); + + // Load our settings into the UI + Settings = settings; + SettingsFilePath = settingsFilePath; + + txtExecutablePath.Text = Settings.GameExecutableFilePath; + numericTCPPort.Value = Settings.Port; + if (Settings.FilesystemDatabaseDirectory != null) + txtDbFolder.Text = Settings.FilesystemDatabaseDirectory; + chkStartServerOnStartup.Checked = Settings.StartServerOnStartup; + chkPopulationOverPing.Checked = Settings.MatchingPopulationOverPing; + chkForceIntoAnySession.Checked = Settings.MatchingForceIntoAnySessionOnFailure; + + // Set the server DB api key + txtServerDBApiKey.Text = Settings.ServerDBApiKey ?? ""; + chkUseServerDBApiKeys.Checked = !string.IsNullOrEmpty(Settings.ServerDBApiKey); + + // Set the dialog result to cancelled, this way only if we successfully save settings, do we return OK. + DialogResult = DialogResult.Cancel; + } + + private void btnOpenGameFolder_Click(object sender, EventArgs e) + { + // Create a folder browser dialog to let the user select the game executable. + OpenFileDialog openFileDialog = new OpenFileDialog(); + openFileDialog.Filter = "Executable Files (.exe)|*.exe"; + openFileDialog.Multiselect = false; + if (openFileDialog.ShowDialog() == DialogResult.OK) + { + // Set the executable path. + txtExecutablePath.Text = openFileDialog.FileName; + } + } + + private void btnOpenDbFolder_Click(object sender, EventArgs e) + { + // Create a folder browser dialog to let the user select the database folder. + FolderBrowserDialog folderBrowser = new FolderBrowserDialog(); + if (folderBrowser.ShowDialog() == DialogResult.OK) + { + // Set the folder path. + txtDbFolder.Text = folderBrowser.SelectedPath; + } + } + + private void btnSaveSettings_Click(object sender, EventArgs e) + { + // Validate the settings provided. + if (!Directory.Exists(txtDbFolder.Text)) + { + MessageBox.Show("The provided filesystem database folder could not be found.", "Error"); + return; + } + if (!File.Exists(txtExecutablePath.Text)) + { + MessageBox.Show("The provided game executable could not be found.", "Error"); + return; + } + if (!File.Exists(txtExecutablePath.Text)) + { + MessageBox.Show("The specified executable path does not exist.", "Error"); + return; + } + + // Obtain the new server DB API key. + string? newServerDbApiKey = chkUseServerDBApiKeys.Checked ? txtServerDBApiKey.Text.Trim() : null; + + // Display a confirmation dialog if we're about to change an existing API key. + if (newServerDbApiKey != null && Settings.ServerDBApiKey != null && newServerDbApiKey != Settings.ServerDBApiKey) + { + if (MessageBox.Show("Resetting the API key used to access ServerDB may invalidate authentication for game servers which do not update their service configs, would you like to continue?", "Echo Relay: Warning", MessageBoxButtons.YesNo) != DialogResult.Yes) + { + return; + } + } + + // Set the provided in our settings object. + Settings.Port = (ushort)numericTCPPort.Value; + Settings.GameExecutableFilePath = txtExecutablePath.Text; + Settings.FilesystemDatabaseDirectory = txtDbFolder.Text; + Settings.MongoDBConnectionString = null; // TODO: currently unsupported + Settings.StartServerOnStartup = chkStartServerOnStartup.Checked; + Settings.ServerDBApiKey = newServerDbApiKey; + Settings.MatchingPopulationOverPing = chkPopulationOverPing.Checked; + Settings.MatchingForceIntoAnySessionOnFailure = chkForceIntoAnySession.Checked; + + // Save the settings. + Settings.Save(SettingsFilePath); + + // Set our result and close the dialog. + DialogResult = DialogResult.OK; + Close(); + } + + private void RegenerateServerDBApiKey() + { + txtServerDBApiKey.Text = Convert.ToBase64String(RandomNumberGenerator.GetBytes(0x20)); + } + + private void RefreshServerDBApiKey() + { + if (!chkUseServerDBApiKeys.Checked) + { + chkUseServerDBApiKeys.Checked = false; + txtServerDBApiKey.ReadOnly = true; + } + else + { + chkUseServerDBApiKeys.Checked = true; + if (string.IsNullOrEmpty(txtServerDBApiKey.Text.Trim())) + RegenerateServerDBApiKey(); + txtServerDBApiKey.ReadOnly = false; + } + } + private void chkUseServerDBApiKeys_CheckedChanged(object sender, EventArgs e) + { + RefreshServerDBApiKey(); + } + + private void btnRegenerateAPIKey_Click(object sender, EventArgs e) + { + // Regenerate the key. + RegenerateServerDBApiKey(); + } + } +} diff --git a/EchoRelay.App/Forms/Dialogs/SettingsDialog.resx b/EchoRelay.App/Forms/Dialogs/SettingsDialog.resx new file mode 100644 index 0000000..a395bff --- /dev/null +++ b/EchoRelay.App/Forms/Dialogs/SettingsDialog.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/EchoRelay.App/Forms/MainWindow.Designer.cs b/EchoRelay.App/Forms/MainWindow.Designer.cs new file mode 100644 index 0000000..c7fffb5 --- /dev/null +++ b/EchoRelay.App/Forms/MainWindow.Designer.cs @@ -0,0 +1,744 @@ +namespace EchoRelay +{ + partial class MainWindow + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + components = new System.ComponentModel.Container(); + menuStrip1 = new MenuStrip(); + fileToolStripMenuItem = new ToolStripMenuItem(); + saveChangesToolStripMenuItem = new ToolStripMenuItem(); + toolStripSeparator3 = new ToolStripSeparator(); + exitToolStripMenuItem = new ToolStripMenuItem(); + editToolStripMenuItem = new ToolStripMenuItem(); + revertUnsavedChangesToolStripMenuItem = new ToolStripMenuItem(); + viewToolStripMenuItem = new ToolStripMenuItem(); + toolsToolStripMenuItem = new ToolStripMenuItem(); + startServerToolStripMenuItem = new ToolStripMenuItem(); + launchEchoVRToolStripMenuItem = new ToolStripMenuItem(); + serverHeadlessToolStripMenuItem = new ToolStripMenuItem(); + clientWindowedNoOVRToolStripMenuItem = new ToolStripMenuItem(); + clientWindowedOVRToolStripMenuItem = new ToolStripMenuItem(); + offlineWindowedNoOVRToolStripMenuItem = new ToolStripMenuItem(); + clientOVRToolStripMenuItem = new ToolStripMenuItem(); + offlineOVRToolStripMenuItem = new ToolStripMenuItem(); + serverToolStripMenuItem = new ToolStripMenuItem(); + toolStripSeparator1 = new ToolStripSeparator(); + customToolStripMenuItem = new ToolStripMenuItem(); + settingsToolStripMenuItem = new ToolStripMenuItem(); + helpToolStripMenuItem = new ToolStripMenuItem(); + aboutToolStripMenuItem = new ToolStripMenuItem(); + rtbLog = new RichTextBox(); + ctxMenuServerLog = new ContextMenuStrip(components); + clearToolStripMenuItem = new ToolStripMenuItem(); + toolStrip1 = new ToolStrip(); + btnToggleRunningState = new ToolStripButton(); + toolStripSeparator2 = new ToolStripSeparator(); + btnLaunchGameServer = new ToolStripButton(); + toolStripSeparator4 = new ToolStripSeparator(); + btnSaveChanges = new ToolStripButton(); + btnRevertChanges = new ToolStripButton(); + tabControlMain = new TabControl(); + tabServerInfo = new TabPage(); + serverInfoControl = new EchoRelay.App.Forms.Controls.ServerInfoControl(); + tabPeers = new TabPage(); + peerConnectionsControl = new EchoRelay.App.Forms.Controls.PeerConnectionsControl(); + tabGameServers = new TabPage(); + gameServersControl = new EchoRelay.App.Forms.Controls.GameServersControl(); + tabStorage = new TabPage(); + tabControlStorage = new TabControl(); + tabStorageAccessControls = new TabPage(); + accessControlListEditor = new EchoRelay.App.Forms.Controls.AccessControlListEditor(); + tabStorageAccounts = new TabPage(); + accountSelector = new EchoRelay.App.Forms.Controls.AccountSelector(); + tabStorageChannelInfo = new TabPage(); + channelInfoEditor = new EchoRelay.App.Forms.Controls.ChannelInfoEditor(); + tabStorageConfigs = new TabPage(); + tabStorageDocuments = new TabPage(); + tabStorageLoginSettings = new TabPage(); + loginSettingsEditor = new EchoRelay.App.Forms.Controls.LoginSettingsEditor(); + statusStripProgress = new StatusStrip(); + lblToolStripPadding = new ToolStripStatusLabel(); + lblStatusHeader = new ToolStripStatusLabel(); + lblStatus = new ToolStripStatusLabel(); + progressBarStatus = new ToolStripProgressBar(); + panelTabStripContainer = new Panel(); + splitContainer1 = new SplitContainer(); + groupBoxLog = new GroupBox(); + menuStrip1.SuspendLayout(); + ctxMenuServerLog.SuspendLayout(); + toolStrip1.SuspendLayout(); + tabControlMain.SuspendLayout(); + tabServerInfo.SuspendLayout(); + tabPeers.SuspendLayout(); + tabGameServers.SuspendLayout(); + tabStorage.SuspendLayout(); + tabControlStorage.SuspendLayout(); + tabStorageAccessControls.SuspendLayout(); + tabStorageAccounts.SuspendLayout(); + tabStorageChannelInfo.SuspendLayout(); + tabStorageLoginSettings.SuspendLayout(); + statusStripProgress.SuspendLayout(); + ((System.ComponentModel.ISupportInitialize)splitContainer1).BeginInit(); + splitContainer1.Panel1.SuspendLayout(); + splitContainer1.Panel2.SuspendLayout(); + splitContainer1.SuspendLayout(); + groupBoxLog.SuspendLayout(); + SuspendLayout(); + // + // menuStrip1 + // + menuStrip1.Items.AddRange(new ToolStripItem[] { fileToolStripMenuItem, editToolStripMenuItem, viewToolStripMenuItem, toolsToolStripMenuItem, helpToolStripMenuItem }); + menuStrip1.Location = new Point(0, 0); + menuStrip1.Name = "menuStrip1"; + menuStrip1.Size = new Size(1333, 24); + menuStrip1.TabIndex = 0; + menuStrip1.Text = "menuStrip1"; + // + // fileToolStripMenuItem + // + fileToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { saveChangesToolStripMenuItem, toolStripSeparator3, exitToolStripMenuItem }); + fileToolStripMenuItem.Name = "fileToolStripMenuItem"; + fileToolStripMenuItem.Size = new Size(37, 20); + fileToolStripMenuItem.Text = "File"; + // + // saveChangesToolStripMenuItem + // + saveChangesToolStripMenuItem.Name = "saveChangesToolStripMenuItem"; + saveChangesToolStripMenuItem.ShortcutKeys = Keys.Control | Keys.S; + saveChangesToolStripMenuItem.Size = new Size(187, 22); + saveChangesToolStripMenuItem.Text = "Save Changes"; + saveChangesToolStripMenuItem.Click += btnSaveChanges_Click; + // + // toolStripSeparator3 + // + toolStripSeparator3.Name = "toolStripSeparator3"; + toolStripSeparator3.Size = new Size(184, 6); + // + // exitToolStripMenuItem + // + exitToolStripMenuItem.Name = "exitToolStripMenuItem"; + exitToolStripMenuItem.Size = new Size(187, 22); + exitToolStripMenuItem.Text = "Exit"; + exitToolStripMenuItem.Click += exitToolStripMenuItem_Click; + // + // editToolStripMenuItem + // + editToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { revertUnsavedChangesToolStripMenuItem }); + editToolStripMenuItem.Name = "editToolStripMenuItem"; + editToolStripMenuItem.Size = new Size(39, 20); + editToolStripMenuItem.Text = "Edit"; + // + // revertUnsavedChangesToolStripMenuItem + // + revertUnsavedChangesToolStripMenuItem.Name = "revertUnsavedChangesToolStripMenuItem"; + revertUnsavedChangesToolStripMenuItem.ShortcutKeys = Keys.Control | Keys.R; + revertUnsavedChangesToolStripMenuItem.Size = new Size(242, 22); + revertUnsavedChangesToolStripMenuItem.Text = "Revert unsaved changes"; + revertUnsavedChangesToolStripMenuItem.Click += btnRevertChanges_Click; + // + // viewToolStripMenuItem + // + viewToolStripMenuItem.Name = "viewToolStripMenuItem"; + viewToolStripMenuItem.Size = new Size(44, 20); + viewToolStripMenuItem.Text = "View"; + // + // toolsToolStripMenuItem + // + toolsToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { startServerToolStripMenuItem, launchEchoVRToolStripMenuItem, settingsToolStripMenuItem }); + toolsToolStripMenuItem.Name = "toolsToolStripMenuItem"; + toolsToolStripMenuItem.Size = new Size(46, 20); + toolsToolStripMenuItem.Text = "Tools"; + // + // startServerToolStripMenuItem + // + startServerToolStripMenuItem.Name = "startServerToolStripMenuItem"; + startServerToolStripMenuItem.ShortcutKeys = Keys.Control | Keys.T; + startServerToolStripMenuItem.Size = new Size(180, 22); + startServerToolStripMenuItem.Text = "Start server"; + startServerToolStripMenuItem.Click += btnToggleRunningState_Click; + // + // launchEchoVRToolStripMenuItem + // + launchEchoVRToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { serverToolStripMenuItem, clientWindowedNoOVRToolStripMenuItem, clientWindowedOVRToolStripMenuItem, offlineWindowedNoOVRToolStripMenuItem, clientOVRToolStripMenuItem, offlineOVRToolStripMenuItem, serverHeadlessToolStripMenuItem, toolStripSeparator1, customToolStripMenuItem }); + launchEchoVRToolStripMenuItem.Name = "launchEchoVRToolStripMenuItem"; + launchEchoVRToolStripMenuItem.Size = new Size(180, 22); + launchEchoVRToolStripMenuItem.Text = "Launch Echo VR"; + // + // serverHeadlessToolStripMenuItem + // + serverHeadlessToolStripMenuItem.Name = "serverHeadlessToolStripMenuItem"; + serverHeadlessToolStripMenuItem.ShortcutKeys = Keys.Control | Keys.D7; + serverHeadlessToolStripMenuItem.Size = new Size(257, 22); + serverHeadlessToolStripMenuItem.Text = "Server (headless)"; + serverHeadlessToolStripMenuItem.Click += serverHeadlessToolStripMenuItem_Click; + // + // clientWindowedNoOVRToolStripMenuItem + // + clientWindowedNoOVRToolStripMenuItem.Name = "clientWindowedNoOVRToolStripMenuItem"; + clientWindowedNoOVRToolStripMenuItem.ShortcutKeys = Keys.Control | Keys.D2; + clientWindowedNoOVRToolStripMenuItem.Size = new Size(257, 22); + clientWindowedNoOVRToolStripMenuItem.Text = "Client (windowed, no OVR)"; + clientWindowedNoOVRToolStripMenuItem.Click += clientWindowedNoOVRToolStripMenuItem_Click; + // + // clientWindowedOVRToolStripMenuItem + // + clientWindowedOVRToolStripMenuItem.Name = "clientWindowedOVRToolStripMenuItem"; + clientWindowedOVRToolStripMenuItem.ShortcutKeys = Keys.Control | Keys.D3; + clientWindowedOVRToolStripMenuItem.Size = new Size(257, 22); + clientWindowedOVRToolStripMenuItem.Text = "Client (windowed)"; + clientWindowedOVRToolStripMenuItem.Click += clientWindowedOVRToolStripMenuItem_Click; + // + // offlineWindowedNoOVRToolStripMenuItem + // + offlineWindowedNoOVRToolStripMenuItem.Name = "offlineWindowedNoOVRToolStripMenuItem"; + offlineWindowedNoOVRToolStripMenuItem.ShortcutKeys = Keys.Control | Keys.D4; + offlineWindowedNoOVRToolStripMenuItem.Size = new Size(257, 22); + offlineWindowedNoOVRToolStripMenuItem.Text = "Offline (windowed)"; + offlineWindowedNoOVRToolStripMenuItem.Click += offlineWindowedNoOVRToolStripMenuItem_Click; + // + // clientOVRToolStripMenuItem + // + clientOVRToolStripMenuItem.Name = "clientOVRToolStripMenuItem"; + clientOVRToolStripMenuItem.ShortcutKeys = Keys.Control | Keys.D5; + clientOVRToolStripMenuItem.Size = new Size(257, 22); + clientOVRToolStripMenuItem.Text = "Client"; + clientOVRToolStripMenuItem.Click += clientOVRToolStripMenuItem_Click; + // + // offlineOVRToolStripMenuItem + // + offlineOVRToolStripMenuItem.Name = "offlineOVRToolStripMenuItem"; + offlineOVRToolStripMenuItem.ShortcutKeys = Keys.Control | Keys.D6; + offlineOVRToolStripMenuItem.Size = new Size(257, 22); + offlineOVRToolStripMenuItem.Text = "Offline"; + offlineOVRToolStripMenuItem.Click += offlineOVRToolStripMenuItem_Click; + // + // serverToolStripMenuItem + // + serverToolStripMenuItem.Name = "serverToolStripMenuItem"; + serverToolStripMenuItem.ShortcutKeys = Keys.Control | Keys.D1; + serverToolStripMenuItem.Size = new Size(257, 22); + serverToolStripMenuItem.Text = "Server"; + serverToolStripMenuItem.Click += serverToolStripMenuItem_Click; + // + // toolStripSeparator1 + // + toolStripSeparator1.Name = "toolStripSeparator1"; + toolStripSeparator1.Size = new Size(254, 6); + // + // customToolStripMenuItem + // + customToolStripMenuItem.Name = "customToolStripMenuItem"; + customToolStripMenuItem.ShortcutKeys = Keys.Control | Keys.G; + customToolStripMenuItem.Size = new Size(257, 22); + customToolStripMenuItem.Text = "Custom"; + customToolStripMenuItem.Click += customToolStripMenuItem_Click; + // + // settingsToolStripMenuItem + // + settingsToolStripMenuItem.Name = "settingsToolStripMenuItem"; + settingsToolStripMenuItem.Size = new Size(180, 22); + settingsToolStripMenuItem.Text = "Settings"; + settingsToolStripMenuItem.Click += settingsToolStripMenuItem_Click; + // + // helpToolStripMenuItem + // + helpToolStripMenuItem.DropDownItems.AddRange(new ToolStripItem[] { aboutToolStripMenuItem }); + helpToolStripMenuItem.Name = "helpToolStripMenuItem"; + helpToolStripMenuItem.Size = new Size(44, 20); + helpToolStripMenuItem.Text = "Help"; + // + // aboutToolStripMenuItem + // + aboutToolStripMenuItem.Name = "aboutToolStripMenuItem"; + aboutToolStripMenuItem.Size = new Size(180, 22); + aboutToolStripMenuItem.Text = "About"; + aboutToolStripMenuItem.Click += aboutToolStripMenuItem_Click; + // + // rtbLog + // + rtbLog.BackColor = SystemColors.Window; + rtbLog.ContextMenuStrip = ctxMenuServerLog; + rtbLog.Dock = DockStyle.Fill; + rtbLog.Location = new Point(3, 19); + rtbLog.Name = "rtbLog"; + rtbLog.ReadOnly = true; + rtbLog.Size = new Size(1327, 180); + rtbLog.TabIndex = 1; + rtbLog.Text = ""; + rtbLog.WordWrap = false; + // + // ctxMenuServerLog + // + ctxMenuServerLog.Items.AddRange(new ToolStripItem[] { clearToolStripMenuItem }); + ctxMenuServerLog.Name = "ctxMenuServerLog"; + ctxMenuServerLog.Size = new Size(102, 26); + // + // clearToolStripMenuItem + // + clearToolStripMenuItem.Name = "clearToolStripMenuItem"; + clearToolStripMenuItem.Size = new Size(101, 22); + clearToolStripMenuItem.Text = "Clear"; + clearToolStripMenuItem.Click += clearToolStripMenuItem_Click; + // + // toolStrip1 + // + toolStrip1.Items.AddRange(new ToolStripItem[] { btnToggleRunningState, toolStripSeparator2, btnLaunchGameServer, toolStripSeparator4, btnSaveChanges, btnRevertChanges }); + toolStrip1.Location = new Point(0, 24); + toolStrip1.Name = "toolStrip1"; + toolStrip1.Size = new Size(1333, 25); + toolStrip1.TabIndex = 2; + toolStrip1.Text = "toolStrip1"; + // + // btnToggleRunningState + // + btnToggleRunningState.DisplayStyle = ToolStripItemDisplayStyle.Image; + btnToggleRunningState.Image = EchoRelay.App.Properties.Resources.play_button_icon; + btnToggleRunningState.ImageTransparentColor = Color.Magenta; + btnToggleRunningState.Name = "btnToggleRunningState"; + btnToggleRunningState.Size = new Size(23, 22); + btnToggleRunningState.Text = "Toggle server on/off"; + btnToggleRunningState.Click += btnToggleRunningState_Click; + // + // toolStripSeparator2 + // + toolStripSeparator2.Name = "toolStripSeparator2"; + toolStripSeparator2.Size = new Size(6, 25); + // + // btnLaunchGameServer + // + btnLaunchGameServer.DisplayStyle = ToolStripItemDisplayStyle.Image; + btnLaunchGameServer.Image = EchoRelay.App.Properties.Resources.launch_server_button_icon; + btnLaunchGameServer.ImageTransparentColor = Color.Magenta; + btnLaunchGameServer.Name = "btnLaunchGameServer"; + btnLaunchGameServer.Size = new Size(23, 22); + btnLaunchGameServer.Text = "Launch Game Server"; + btnLaunchGameServer.Click += serverHeadlessToolStripMenuItem_Click; + // + // toolStripSeparator4 + // + toolStripSeparator4.Name = "toolStripSeparator4"; + toolStripSeparator4.Size = new Size(6, 25); + // + // btnSaveChanges + // + btnSaveChanges.DisplayStyle = ToolStripItemDisplayStyle.Image; + btnSaveChanges.Image = EchoRelay.App.Properties.Resources.save_button_icon; + btnSaveChanges.ImageTransparentColor = Color.Magenta; + btnSaveChanges.Name = "btnSaveChanges"; + btnSaveChanges.Size = new Size(23, 22); + btnSaveChanges.Text = "Save changes"; + btnSaveChanges.Click += btnSaveChanges_Click; + // + // btnRevertChanges + // + btnRevertChanges.DisplayStyle = ToolStripItemDisplayStyle.Image; + btnRevertChanges.Image = EchoRelay.App.Properties.Resources.undo_button_icon; + btnRevertChanges.ImageTransparentColor = Color.Magenta; + btnRevertChanges.Name = "btnRevertChanges"; + btnRevertChanges.Size = new Size(23, 22); + btnRevertChanges.Text = "Revert unsaved changes"; + btnRevertChanges.Click += btnRevertChanges_Click; + // + // tabControlMain + // + tabControlMain.Controls.Add(tabServerInfo); + tabControlMain.Controls.Add(tabPeers); + tabControlMain.Controls.Add(tabGameServers); + tabControlMain.Controls.Add(tabStorage); + tabControlMain.Dock = DockStyle.Fill; + tabControlMain.Location = new Point(0, 0); + tabControlMain.Name = "tabControlMain"; + tabControlMain.SelectedIndex = 0; + tabControlMain.Size = new Size(1333, 539); + tabControlMain.TabIndex = 3; + // + // tabServerInfo + // + tabServerInfo.Controls.Add(serverInfoControl); + tabServerInfo.Location = new Point(4, 24); + tabServerInfo.Name = "tabServerInfo"; + tabServerInfo.Padding = new Padding(3); + tabServerInfo.Size = new Size(1325, 511); + tabServerInfo.TabIndex = 0; + tabServerInfo.Text = "Server Info"; + tabServerInfo.UseVisualStyleBackColor = true; + // + // serverInfoControl + // + serverInfoControl.Dock = DockStyle.Fill; + serverInfoControl.Location = new Point(3, 3); + serverInfoControl.Name = "serverInfoControl"; + serverInfoControl.Size = new Size(1319, 505); + serverInfoControl.TabIndex = 0; + // + // tabPeers + // + tabPeers.Controls.Add(peerConnectionsControl); + tabPeers.Location = new Point(4, 24); + tabPeers.Name = "tabPeers"; + tabPeers.Padding = new Padding(3); + tabPeers.Size = new Size(1325, 511); + tabPeers.TabIndex = 2; + tabPeers.Text = "Peers"; + tabPeers.UseVisualStyleBackColor = true; + // + // peerConnectionsControl + // + peerConnectionsControl.Dock = DockStyle.Fill; + peerConnectionsControl.Location = new Point(3, 3); + peerConnectionsControl.Name = "peerConnectionsControl"; + peerConnectionsControl.Size = new Size(1319, 505); + peerConnectionsControl.TabIndex = 0; + // + // tabGameServers + // + tabGameServers.Controls.Add(gameServersControl); + tabGameServers.Location = new Point(4, 24); + tabGameServers.Name = "tabGameServers"; + tabGameServers.Padding = new Padding(3); + tabGameServers.Size = new Size(1325, 511); + tabGameServers.TabIndex = 3; + tabGameServers.Text = "Game Servers"; + tabGameServers.UseVisualStyleBackColor = true; + // + // gameServersControl + // + gameServersControl.Dock = DockStyle.Fill; + gameServersControl.Location = new Point(3, 3); + gameServersControl.Name = "gameServersControl"; + gameServersControl.Size = new Size(1319, 505); + gameServersControl.TabIndex = 0; + // + // tabStorage + // + tabStorage.Controls.Add(tabControlStorage); + tabStorage.Location = new Point(4, 24); + tabStorage.Name = "tabStorage"; + tabStorage.Padding = new Padding(3); + tabStorage.Size = new Size(1325, 511); + tabStorage.TabIndex = 1; + tabStorage.Text = "Storage"; + tabStorage.UseVisualStyleBackColor = true; + // + // tabControlStorage + // + tabControlStorage.Controls.Add(tabStorageAccessControls); + tabControlStorage.Controls.Add(tabStorageAccounts); + tabControlStorage.Controls.Add(tabStorageChannelInfo); + tabControlStorage.Controls.Add(tabStorageConfigs); + tabControlStorage.Controls.Add(tabStorageDocuments); + tabControlStorage.Controls.Add(tabStorageLoginSettings); + tabControlStorage.Dock = DockStyle.Fill; + tabControlStorage.Location = new Point(3, 3); + tabControlStorage.Name = "tabControlStorage"; + tabControlStorage.SelectedIndex = 0; + tabControlStorage.Size = new Size(1319, 505); + tabControlStorage.TabIndex = 0; + // + // tabStorageAccessControls + // + tabStorageAccessControls.Controls.Add(accessControlListEditor); + tabStorageAccessControls.Location = new Point(4, 24); + tabStorageAccessControls.Name = "tabStorageAccessControls"; + tabStorageAccessControls.Padding = new Padding(3); + tabStorageAccessControls.Size = new Size(1311, 477); + tabStorageAccessControls.TabIndex = 5; + tabStorageAccessControls.Text = "Access Controls"; + tabStorageAccessControls.UseVisualStyleBackColor = true; + // + // accessControlListEditor + // + accessControlListEditor.Changed = false; + accessControlListEditor.Dock = DockStyle.Fill; + accessControlListEditor.Location = new Point(3, 3); + accessControlListEditor.Name = "accessControlListEditor"; + accessControlListEditor.Size = new Size(1305, 471); + accessControlListEditor.Storage = null; + accessControlListEditor.TabIndex = 0; + // + // tabStorageAccounts + // + tabStorageAccounts.AutoScroll = true; + tabStorageAccounts.Controls.Add(accountSelector); + tabStorageAccounts.Location = new Point(4, 24); + tabStorageAccounts.Name = "tabStorageAccounts"; + tabStorageAccounts.Padding = new Padding(3); + tabStorageAccounts.Size = new Size(1311, 477); + tabStorageAccounts.TabIndex = 0; + tabStorageAccounts.Text = "Accounts"; + tabStorageAccounts.UseVisualStyleBackColor = true; + // + // accountSelector + // + accountSelector.Changed = false; + accountSelector.Dock = DockStyle.Fill; + accountSelector.Location = new Point(3, 3); + accountSelector.Name = "accountSelector"; + accountSelector.Size = new Size(1305, 471); + accountSelector.Storage = null; + accountSelector.TabIndex = 0; + // + // tabStorageChannelInfo + // + tabStorageChannelInfo.AutoScroll = true; + tabStorageChannelInfo.Controls.Add(channelInfoEditor); + tabStorageChannelInfo.Location = new Point(4, 24); + tabStorageChannelInfo.Name = "tabStorageChannelInfo"; + tabStorageChannelInfo.Padding = new Padding(3); + tabStorageChannelInfo.Size = new Size(1311, 477); + tabStorageChannelInfo.TabIndex = 1; + tabStorageChannelInfo.Text = "Channels"; + tabStorageChannelInfo.UseVisualStyleBackColor = true; + // + // channelInfoEditor + // + channelInfoEditor.Changed = false; + channelInfoEditor.Dock = DockStyle.Top; + channelInfoEditor.Location = new Point(3, 3); + channelInfoEditor.Name = "channelInfoEditor"; + channelInfoEditor.Size = new Size(1305, 362); + channelInfoEditor.Storage = null; + channelInfoEditor.TabIndex = 0; + // + // tabStorageConfigs + // + tabStorageConfigs.Location = new Point(4, 24); + tabStorageConfigs.Name = "tabStorageConfigs"; + tabStorageConfigs.Padding = new Padding(3); + tabStorageConfigs.Size = new Size(1311, 477); + tabStorageConfigs.TabIndex = 2; + tabStorageConfigs.Text = "Configs"; + tabStorageConfigs.UseVisualStyleBackColor = true; + // + // tabStorageDocuments + // + tabStorageDocuments.Location = new Point(4, 24); + tabStorageDocuments.Name = "tabStorageDocuments"; + tabStorageDocuments.Padding = new Padding(3); + tabStorageDocuments.Size = new Size(1311, 477); + tabStorageDocuments.TabIndex = 3; + tabStorageDocuments.Text = "Documents"; + tabStorageDocuments.UseVisualStyleBackColor = true; + // + // tabStorageLoginSettings + // + tabStorageLoginSettings.AutoScroll = true; + tabStorageLoginSettings.Controls.Add(loginSettingsEditor); + tabStorageLoginSettings.Location = new Point(4, 24); + tabStorageLoginSettings.Name = "tabStorageLoginSettings"; + tabStorageLoginSettings.Padding = new Padding(3); + tabStorageLoginSettings.Size = new Size(1311, 477); + tabStorageLoginSettings.TabIndex = 4; + tabStorageLoginSettings.Text = "Login Settings"; + tabStorageLoginSettings.UseVisualStyleBackColor = true; + // + // loginSettingsEditor + // + loginSettingsEditor.Changed = false; + loginSettingsEditor.Dock = DockStyle.Top; + loginSettingsEditor.Location = new Point(3, 3); + loginSettingsEditor.Name = "loginSettingsEditor"; + loginSettingsEditor.Size = new Size(1305, 226); + loginSettingsEditor.Storage = null; + loginSettingsEditor.TabIndex = 0; + // + // statusStripProgress + // + statusStripProgress.Items.AddRange(new ToolStripItem[] { lblToolStripPadding, lblStatusHeader, lblStatus, progressBarStatus }); + statusStripProgress.Location = new Point(0, 794); + statusStripProgress.Name = "statusStripProgress"; + statusStripProgress.Size = new Size(1333, 22); + statusStripProgress.TabIndex = 4; + statusStripProgress.Text = "statusStrip1"; + // + // lblToolStripPadding + // + lblToolStripPadding.Name = "lblToolStripPadding"; + lblToolStripPadding.Size = new Size(1148, 17); + lblToolStripPadding.Spring = true; + // + // lblStatusHeader + // + lblStatusHeader.Name = "lblStatusHeader"; + lblStatusHeader.Size = new Size(42, 17); + lblStatusHeader.Text = "Status:"; + // + // lblStatus + // + lblStatus.Name = "lblStatus"; + lblStatus.Size = new Size(26, 17); + lblStatus.Text = "Idle"; + // + // progressBarStatus + // + progressBarStatus.Alignment = ToolStripItemAlignment.Right; + progressBarStatus.Name = "progressBarStatus"; + progressBarStatus.Size = new Size(100, 16); + // + // panelTabStripContainer + // + panelTabStripContainer.Location = new Point(0, 49); + panelTabStripContainer.Name = "panelTabStripContainer"; + panelTabStripContainer.Size = new Size(1003, 560); + panelTabStripContainer.TabIndex = 5; + // + // splitContainer1 + // + splitContainer1.Dock = DockStyle.Fill; + splitContainer1.FixedPanel = FixedPanel.Panel2; + splitContainer1.Location = new Point(0, 49); + splitContainer1.Name = "splitContainer1"; + splitContainer1.Orientation = Orientation.Horizontal; + // + // splitContainer1.Panel1 + // + splitContainer1.Panel1.Controls.Add(tabControlMain); + // + // splitContainer1.Panel2 + // + splitContainer1.Panel2.Controls.Add(groupBoxLog); + splitContainer1.Size = new Size(1333, 745); + splitContainer1.SplitterDistance = 539; + splitContainer1.TabIndex = 4; + // + // groupBoxLog + // + groupBoxLog.Controls.Add(rtbLog); + groupBoxLog.Dock = DockStyle.Fill; + groupBoxLog.Location = new Point(0, 0); + groupBoxLog.Name = "groupBoxLog"; + groupBoxLog.Size = new Size(1333, 202); + groupBoxLog.TabIndex = 2; + groupBoxLog.TabStop = false; + groupBoxLog.Text = "Server Log"; + // + // MainWindow + // + AutoScaleDimensions = new SizeF(7F, 15F); + AutoScaleMode = AutoScaleMode.Font; + ClientSize = new Size(1333, 816); + Controls.Add(splitContainer1); + Controls.Add(panelTabStripContainer); + Controls.Add(statusStripProgress); + Controls.Add(toolStrip1); + Controls.Add(menuStrip1); + MainMenuStrip = menuStrip1; + Name = "MainWindow"; + Text = "Echo Relay"; + FormClosing += MainWindow_FormClosing; + Load += Form1_Load; + menuStrip1.ResumeLayout(false); + menuStrip1.PerformLayout(); + ctxMenuServerLog.ResumeLayout(false); + toolStrip1.ResumeLayout(false); + toolStrip1.PerformLayout(); + tabControlMain.ResumeLayout(false); + tabServerInfo.ResumeLayout(false); + tabPeers.ResumeLayout(false); + tabGameServers.ResumeLayout(false); + tabStorage.ResumeLayout(false); + tabControlStorage.ResumeLayout(false); + tabStorageAccessControls.ResumeLayout(false); + tabStorageAccounts.ResumeLayout(false); + tabStorageChannelInfo.ResumeLayout(false); + tabStorageLoginSettings.ResumeLayout(false); + statusStripProgress.ResumeLayout(false); + statusStripProgress.PerformLayout(); + splitContainer1.Panel1.ResumeLayout(false); + splitContainer1.Panel2.ResumeLayout(false); + ((System.ComponentModel.ISupportInitialize)splitContainer1).EndInit(); + splitContainer1.ResumeLayout(false); + groupBoxLog.ResumeLayout(false); + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private MenuStrip menuStrip1; + private ToolStripMenuItem fileToolStripMenuItem; + private ToolStripMenuItem exitToolStripMenuItem; + private ToolStripMenuItem editToolStripMenuItem; + private ToolStripMenuItem viewToolStripMenuItem; + private ToolStripMenuItem toolsToolStripMenuItem; + private ToolStripMenuItem helpToolStripMenuItem; + private ToolStripMenuItem aboutToolStripMenuItem; + private RichTextBox rtbLog; + private ToolStripMenuItem settingsToolStripMenuItem; + private ToolStripMenuItem launchEchoVRToolStripMenuItem; + private ToolStripSeparator toolStripSeparator1; + private ToolStripMenuItem clientOVRToolStripMenuItem; + private ToolStripMenuItem clientWindowedNoOVRToolStripMenuItem; + private ToolStripMenuItem serverHeadlessToolStripMenuItem; + private ToolStripMenuItem offlineOVRToolStripMenuItem; + private ToolStripMenuItem offlineWindowedNoOVRToolStripMenuItem; + private ToolStripMenuItem customToolStripMenuItem; + private ToolStripMenuItem clientWindowedOVRToolStripMenuItem; + private ToolStripMenuItem serverToolStripMenuItem; + private ToolStrip toolStrip1; + private ToolStripButton btnToggleRunningState; + private TabControl tabControlMain; + private TabPage tabServerInfo; + private TabPage tabStorage; + private TabControl tabControlStorage; + private TabPage tabStorageAccounts; + private TabPage tabStorageChannelInfo; + private TabPage tabStorageConfigs; + private TabPage tabStorageDocuments; + private TabPage tabStorageLoginSettings; + private StatusStrip statusStripProgress; + private ToolStripProgressBar progressBarStatus; + private ToolStripStatusLabel lblToolStripPadding; + private ToolStripStatusLabel lblStatus; + private ToolStripStatusLabel lblStatusHeader; + private Panel panelTabStripContainer; + private SplitContainer splitContainer1; + private EchoRelay.App.Forms.Controls.LoginSettingsEditor loginSettingsEditor; + private EchoRelay.App.Forms.Controls.ChannelInfoEditor channelInfoEditor; + private EchoRelay.App.Forms.Controls.ServerInfoControl serverInfoControl; + private TabPage tabPeers; + private TabPage tabGameServers; + private EchoRelay.App.Forms.Controls.PeerConnectionsControl peerConnectionsControl; + private ToolStripMenuItem startServerToolStripMenuItem; + private EchoRelay.App.Forms.Controls.GameServersControl gameServersControl; + private GroupBox groupBoxLog; + private ToolStripSeparator toolStripSeparator2; + private ToolStripButton btnLaunchGameServer; + private ToolStripMenuItem saveChangesToolStripMenuItem; + private ToolStripSeparator toolStripSeparator3; + private ToolStripMenuItem revertUnsavedChangesToolStripMenuItem; + private ToolStripSeparator toolStripSeparator4; + private ToolStripButton btnSaveChanges; + private ToolStripButton btnRevertChanges; + private EchoRelay.App.Forms.Controls.AccountSelector accountSelector; + private TabPage tabStorageAccessControls; + private EchoRelay.App.Forms.Controls.AccessControlListEditor accessControlListEditor; + private ContextMenuStrip ctxMenuServerLog; + private ToolStripMenuItem clearToolStripMenuItem; + } +} \ No newline at end of file diff --git a/EchoRelay.App/Forms/MainWindow.cs b/EchoRelay.App/Forms/MainWindow.cs new file mode 100644 index 0000000..4d54a17 --- /dev/null +++ b/EchoRelay.App/Forms/MainWindow.cs @@ -0,0 +1,486 @@ +using EchoRelay.App.Forms.Controls; +using EchoRelay.App.Forms.Dialogs; +using EchoRelay.App.Properties; +using EchoRelay.App.Settings; +using EchoRelay.App.Utils; +using EchoRelay.Core.Game; +using EchoRelay.Core.Server; +using EchoRelay.Core.Server.Messages; +using EchoRelay.Core.Server.Services; +using EchoRelay.Core.Server.Storage; +using EchoRelay.Core.Server.Storage.Filesystem; +using System.Net; + +namespace EchoRelay +{ + public partial class MainWindow : Form + { + #region Properties + /// + /// The original window title set for the app, to be restored if changed, e.g. when appending annotation for unsaved changes, then saving. + /// + private string OriginalWindowTitle { get; } + /// + /// The file path which the are loaded from. + /// + public string SettingsFilePath { get; } + /// + /// The application settings loaded from the . + /// + public AppSettings Settings { get; } + + /// + /// The websocket server used to power central game services. + /// + public Server Server { get; set; } + + /// + /// The UI editors for different storage resources. + /// + public StorageEditorBase[] StorageEditors { get; } + #endregion + + #region Constructor + public MainWindow() + { + InitializeComponent(); + + // Store the original window title + OriginalWindowTitle = this.Text; + + // Set our settings file path to be within the current directory. + SettingsFilePath = Path.Join(Environment.CurrentDirectory, "settings.json"); + + // Try to load our application settings. + AppSettings? settings = AppSettings.Load(SettingsFilePath); + + // Validate the settings. + if (settings == null || !settings.Validate()) + { + // Show our initial message describing what is about to happen. + MessageBox.Show("Application settings have not been correctly configured. Please configure them now.", "Echo Relay: Settings"); + + // If the settings weren't initialized, do so with some default values. + settings ??= new AppSettings(port: 777); + + // Display our settings dialog and expect an OK result (meaning the settings were saved, the dialog was not simply closed). + SettingsDialog settingsDialog = new SettingsDialog(settings, SettingsFilePath); + if (settingsDialog.ShowDialog() != DialogResult.OK) + { + // Close this window and return. + Environment.Exit(1); + return; + } + } + + // Set our loaded/created settings + Settings = settings; + + // Create our file system storage and open it. + ServerStorage serverStorage = new FilesystemServerStorage(Settings.FilesystemDatabaseDirectory!); + serverStorage.Open(); + + // Perform initial deployment + bool allCriticalResourcesExist = serverStorage.AccessControlList.Exists() && serverStorage.ChannelInfo.Exists() && serverStorage.LoginSettings.Exists() && serverStorage.SymbolCache.Exists(); + bool anyCriticalResourcesExist = serverStorage.AccessControlList.Exists() || serverStorage.ChannelInfo.Exists() || serverStorage.LoginSettings.Exists() || serverStorage.SymbolCache.Exists(); + bool performInitialSetup = !allCriticalResourcesExist; + if (performInitialSetup && anyCriticalResourcesExist) + { + performInitialSetup = MessageBox.Show("Critical resources are missing from storage, but storage is non-empty.\n" + + "Would you like to re-deploy initial setup resources? Warning: this will clear all storage except accounts!", "Redeployment", MessageBoxButtons.YesNo) == DialogResult.Yes; + } + if (performInitialSetup) + InitialDeployment.PerformInitialDeployment(serverStorage, Settings.GameExecutableDirectory, false); + + // Create our list of storage editors and set the storage for each. + StorageEditors = new StorageEditorBase[] { accessControlListEditor, accountSelector, channelInfoEditor, loginSettingsEditor }; + foreach (StorageEditorBase storageEditor in StorageEditors) + { + storageEditor.Storage = serverStorage; + storageEditor.OnUnsavedChangesStateChange += StorageEditor_OnUnsavedChangesStateChange; + } + + // Create a server instance and set up our event handlers + Server = new Server(serverStorage, + new ServerSettings( + port: Settings.Port, + serverDbApiKey: Settings.ServerDBApiKey, + favorPopulationOverPing: Settings.MatchingPopulationOverPing, + forceIntoAnySessionIfCreationFails: Settings.MatchingForceIntoAnySessionOnFailure + ) + ); + Server.OnServerStarted += Server_OnServerStarted; + Server.OnServerStopped += Server_OnServerStopped; + Server.OnAuthorizationResult += Server_OnAuthorizationResult; + Server.OnServicePeerConnected += Server_OnServicePeerConnected; + Server.OnServicePeerDisconnected += Server_OnServicePeerDisconnected; + Server.OnServicePeerAuthenticated += Server_OnServicePeerAuthenticated; + Server.OnServicePacketSent += Server_OnServicePacketSent; + Server.OnServicePacketReceived += Server_OnServicePacketReceived; + Server.ServerDBService.Registry.OnGameServerRegistered += Registry_OnGameServerRegistered; + Server.ServerDBService.Registry.OnGameServerUnregistered += Registry_OnGameServerUnregistered; + } + #endregion + + #region Functions + private string getPacketDisplayString(Packet packet) + { + string result = ""; + foreach (var message in packet) + { + result += $"\t{message}\n"; + } + return result; + } + #endregion + + #region Event Handlers + private async void Form1_Load(object sender, EventArgs e) + { + // Start the server if it is configured to start on startup. + if (Settings.StartServerOnStartup) + await Server.Start(); + } + + private void Server_OnServerStarted(Server server) + { + // Invoke the UI thread to perform updates. + this.InvokeUIThread(() => + { + btnToggleRunningState.Checked = true; + btnToggleRunningState.Image = Resources.stop_button_icon; + lblStatus.Text = "Running"; + startServerToolStripMenuItem.Text = "Stop server"; + progressBarStatus.Style = ProgressBarStyle.Marquee; + serverInfoControl.UpdateServerInfo(Server, true); + }); + } + private void Server_OnServerStopped(Server server) + { + // Invoke the UI thread to perform updates. + this.InvokeUIThread(() => + { + // Reset our UI state + btnToggleRunningState.Checked = false; + btnToggleRunningState.Image = Resources.play_button_icon; + lblStatus.Text = "Idle"; + startServerToolStripMenuItem.Text = "Start server"; + progressBarStatus.Style = ProgressBarStyle.Continuous; + serverInfoControl.UpdateServerInfo(null, true); + }); + } + + private void Server_OnAuthorizationResult(Server server, IPEndPoint client, bool authorized) + { + // Invoke the UI thread to perform updates. + this.InvokeUIThread(() => + { + // Add to our log + if (!authorized) + AppendLogText($"[SERVER] client({client.Address}:{client.Port}) failed authorization\n"); + }); + } + + private async void btnToggleRunningState_Click(object sender, EventArgs e) + { + // If the server is running + if (Server.Running) + Server.Stop(); + else + await Server.Start(); + } + + private void StorageEditor_OnUnsavedChangesStateChange(StorageEditorBase storageEditor, bool hasUnsavedChanges) + { + RefreshUnsavedChangesState(); + } + + private void RefreshUnsavedChangesState() + { + // Evaluate whether there are unsaved changes. + bool changed = false; + foreach (StorageEditorBase storageEditor in StorageEditors) + { + changed |= storageEditor.Changed; + } + + // Update the window title accordingly. + if (changed) + this.Text = OriginalWindowTitle + " [unsaved changes]**"; + else + this.Text = OriginalWindowTitle; + } + + private void btnSaveChanges_Click(object sender, EventArgs e) + { + // Save changes in all editors + foreach (StorageEditorBase storageEditor in StorageEditors) + storageEditor.SaveChanges(); + + // Refresh our window indicators for unsaved changes. + RefreshUnsavedChangesState(); + } + + private void btnRevertChanges_Click(object sender, EventArgs e) + { + // Provide a confirmation window. + if (MessageBox.Show("You are about to undo all unsaved changes in all editors. Would you like to continue?", "Echo Relay: Warning", MessageBoxButtons.YesNo) == DialogResult.Yes) + { + // Revert changes in all editors + foreach (StorageEditorBase storageEditor in StorageEditors) + storageEditor.RevertChanges(); + + // Refresh our window indicators for unsaved changes. + RefreshUnsavedChangesState(); + } + } + + private void Server_OnServicePeerConnected(Service service, Peer peer) + { + // Invoke the UI thread to perform updates. + this.InvokeUIThread(() => + { + // Add to our log + AppendLogText($"[{service.Name}] client({peer.Address}:{peer.Port}) connected\n"); + + // Update server info + serverInfoControl.UpdateServerInfo(Server, false); + + // Add our peer to the peers list + peerConnectionsControl.AddOrUpdatePeer(peer); + + // Update peer count on the peers tab. + tabPeers.Text = $"Peers ({peerConnectionsControl.PeerCount})"; + }); + } + + private void Server_OnServicePeerDisconnected(Service service, Peer peer) + { + // Invoke the UI thread to perform updates. + this.InvokeUIThread(() => + { + // Add to our log + AppendLogText($"[{service.Name}] client({peer.Address}:{peer.Port}) disconnected\n"); + + // Update server info + serverInfoControl.UpdateServerInfo(Server, false); + + // Remove a peer from the peers list. + peerConnectionsControl.RemovePeer(peer); + + // Update peer count on the peers tab. + tabPeers.Text = peerConnectionsControl.PeerCount > 0 ? $"Peers ({peerConnectionsControl.PeerCount})" : "Peers"; + }); + } + + private void Server_OnServicePeerAuthenticated(Service service, Peer peer, XPlatformId userId) + { + // Invoke the UI thread to perform updates. + this.InvokeUIThread(() => + { + // Update our peer. + peerConnectionsControl.AddOrUpdatePeer(peer); + }); + } + + private void Server_OnServicePacketReceived(EchoRelay.Core.Server.Services.Service service, EchoRelay.Core.Server.Services.Peer sender, EchoRelay.Core.Server.Messages.Packet packet) + { + // Invoke the UI thread to perform updates. + this.InvokeUIThread(() => + { + // Add to our log + AppendLogText($"[{service.Name}] client({sender.Address}:{sender.Port})->server:\n" + getPacketDisplayString(packet)); + }); + } + + private void Server_OnServicePacketSent(EchoRelay.Core.Server.Services.Service service, EchoRelay.Core.Server.Services.Peer sender, EchoRelay.Core.Server.Messages.Packet packet) + { + // Invoke the UI thread to perform updates. + this.InvokeUIThread(() => + { + // Add to our log + AppendLogText($"[{service.Name}] server->client({sender.Address}:{sender.Port}\n" + getPacketDisplayString(packet)); + }); + } + private void AppendLogText(string text) + { + // Check if the cursor is at the end of the textbox + bool endSelected = rtbLog.SelectionStart >= text.Length - 1; + + // Ensure the log buffer does not exceed the given number of lines. + int maxLineCount = 200; + if (rtbLog.Lines.Length > maxLineCount) + { + // Obtain the line start and length. + int startLineCharIndex = rtbLog.GetFirstCharIndexFromLine(0); + int endLineCharIndex = rtbLog.GetFirstCharIndexFromLine(rtbLog.Lines.Length - maxLineCount); + rtbLog.Text = rtbLog.Text.Remove(startLineCharIndex, endLineCharIndex - startLineCharIndex); + } + + // Append our log text + rtbLog.AppendText(text); + + // Scroll to the end again if the end was previously selected + if (endSelected) + { + rtbLog.SelectionStart = rtbLog.Text.Length; + rtbLog.ScrollToCaret(); + } + } + + private void Registry_OnGameServerRegistered(EchoRelay.Core.Server.Services.ServerDB.RegisteredGameServer gameServer) + { + // Invoke the UI thread to perform updates. + this.InvokeUIThread(() => + { + // Unregister any existing events + gameServer.OnPlayersAdded += GameServer_OnPlayersAdded; + gameServer.OnPlayerRemoved += GameServer_OnPlayerRemoved; + gameServer.OnSessionStateChanged += GameServer_OnSessionStateChanged; + + // Register our event handlers for any newly registered server. + gameServer.OnPlayersAdded += GameServer_OnPlayersAdded; + gameServer.OnPlayerRemoved += GameServer_OnPlayerRemoved; + gameServer.OnSessionStateChanged += GameServer_OnSessionStateChanged; + + // Add the game server to our UI control + gameServersControl.AddOrUpdateGameServer(gameServer); + + // Update server count on the game server tab. + tabGameServers.Text = $"Game Servers ({gameServersControl.GameServerCount})"; + }); + } + + private void Registry_OnGameServerUnregistered(EchoRelay.Core.Server.Services.ServerDB.RegisteredGameServer gameServer) + { + // Invoke the UI thread to perform updates. + this.InvokeUIThread(() => + { + // Remove the game server from our UI control + gameServersControl.RemoveGameServer(gameServer); + + // Update server count on the game server tab. + tabGameServers.Text = gameServersControl.GameServerCount > 0 ? $"Game Servers ({gameServersControl.GameServerCount})" : "Game Servers"; + }); + } + + private void GameServer_OnPlayersAdded(EchoRelay.Core.Server.Services.ServerDB.RegisteredGameServer gameServer, (Guid playerSession, Peer? peer)[] players) + { + // Invoke the UI thread to perform updates. + this.InvokeUIThread(() => + { + // Update the state of the game server in our UI control + gameServersControl.AddOrUpdateGameServer(gameServer); + }); + } + + private void GameServer_OnPlayerRemoved(EchoRelay.Core.Server.Services.ServerDB.RegisteredGameServer gameServer, Guid playerSession, Peer? peer) + { + // Invoke the UI thread to perform updates. + this.InvokeUIThread(() => + { + // Update the state of the game server in our UI control + gameServersControl.AddOrUpdateGameServer(gameServer); + }); + } + + private void GameServer_OnSessionStateChanged(EchoRelay.Core.Server.Services.ServerDB.RegisteredGameServer gameServer) + { + // Invoke the UI thread to perform updates. + this.InvokeUIThread(() => + { + // Update the state of the game server in our UI control + gameServersControl.AddOrUpdateGameServer(gameServer); + }); + } + + private void exitToolStripMenuItem_Click(object sender, EventArgs e) + { + // Begin closing this window. + Close(); + } + + private void MainWindow_FormClosing(object sender, FormClosingEventArgs e) + { + // Stop any server operations. + Server.Stop(); + } + + private void aboutToolStripMenuItem_Click(object sender, EventArgs e) + { + MessageBox.Show("For personal education/research purposes only."); + } + + private void settingsToolStripMenuItem_Click(object sender, EventArgs e) + { + // Display our settings dialog. If the user saved settings, we warn the application will be restarted. + SettingsDialog settingsDialog = new SettingsDialog(Settings, SettingsFilePath); + if (settingsDialog.ShowDialog() == DialogResult.OK) + { + MessageBox.Show("Application settings have been changed, the application will now restart."); + Application.Restart(); + return; + } + } + + private void serverHeadlessToolStripMenuItem_Click(object sender, EventArgs e) + { + // Launch game as a no OVR, spectator server. + GameLauncher.Launch(Settings.GameExecutableFilePath, GameLauncher.LaunchRole.Server, false, false, false, true, true, null); + } + + private void serverToolStripMenuItem_Click(object sender, EventArgs e) + { + // Launch game as a no OVR, spectator server. + GameLauncher.Launch(Settings.GameExecutableFilePath, GameLauncher.LaunchRole.Server, false, false, false, true, false, null); + } + + private void clientWindowedNoOVRToolStripMenuItem_Click(object sender, EventArgs e) + { + // Launch game as a spectator client. + GameLauncher.Launch(Settings.GameExecutableFilePath, GameLauncher.LaunchRole.Client, true, false, false, true, false, null); + } + + private void clientWindowedOVRToolStripMenuItem_Click(object sender, EventArgs e) + { + // Launch game as a spectator client. + GameLauncher.Launch(Settings.GameExecutableFilePath, GameLauncher.LaunchRole.Client, true, false, false, false, false, null); + } + + private void offlineWindowedNoOVRToolStripMenuItem_Click(object sender, EventArgs e) + { + // Launch game as a spectator offline user. + GameLauncher.Launch(Settings.GameExecutableFilePath, GameLauncher.LaunchRole.Client, true, false, false, true, false, null); + } + + private void clientOVRToolStripMenuItem_Click(object sender, EventArgs e) + { + // Launch game as a normal client. + GameLauncher.Launch(Settings.GameExecutableFilePath, GameLauncher.LaunchRole.Client, false, false, false, false, false, null); + } + + private void offlineOVRToolStripMenuItem_Click(object sender, EventArgs e) + { + // Launch game as a normal offline user. + GameLauncher.Launch(Settings.GameExecutableFilePath, GameLauncher.LaunchRole.Client, false, false, false, false, false, null); + } + + private void customToolStripMenuItem_Click(object sender, EventArgs e) + { + // Create a game launcher dialog and show it + GameLauncherDialog gameLauncherDialog = new GameLauncherDialog(Settings); + gameLauncherDialog.ShowDialog(); + } + + private void controlRuntimeGeneral_Load(object sender, EventArgs e) + { + + } + + private void clearToolStripMenuItem_Click(object sender, EventArgs e) + { + rtbLog.Text = ""; + } + #endregion + } +} diff --git a/EchoRelay.App/Forms/MainWindow.resx b/EchoRelay.App/Forms/MainWindow.resx new file mode 100644 index 0000000..19a128b --- /dev/null +++ b/EchoRelay.App/Forms/MainWindow.resx @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + 17, 17 + + + 394, 18 + + + 133, 18 + + + 238, 18 + + \ No newline at end of file diff --git a/EchoRelay.App/Program.cs b/EchoRelay.App/Program.cs new file mode 100644 index 0000000..17e2a23 --- /dev/null +++ b/EchoRelay.App/Program.cs @@ -0,0 +1,17 @@ +namespace EchoRelay +{ + internal static class Program + { + /// + /// The main entry point for the application. + /// + [STAThread] + static void Main() + { + // To customize application configuration such as set high DPI settings or default font, + // see https://aka.ms/applicationconfiguration. + ApplicationConfiguration.Initialize(); + Application.Run(new MainWindow()); + } + } +} diff --git a/EchoRelay.App/Properties/Resources.Designer.cs b/EchoRelay.App/Properties/Resources.Designer.cs new file mode 100644 index 0000000..826ed3b --- /dev/null +++ b/EchoRelay.App/Properties/Resources.Designer.cs @@ -0,0 +1,133 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace EchoRelay.App.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("EchoRelay.App.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap launch_server_button_icon { + get { + object obj = ResourceManager.GetObject("launch_server_button_icon", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap play_button_icon { + get { + object obj = ResourceManager.GetObject("play_button_icon", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap reload_button_icon { + get { + object obj = ResourceManager.GetObject("reload_button_icon", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap reload_button_icon_small { + get { + object obj = ResourceManager.GetObject("reload_button_icon_small", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap save_button_icon { + get { + object obj = ResourceManager.GetObject("save_button_icon", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap stop_button_icon { + get { + object obj = ResourceManager.GetObject("stop_button_icon", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + + /// + /// Looks up a localized resource of type System.Drawing.Bitmap. + /// + internal static System.Drawing.Bitmap undo_button_icon { + get { + object obj = ResourceManager.GetObject("undo_button_icon", resourceCulture); + return ((System.Drawing.Bitmap)(obj)); + } + } + } +} diff --git a/EchoRelay.App/Properties/Resources.resx b/EchoRelay.App/Properties/Resources.resx new file mode 100644 index 0000000..f006671 --- /dev/null +++ b/EchoRelay.App/Properties/Resources.resx @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + ..\Resources\launch_server_button_icon.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\play_button_icon.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\reload_button_icon.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\reload_button_icon_small.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\save_button_icon.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\stop_button_icon.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + + ..\Resources\undo_button_icon.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a + + \ No newline at end of file diff --git a/EchoRelay.App/README.md b/EchoRelay.App/README.md new file mode 100644 index 0000000..5654531 --- /dev/null +++ b/EchoRelay.App/README.md @@ -0,0 +1,19 @@ +# EchoRelay.App + +This is a simple/rough C#.NET WinForms app which demonstrates some of the capabilities of `EchoRelay.Core` by offering a visual configuration and operation of central services. + +To install this component, read the installation instructions within the solution's [README](../README.md). + +## Features + +`EchoRelay.App` simply reflects the _some_ of the work done within `EchoRelay.Core`. Not all features are exposed or completed in this example. + +Although this document will not expand on usage of this component in depth, sifting through surrounding READMEs and documentation should enable you to understand it well enough. + +In the least, the application was designed with some basic validation and error messages to guide you along. + +## Known issues + +- Quick launch options for offline client play do not actually work right now, because they do not provide the required `-level`, `-gametype` and `-region` arguments to load a valid level. +- There is a known UI state issue with game servers which crash in a specific way not being removed from the game servers list. +- Probably a good amount of random things beyond that. I won't expand much here as this really is a rough proof-of-concept UI for `EchoRelay.Core` and I didn't invest into this being robust. diff --git a/EchoRelay.App/Resources/launch_client_button_icon.png b/EchoRelay.App/Resources/launch_client_button_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..aea29baec8b921d848157e656bf23b44321cae5f GIT binary patch literal 1047 zcmV+y1nB#TP)(RCt{2om+^GK^Vt>yACUf*6Cssi;d(;$dz!h4crJPAlH(J`9bBZNIv1+ZuzM7h;uP^)U`|iH;&X}2B{cpal znP>jJ-}}uohi4%W2m}IwKp+qZ1OkZ}3oHi?0at<7z!%^b@Ev$1Kd%QSC2!tu`U9(g zyFipsmw<_;2VgrzzzX0~Hw?~I8&GX}Ft#!TINw8qbM*&UXnIh#F#>p&qRC3)o3RF1 z01U@-N`c23OwQL2;0RELr|;ym8JL`_7GNr#QIoOBN}qvIctQ#A){xOc*YJc|gC;9g z;sLiUF!~~nGu#4`Bbmkz#$gs&V6u|a8RRYt3|88W!(6ezU?q|EJ4>?#1}iP3p@rXSJOcs55#{vUJ&%;Cd0v|Fo`907F zPuZBE(W~+N&dSRSOcohwZ#<_`^FovJ3rI=WtkLLArUz#eMZj?lCLi-5F-c0Vl;_q5 zeV%2+AuXc=T@Z}Uh$uYK!Dy$5!V?{gc7nwzZ{CWws!R{cHl_m|n$mC4c0h7nV+wv# zb>cI{z)FoKE3Ne5tX*dpJJ)A?FkSSu4j6{xi~`om>%l)WG&oK?Q&cI3ALlmEAszH#z~Y+9uvUdqxFol%nFF3lTYCs2W!@?MCJ zXPD7Q~Hm-OXc5m_Wb~uk3j|jM>P*J%8~d=o4mw- z!bIr_ajffUhJ%cNc3@22ET+xC{qfA+-xcyON@PH_7L*KnCm%Ge3^06ZzdR?Fk(z#Z zNL91@F~!InheIX7fno%wTMg&to|P{H5iQNm#*r*M+^=sl9^jRQw+y+SjQQ; zZw3d{(n)$;B(phFaebBEq(+tDC+9$c*ba1Knz*5hqOfIh(WyN~d#RRC-eK72IR#>y zldvseV4BB@x050l44T{u^eYfszl4=-l9V?ti_zXWQbb2F?I@;B#)$7!g7%WqRePca zc4~YyXmm}C_?8(oT4c$o@x$P5$k3SoCm`+|KPg^U8w&&ifj}S-2m}JS&tIqCu2z50 RRjB{~002ovPDHLkV1nli(_#Ps literal 0 HcmV?d00001 diff --git a/EchoRelay.App/Resources/launch_client_noovr_button_icon.png b/EchoRelay.App/Resources/launch_client_noovr_button_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ba9e9498ba7db63e87e346bc3f95b757b65f9a23 GIT binary patch literal 882 zcmV-&1C9KNP)d#%O$yFu(5N^f)xszq2X_kSXfF? zn3BOlh*HSHUTDf?gV|1om}!(UD3_**a+z^EI(1IHO{ST5<~+~&%{jkMeK+sh`<(ap z>FK$g8z_pRD2k#eilQi0QwwYajsQ1-4xktK<&CHn`s?!(fX&?hy1c5(qi3{=P70%Ew2Dp&qi1yS zYz`>e9!nXdC$BA_=r(sur2@M`>Old09ruB0l?~-eja#l<>PU zD%0iuyRAVaHEvE_s{mOhP+DD0Vs4^afu{n*n1a&oYK+EA;FSv4nqwhF-xE-f5t_RpMW@`C zVMZCdlY21A+~?VhvDjrB!O$lT1{H1HD2k#eilQirqA+Ip2R5HO1kest8vp)U-xyoZ;j%<&D4$jmdz&mS`}xq+0R|AI~KpFJj8xtntDw`4nfA{ zS1ExTCj?&(5(}N)eRGTR(X9}ae4F8I@4`sMzWp2CW#sel25ya>X5BT91T()KyRc?L z&aH)y2q43w0%ixeW_;bpcx~;3Os<;@?f-uy$$Xo?RoLj0Kp6A#uS-OY{+Sn<1)QkU zKC*g3bJ&R=tup)~pSFn_)hWJiZ2z?#B(mbaY3U_<<8Qww8@fu)EnBez9r+lx_}>2Z zOJq~#?YQ;Ao1}J3+xB+uf(bVr6mxH9hn+acay|L@4Q2~DsCen^Z&h;zw&t#nS2ViB zCwd(h>XU9>n4wgkd}8Ur1e1x+B{s3-@wP3V_||Cup()A_n0A;+OexDLpZ&`3*xqR$ a#cTE6Y+i6dAP|^j89ZJ6T-G@yGywo}{!^g< literal 0 HcmV?d00001 diff --git a/EchoRelay.App/Resources/play_button_icon.png b/EchoRelay.App/Resources/play_button_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..bf43a080081b68929dd7eff72569ce8d744d4349 GIT binary patch literal 684 zcmV;d0#p5oP))TiK(bG-CopEA^g_s{N1P{t5=2B2t?F0x>i^*1SA7&S{dd2a1|ftHLI@#*2+9ud z3OEJ600+Pw??pxAC1AhhEARri;k~#>oc=-d&#ZxGz*X->M!+tR0oeCmXb++-WdZDZ zFSrNM25fCGWSkComWv+SwTn{&3k zRC=o8;YNqE&jUJV#L~y)}|W>y_Rb%C}VM@6jOEV9r{s^wx0JEme98 zkfDW2Z-KG|9#bh6EZbTuy#>uwTcx+)8EdKZ)=5shE4_7=BjAZd6SvY^$Jqey(lEY6 zQ(pKRiDs6Qeg2aYooFPQD0!_DP4O?ox4<=trkG@1-cj}xtFTa3uqrrZ1?#2~Wd-Xt zDP;xg#y_1;r&@pvdN@l_!5TN9tY8h$P*$);!YKbatRY9r3J&v2T&pxI@2A$evC1;m zGKY~Ht0YrxlSR4l7QC%>RxUT5l9x4C=G=G)9$Ki(xtUY&x}^#I+{{^-Z_+U(1#k4n zDCI>jfQOC;s5){BK8#YCbCXN(Dq3aE&HoA>qgLkJ{Hx$8dS%YdWd(mvmHEE+!dv6A zR}#D43vQK*2tMwyGUw*gUj%nunRBC;Da^!9l6`)%KkM}%LI@#*5JCt+d;9=nF)yM9 S(ySo>0000$=-&1d)ZMWwsS*rCCt4wH1|AP^9#> zAQT}hLn?|`+DwZQD$58eDz~CgEUB!BR_c$)BFscHA<3kcVv}-B7yI&S&57Zig=J}d zcjnIAH}~%R;IMz~+?jLUy)$#>oHG!KL?V$$aTx|o0Hy=2z!KmiU>&dp*aq|fT|g(W z5m*m&0M7z<0#^a2NLvvL16&L&09J{zl_Sw@y$9S4oE0rIIK$;vyaf2op#dt%cHjlz zf@qvFCjbx0XGc|8Hp?gR_@QW=Gl3PrfdCAl2FQMSYsNAJ7zeZiy#Xj*B`}Qc>lg~i zNZ=_Mq{^A>0UieGCqKL(&!l~OhV=Q+|z3srh6UI7jTpiJdR^r!YQ;E0sQ(CuW( zsq%|b1(gk!lYXBubQ_s*t>ev{3RHeI+`p6ttSU3%4&dJal%+C~eZWi$V`O5GR?qXr zU%(eKFSUwqYpVQ8CjpJXIkMxQ0o)@y|BarP*?P?L5`LyKGsvT!2D=V;1ZYmV>>L4H zByD}~sjqrzoYWXz4f43-fo%hx5D%{}rT~k8ZpVF}m(UpA404OZ!Tc`gdlIYm#PzdPvs+rV*z!)V|g$Dc9p z2GJAbcMjf)YY3McJhoj^eIY@&X3H*?v z1Nv;Cmi$o*ycnWE7N>al^FR&xr3P3Ul0nAEL5^>#j)4edhW34gkF^ti53qod@B}M?!re= z97>+z$E;#(f3kScZ4`%+$6!8VuC@%vEJ}oVi|3O@W8ScM$SjIMO9s)EM(AJp4#Nj@ z6(qW^lu^vF5$9Vx;C}K?ONMdBURPIo-!a~gjv`37jfp`%i)!C+m-xxM}ssC~2hGs+E>B z4RW~r_DD)}V8|{*gG@{N(8%F)-<6^0K=z0y?lm;V-h$-K1j`yjgRHK0hl|X~7A~g; zOdp~$klt#QyK~HZi?JJ*3-oP_gTT$?pE?PnH4O|g4oZ)2Mlxnw=*ntC7(Y{$skJOK zq6@DX6F*axnQNh^PTL%sIA>Ho9ki*4WW2iSBd(_0=k6a!Q|#ZBL5i}p?awsJf}c7o z&6>Eh9lUJQPRqCBG^%WnO$98i@uj6MbjjLOxcX#$C^;uXx%kXp;KZ~Ya5YK;Cy@p!K=q61@6~+GU}j0+)lp@}ZK$aR%@b@K2y`#A|5-67tF@ z$+P<<07G=iUFmr^tMQHwt-J(y&f%?_NvrWJliht$N~Akyn5@hml!z76M9NCFMOI50 z!={2n!&D{WW1^fewn_BI$G{c|T;49{7<=Szr#KKlkx0S$;?kK|kW%Q8NF)-8@PGUV XO^uDgo3Y)<00000NkvXXu0mjfPXIP)^@RCt{2n#pSvK@^9-Brc=IxDlgqL+~Ij|9}S(MT3Y3#U*N7 zP979@@Go$^=)o8i^yooQF$PHv9tA~li)d6v#Ra2?K_iM$%t*l#LZ)AJ)l5%MPsj&# z$#lJXzwUaks$K;K8MurERsu(WX5b0%5%>yp0Uv-zz$M@?PzQ`6Aq)dH0&PGKknwE4 zffiu3*ovJLuwUMICZhHZ*b%3o6~LPa1@vz(fyEAfROc#S1aQ890{XUIpa~dm5z(HD zh+5!&Od>LU+D%}hBEtOc#Y`aWIPf>%3a}ek0?YzxfC<2CU=eTtxD5R8m}*l4M0g3A z>=+LZfK4*}T&V=MN}3L{{|uPhPlU6Ok-*)M0nFM^50sHRl!whRx7bWmd^6jrEVSdS3AAFfQ1PvU5@V-jH(pX)RD>L*gh)yK zI7o=8H3#CmX=~4?;^#<%CH1OHL()W)0S}e&P2pU{Rm4+Z5`|~8#fLTd3W?)OAY*hY zK6Hjv?ZZTLOJShUQ~=j4e(VY};PORI#~`H9GX5)uSuUaNM_$l0rsHjLZg8bi z`aE{9|Jpa*2c_8Qa@pcC-F^aBfrE0kR|{C literal 0 HcmV?d00001 diff --git a/EchoRelay.App/Resources/save_button_icon.png b/EchoRelay.App/Resources/save_button_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..372598d824a3f77c7e0dc6cbe085ecd30eed0007 GIT binary patch literal 679 zcmeAS@N?(olHy`uVBq!ia0vp^DImv-;4ANBQm%etrhl=^4!8|7SEN)%lcRh>Re5>l-2>=f9u ztkZ0O?K}sz8nu1N!r*Itf_G)>cicm7^Y--DgRL@}f39HNHHY@(%_<8QPubhW( z=L8PTn-ls^wxu|qXsdBDQ;~KuOL6+N@p{iq&YO$kw>{T*(ottQZ}EhOxDgTKf?Zod1>;}&F+HD#n zKiNHDJTu|DB7Z{L9)6lA?SH@T!==_WLW=e-Ei(ia(@wP7tkzFTyHfJ;iqe5g@_ToA z`HI(P9H?yWuadlHZIS!+$GiWUQ*SlDlHcH_%%|KMCCP4*CTqc5XI|0tMd(@Tn^#+& irU6p|C^<0oRxmf1i-k|N?E45zc?_PeelF{r5}E)uIvCmj literal 0 HcmV?d00001 diff --git a/EchoRelay.App/Resources/screenshot.png b/EchoRelay.App/Resources/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..a74e87880ec579a343be0324103bf72a4520e07e GIT binary patch literal 101164 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 n&(Ijl2(-ERSFH=O3lbfcF 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 DDNEJ+ literal 0 HcmV?d00001 diff --git a/EchoRelay.App/Resources/stop_button_icon.png b/EchoRelay.App/Resources/stop_button_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..64632f63ee2bf2bd9f00aa1cf084fe5f9a9e86dd GIT binary patch literal 423 zcmeAS@N?(olHy`uVBq!ia0vp^DImyTVHauir01rjK@-f!n$_2}OR U=PSn}fg#V}>FVdQ&MBb@0OrZ8uK)l5 literal 0 HcmV?d00001 diff --git a/EchoRelay.App/Resources/undo_button_icon.png b/EchoRelay.App/Resources/undo_button_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..2916ba5c2745d492920af6c544c0f205acd9254d GIT binary patch literal 1037 zcmeAS@N?(olHy`uVBq!ia0vp^DIm>jm%L&-wlPO8$4T`_=aE?^K`v!G73)2Z)q? ze=)T;SS=8{!TCkUIwVw*dk@2=`K!cU#Fg#PI+gLKwdc(BZ=0CII6d9i?r2-5gq~1V zW|o^GyM?QVO{(a`iH5zY?Y?vO=qfYEG5tNoH^;~|>R5I{&CSN(V<{Sk2p|`$tl##l zIV+yN=KLVBgGc7`=TEO2EDt0s(Cswx>+D&Ru$Orq^X`V34&g!xP4ZIbg;wbAGtFhl z>X_hpAezdi{k_Utmv#Bicfd_XAkh!35t5OZ^1LB^xFC*E2hl8CEj#gW2xkI%{z=W8X8a8F7O^b zqI2{j`wiwAZH1>j_5CYV%QVl{uHDVLB*)QG)8i$RjGn<8Bgv;r6pn5*d~RyPVzp>a zh}wp*=X=_GT^zJ@Q=|L-y#MRwGSdoR`<9*B4ZP_uy>ZsAIooXC~ z!U-2Ib4#rFV))f~H;ZCk!X=IqQjRC~O-SUKq03R29PjpE>DG;S_EDR4yB?pqOd$*Zr7HzfOfv85&6VnAQT%4? z+c>f8`8z(@6{ohGzNlDo_*2`Yf9=-hNAJmZZBx9lCHH~o`>#y`<$fC$rnGH1sV`#A zROl)2Xq&nO<9VHPEm4A6J&em)B;~ivH<&J!&!WhFiZ8 + /// The JSON serializable settings used by the application. + /// + public class AppSettings + { + #region Properties + ///

+ /// The file path to the game executable. + /// + [JsonProperty("game_executable")] + public string GameExecutableFilePath { get; set; } = ""; + + /// + /// The directory containing the game executable, derived from . + /// + [JsonIgnore] + public string GameExecutableDirectory + { + get + { + return Directory.GetParent(GameExecutableFilePath)?.FullName ?? ""; + } + } + + /// + /// The TCP port which the server should bind to. + /// + [JsonProperty("port")] + public ushort Port { get; set; } + + /// + /// A path to the directory which should contain the filesystem-based database used by the application. + /// Note: This must be null if is set. + /// + [JsonProperty("filesystem_db")] + public string? FilesystemDatabaseDirectory { get; set; } + + /// + /// The connection string to the MongoDB database used by the application. + /// Note: This must be null if is set. + /// + [JsonProperty("mongo_db")] + public string? MongoDBConnectionString { get; set; } + + /// + /// Indicates whether the websocket server should be started when the process starts. + /// + [JsonProperty("start_server_on_startup")] + public bool StartServerOnStartup { get; set; } + + /// + /// An API key which if set (non-null), must be provided as a query parameter when + /// connecting to ServerDB to successfully pass game server registration. + /// + [JsonProperty("serverdb_api_key")] + public string? ServerDBApiKey { get; set; } + + + /// + /// Indicates whether the matching service should first prioritize populating game servers until full, or ping. + /// + [JsonProperty("matching_population_over_ping")] + public bool MatchingPopulationOverPing { get; set; } + + + /// + /// Indicates whether the matching service should force a match with any available session if it could not match. + /// This helps when there are no available game servers, but you would still like users to play with eachother. + /// + [JsonProperty("matching_force_into_any_session_on_failure")] + public bool MatchingForceIntoAnySessionOnFailure { get; set; } + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + #endregion + + #region Constructor + /// + /// Initializes a new . + /// + public AppSettings() + { + + } + public AppSettings(string gameExecutableFilePath = "", ushort port = 0, string? filesystemDatabaseDirectory = null, string? mongoDbConnectionString = null, bool startServerOnStartup = true, string? serverDbApiKey = null, bool matchingPopulationOverPing = true, bool matchingForceIntoAny = true) + { + GameExecutableFilePath = gameExecutableFilePath; + Port = port; + FilesystemDatabaseDirectory = filesystemDatabaseDirectory; + MongoDBConnectionString = mongoDbConnectionString; + StartServerOnStartup = startServerOnStartup; + ServerDBApiKey = serverDbApiKey; + MatchingPopulationOverPing = matchingPopulationOverPing; + MatchingForceIntoAnySessionOnFailure = matchingForceIntoAny; + } + #endregion + + #region Functions + /// + /// Ensures that the settings are appropriately configured for basic operation. + /// + /// Returns true if the settings are valid, false otherwise. + public bool Validate() + { + // Must have a valid game executable path. + if (string.IsNullOrEmpty(GameExecutableFilePath) || !File.Exists(GameExecutableFilePath)) + return false; + + // Must have some origin database to use. + if (string.IsNullOrEmpty(FilesystemDatabaseDirectory) && string.IsNullOrEmpty(FilesystemDatabaseDirectory)) + return false; + + // If using a filesystem database, the path must be valid. + if (!string.IsNullOrEmpty(FilesystemDatabaseDirectory) && !Directory.Exists(FilesystemDatabaseDirectory)) + return false; + + // Validation succeeded if we made it here. + return true; + } + + /// + /// Loads the application settings from a JSON file. + /// + /// The file path to load the application settings from. + /// Returns the settings if they could be loaded, otherwise null. + public static AppSettings? Load(string filePath) + { + // Check if the file exists. + if (!File.Exists(filePath)) + return null; + + // Read the file as text. + string jsonContents = File.ReadAllText(filePath); + + // Deserialize our settings from the file contents and return it. + AppSettings? settings = JsonConvert.DeserializeObject(jsonContents); + return settings; + } + + /// + /// Saves the application settings to a given filepath. + /// + /// The file path to save the settings to. + public void Save(string filePath) + { + // Serialize the settings. + string jsonContents = JsonConvert.SerializeObject(this); + + // Write it to file. + File.WriteAllText(filePath, jsonContents); + } + #endregion + } +} diff --git a/EchoRelay.App/Utils/ControlUtils.cs b/EchoRelay.App/Utils/ControlUtils.cs new file mode 100644 index 0000000..09ce58a --- /dev/null +++ b/EchoRelay.App/Utils/ControlUtils.cs @@ -0,0 +1,18 @@ +namespace EchoRelay.App.Utils +{ + public static class ControlUtils + { + public static void InvokeUIThread(this Control control, Action method) + { + if (control.Disposing || control.IsDisposed) return; + if (control.InvokeRequired) + try + { + control.Invoke(method); + } + catch (ObjectDisposedException) { } + else + method(); + } + } +} diff --git a/EchoRelay.Core.Test/EchoRelay.Core.Test.csproj b/EchoRelay.Core.Test/EchoRelay.Core.Test.csproj new file mode 100644 index 0000000..7ea78c8 --- /dev/null +++ b/EchoRelay.Core.Test/EchoRelay.Core.Test.csproj @@ -0,0 +1,29 @@ + + + + net7.0 + enable + enable + + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/EchoRelay.Core.Test/Messages/ConfigTests.cs b/EchoRelay.Core.Test/Messages/ConfigTests.cs new file mode 100644 index 0000000..3a2bd22 --- /dev/null +++ b/EchoRelay.Core.Test/Messages/ConfigTests.cs @@ -0,0 +1,28 @@ +using EchoRelay.Core.Server.Messages.Config; + +namespace EchoRelay.Core.Test.Messages +{ + public class ConfigTests + { + [Fact] + public void TestConfigFailurev2() + { + ConfigFailurev2 message = new ConfigFailurev2(); + message.Decode(Convert.FromHexString("000000000000000000000000000000007b2274797065223a20225465737454797065222c20226964223a2022546573744964656e746966696572222c20226572726f72636f6465223a20372c20226572726f72223a202248656c6c6f21227d00")); + Assert.Equal("TestType", message.Info.Type); + Assert.Equal("TestIdentifier", message.Info.Identifier); + Assert.Equal(7, message.Info.ErrorCode); + Assert.Equal("Hello!", message.Info.Error); + } + + [Fact] + public void TestConfigSuccess() + { + ConfigSuccessv2 message = new ConfigSuccessv2(); + message.Decode(Convert.FromHexString("7b0000000000000041010000000000000d00000028b52ffd200d6900007b226e756d223a203838387d00")); + Assert.Equal(123, message.TypeSymbol); + Assert.Equal(321, message.IdentifierSymbol); + Assert.Equal(888, message.Resource.AdditionalData["num"]); + } + } +} diff --git a/EchoRelay.Core.Test/Messages/PacketEncodingSettingsTest.cs b/EchoRelay.Core.Test/Messages/PacketEncodingSettingsTest.cs new file mode 100644 index 0000000..43c887f --- /dev/null +++ b/EchoRelay.Core.Test/Messages/PacketEncodingSettingsTest.cs @@ -0,0 +1,33 @@ +using EchoRelay.Core.Server.Services.ServerDB; + +namespace EchoRelay.Core.Test.Messages +{ + public class PacketEncodingSettingsTest + { + [Fact] + public void TestPacketEncoderSettings() + { + // Test decoding and re-encoding of common packet encoder settings configs + // One 32 byte, and one with 64 byte MAC digest sizes. + PacketEncoderSettings e = new PacketEncoderSettings(0x80080080000083); + Assert.True(e.EncryptionEnabled); + Assert.True(e.MacEnabled); + Assert.Equal(0x20, e.MacDigestSize); + Assert.Equal(0x20, e.MacKeySize); + Assert.Equal(0, e.MacPBKDF2IterationCount); + Assert.Equal(0x20, e.EncryptionKeySize); + Assert.Equal(0x20, e.RandomKeySize); + Assert.Equal((ulong)0x80080080000083, (ulong)e); + + e = new PacketEncoderSettings(0x80080080000103); + Assert.True(e.EncryptionEnabled); + Assert.True(e.MacEnabled); + Assert.Equal(0x40, e.MacDigestSize); + Assert.Equal(0x20, e.MacKeySize); + Assert.Equal(0, e.MacPBKDF2IterationCount); + Assert.Equal(0x20, e.EncryptionKeySize); + Assert.Equal(0x20, e.RandomKeySize); + Assert.Equal((ulong)0x80080080000103, (ulong)e); + } + } +} diff --git a/EchoRelay.Core.Test/Usings.cs b/EchoRelay.Core.Test/Usings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/EchoRelay.Core.Test/Usings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/EchoRelay.Core.Test/Utils/CompressionTests.cs b/EchoRelay.Core.Test/Utils/CompressionTests.cs new file mode 100644 index 0000000..214478b --- /dev/null +++ b/EchoRelay.Core.Test/Utils/CompressionTests.cs @@ -0,0 +1,75 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Test.Utils +{ + public class CompressionTests + { + [Fact] + public void TestZlibCompression() + { + // Create a map of compressed hex strings to decompressed results + var testCases = new Dictionary + { + { + // Compressed original value + "789c2bafa8040002d10169", + + // Expected decompressed value + "777879" + }, + { + // Compressed original value + "789c2bafa86c98307112000dd9039c", + + // Expected decompressed value + "77787980909192" + } + }; + + // Loop for each test case + foreach (var testCase in testCases) + { + // Obtain the test case values as bytes + byte[] compressed = Convert.FromHexString(testCase.Key); + byte[] expectedUncompressed = Convert.FromHexString(testCase.Value); + + // Decompress the compressed data and ensure it matches our expected result. + Assert.Equal(expectedUncompressed, Compression.DecompressZlib(compressed)); + } + } + + [Fact] + public void TestZstdCompression() + { + // Create a map of compressed hex strings to decompressed results + var testCases = new Dictionary + { + { + // Compressed original value + "28b52ffd2003190000777879", + + // Expected decompressed value + "777879" + }, + { + // Compressed original value + "28b52ffd200739000077787980909192", + + // Expected decompressed value + "77787980909192" + } + }; + + // Loop for each test case + foreach (var testCase in testCases) + { + // Obtain the test case values as bytes + byte[] compressed = Convert.FromHexString(testCase.Key); + byte[] expectedUncompressed = Convert.FromHexString(testCase.Value); + + // Decompress the compressed data and ensure it matches our expected result. + Assert.Equal(expectedUncompressed, Compression.DecompressZstd(compressed)); + } + } + } +} diff --git a/EchoRelay.Core.Test/Utils/StreamIOTests.cs b/EchoRelay.Core.Test/Utils/StreamIOTests.cs new file mode 100644 index 0000000..211e2e2 --- /dev/null +++ b/EchoRelay.Core.Test/Utils/StreamIOTests.cs @@ -0,0 +1,150 @@ +using EchoRelay.Core.Utils; +using System.Net; + +namespace EchoRelay.Core.Test.Utils +{ + public class StreamIOTests + { + [Fact] + public void TestValueStreamingDefaultEndian() + { + // Try both byte orders + ByteOrder[] byteOrders = { ByteOrder.LittleEndian, ByteOrder.BigEndian }; + foreach (ByteOrder byteOrder in byteOrders) + { + // Create a new IO + StreamIO io = new StreamIO(byteOrder); + + // Create data to be streamed + byte b = 0x77; + byte[] bArr = new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99 }; + short i16 = 0x1234; + ushort ui16 = 0xFEDC; + int i32 = 0x12345678; + uint ui32 = 0x87654321; + long i64 = 0x1122334455667788; + ulong ui64 = 0x8877665544332211; + Int128 i128 = Int128.Parse("-85070591730234615865843651857942052864"); + UInt128 ui128 = UInt128.Parse("85070591730234615865843651857942052864"); + float f32 = 0.1234567f; + double f64 = 0.1234567891234; + string str = "testUTF8Яα⾀"; + + // Write the data, then read it back using stream methods + StreamMode[] streamModes = { StreamMode.Write, StreamMode.Read }; + foreach (StreamMode streamMode in streamModes) + { + // Set the stream mode, reset our IO position, and stream the data. + io.Position = 0; + io.StreamMode = streamMode; + io.Stream(ref b); + io.Stream(ref bArr); + io.Stream(ref i16); + io.Stream(ref ui16); + io.Stream(ref i32); + io.Stream(ref ui32); + io.Stream(ref i64); + io.Stream(ref ui64); + io.Stream(ref f32); + io.Stream(ref f64); + io.Stream(ref str, true); + io.Stream(ref i128); + io.Stream(ref ui128); + + // If this is the write operation, reset values in preparation for the read, to be sure our code did stream it in properly. + if (streamMode == StreamMode.Write) + { + b = 0; + bArr = new byte[bArr.Length]; + i16 = 0; + ui16 = 0; + i32 = 0; + ui32 = 0; + i64 = 0; + ui64 = 0; + f32 = 0; + f64 = 0; + str = ""; + i128 = 0; + ui128 = 0; + } + + Assert.Equal(io.Length, io.Position); + } + + Assert.Equal((byte)0x77, b); + Assert.Equal(new byte[] { 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99 }, bArr); + Assert.Equal((short)0x1234, i16); + Assert.Equal((ushort)0xFEDC, ui16); + Assert.Equal(0x12345678, i32); + Assert.Equal(0x87654321, ui32); + Assert.Equal(0x1122334455667788, i64); + Assert.Equal(0x8877665544332211, ui64); + Assert.Equal(0.1234567f, f32); + Assert.Equal(0.1234567891234, f64); + Assert.Equal("testUTF8Яα⾀", str); + Assert.Equal(Int128.Parse("-85070591730234615865843651857942052864"), i128); + Assert.Equal(UInt128.Parse("85070591730234615865843651857942052864"), ui128); + io.Close(); + } + } + + [Fact] + public void TestGuidStreaming() + { + // Create a new IO + StreamIO io = new StreamIO(ByteOrder.LittleEndian); + + // Write a guid + Guid guid = Guid.Parse("90dd4db5-b5dd-4655-839e-fdbe5f4bc0bf"); + + + // Write the data, then read it back using stream methods + StreamMode[] streamModes = { StreamMode.Write, StreamMode.Read }; + foreach (StreamMode streamMode in streamModes) + { + // Set the stream mode, reset our IO position, and stream the data. + io.Position = 0; + io.StreamMode = streamMode; + io.Stream(ref guid); + + // If this is the write operation, reset values in preparation for the read, to be sure our code did stream it in properly. + if (streamMode == StreamMode.Write) + { + guid = Guid.Parse("00000000-0000-0000-0000-000000000000"); + + // Since this is the only value we're writing, lets obtain the byte buffer and verify it. + byte[] guidBytes = io.ToArray(); + Assert.Equal(Convert.FromHexString("b54ddd90ddb55546839efdbe5f4bc0bf"), guidBytes); + } + + Assert.Equal(io.Length, io.Position); + } + + Assert.Equal(Guid.Parse("90dd4db5-b5dd-4655-839e-fdbe5f4bc0bf"), guid); + + io.Close(); + } + + [Fact] + public void TestIPAddress() + { + // Define our address bytes + byte[] expectedBytes = new byte[]{ 0x11, 0x22, 0x33, 0x44 }; + IPAddress expectedAddr = IPAddress.Parse("17.34.51.68"); + + // Read an IP address from bytes. + StreamIO io = new StreamIO(expectedBytes, ByteOrder.LittleEndian, StreamMode.Read); + IPAddress addr = io.ReadIPv4Address(ByteOrder.BigEndian); + io.Close(); + Assert.Equal(expectedAddr, addr); + + // Write the IP address to bytes. + io = new StreamIO(ByteOrder.BigEndian, StreamMode.Write); + io.Write(addr); + byte[] addrBytes = io.ToArray(); + io.Close(); + Assert.Equal(expectedBytes, addrBytes); + } + } +} diff --git a/EchoRelay.Core/EchoRelay.Core.csproj b/EchoRelay.Core/EchoRelay.Core.csproj new file mode 100644 index 0000000..a8f3f54 --- /dev/null +++ b/EchoRelay.Core/EchoRelay.Core.csproj @@ -0,0 +1,35 @@ + + + + net7.0 + enable + enable + + + + + + + + + + + + + + + + True + True + Resources.resx + + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + + diff --git a/EchoRelay.Core/Game/GameLauncher.cs b/EchoRelay.Core/Game/GameLauncher.cs new file mode 100644 index 0000000..12058e5 --- /dev/null +++ b/EchoRelay.Core/Game/GameLauncher.cs @@ -0,0 +1,55 @@ +using System.Diagnostics; + +namespace EchoRelay.Core.Game +{ + /// + /// Launches the game with different configurable roles/settings. + /// + public abstract class GameLauncher + { + public static void Launch(string executableFilePath, LaunchRole role = LaunchRole.Client, bool windowed = false, bool spectatorStream = false, bool moderator = false, bool noOVR = false, bool headless = false, List? additionalArgs = null) + { + // Create a list of arguments + List args = additionalArgs ?? new List(); + + // Add any role related arguments (client role = no CLI argument here) + switch(role) + { + case LaunchRole.Server: + args.Add("-server"); + break; + + case LaunchRole.Offline: + args.Add("-offline"); + break; + } + + // Add our flags + if (windowed) + args.Add("-windowed"); + if (spectatorStream) + args.Add("-spectatorstream"); + if (moderator) + args.Add("-moderator"); + if (noOVR) + args.Add("-noovr"); + if (headless) + args.Add("-headless"); + + // Start the process with our provided arguments. + Process.Start(executableFilePath, args); + } + + #region Enums + /// + /// Describes the type of launch that should occur. A client, server, or offline mode. + /// + public enum LaunchRole : int + { + Client = 0, + Server = 1, + Offline = 2, + } + #endregion + } +} diff --git a/EchoRelay.Core/Game/Language.cs b/EchoRelay.Core/Game/Language.cs new file mode 100644 index 0000000..7f9202b --- /dev/null +++ b/EchoRelay.Core/Game/Language.cs @@ -0,0 +1,24 @@ +namespace EchoRelay.Core.Game +{ + public abstract class Language + { + public static readonly string English = "en"; + public static readonly string French = "fr"; + public static readonly string Italian = "it"; + public static readonly string German = "de"; + public static readonly string Spanish = "es"; + public static readonly string Dutch = "nl"; + public static readonly string Portuguese = "pt"; + public static readonly string Russian = "ru"; + public static readonly string Polish = "pl"; + public static readonly string Swedish = "sv"; + public static readonly string Norwegian = "no"; + public static readonly string Finnish = "fi"; + public static readonly string Danish = "da"; + public static readonly string Czech = "cz"; + public static readonly string Turkish = "tr"; + public static readonly string Japanese = "ja"; + public static readonly string Korean = "ko"; + public static readonly string Chinese = "zh"; + } +} diff --git a/EchoRelay.Core/Game/PlatformCode.cs b/EchoRelay.Core/Game/PlatformCode.cs new file mode 100644 index 0000000..8cd0c36 --- /dev/null +++ b/EchoRelay.Core/Game/PlatformCode.cs @@ -0,0 +1,125 @@ +namespace EchoRelay.Core.Game +{ + /// + /// Describes the platforms which a client may be operating on. + /// + public enum PlatformCode : int + { + /// + /// Steam + /// + STM = 1, + + /// + /// Playstation + /// + PSN = 2, + + /// + /// Xbox + /// + XBX = 3, + + /// + /// Oculus VR user + /// + OVR_ORG = 4, + + /// + /// Oculus VR + /// + OVR = 5, + + /// + /// Bot/AI + /// + BOT = 6, + + /// + /// Demo (no ovr) + /// + DMO = 7, + + /// + /// TODO: Tencent? + /// + TEN = 8 + } + + /// + /// Describes extension methods for a given . + /// + public static class PlatformCodeExtensions + { + /// + /// Obtains a platform prefix string for a given . + /// + /// The to obtain an platform prefix string for. + /// Returns the platform prefix string for the provided . + public static string GetPrefix(this PlatformCode code) + { + // Try to obtain a name for this platform code. + string? name = Enum.GetName(typeof(PlatformCode), code); + + // If we could obtain one, the prefix should just be the same as the name, but with underscores represented as dashes. + if (name != null) + return name.Replace("_", "-"); + + // An unknown/invalid platform is denoted with the value returned below. + return "???"; + } + + /// + /// Obtains a display name for a given . + /// + /// The to obtain a display name for. + /// Returns the display name string for the provided . + public static string GetDisplayName(this PlatformCode code) + { + // Switch on the provided platform code and return a display name. + switch (code) + { + case PlatformCode.STM: + return "Steam"; + case PlatformCode.PSN: + return "Playstation"; + case PlatformCode.XBX: + return "Xbox"; + case PlatformCode.OVR_ORG: + return "Oculus VR (ORG)"; + case PlatformCode.OVR: + return "Oculus VR"; + case PlatformCode.BOT: + return "Bot"; + case PlatformCode.DMO: + return "Demo"; + case PlatformCode.TEN: + return "Tencent"; // TODO: Verify, this is only suspected to be the target of "TEN". + default: + return "Unknown"; + + } + } + + /// + /// Parses a string generated from 's ToString() method back into a ."/> + /// + /// The string to parse. + /// The parsed from the string, or 0 (invalid option) otherwise. + public static PlatformCode Parse(string s) + { + // Convert any underscores in the string to dashes. + s = s.Replace("-", "_"); + + // Get the enum option to represent this. + try + { + return (PlatformCode)Enum.Parse(typeof(PlatformCode), s); + } + catch + { + return 0; + } + } + } +} diff --git a/EchoRelay.Core/Game/ServiceConfig.cs b/EchoRelay.Core/Game/ServiceConfig.cs new file mode 100644 index 0000000..cf86c80 --- /dev/null +++ b/EchoRelay.Core/Game/ServiceConfig.cs @@ -0,0 +1,91 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Game +{ + /// + /// The JSON service-related config used by Echo VR to indicate service endpoints and other configuration information. + /// + public class ServiceConfig + { + #region Properties + /// + /// An unofficial JSON key used to denote the endpoint for the HTTP(S) API server. + /// This is only supported with EchoRelay patches. + /// + [JsonProperty("apiservice_host")] + public string? ApiServiceHost { get; set; } + + /// + /// The endpoint to be used for the config service. + /// + [JsonProperty("configservice_host")] + public string ConfigServiceHost { get; set; } + + /// + /// The endpoint to be used for the login service. + /// + [JsonProperty("loginservice_host")] + public string LoginServiceHost { get; set; } + + /// + /// The endpoint to be used for the matching service. + /// + [JsonProperty("matchingservice_host")] + public string MatchingServiceHost { get; set; } + + /// + /// The endpoint to be used for the serverdb service. + /// + [JsonProperty("serverdb_host")] + public string? ServerDBServiceHost { get; set; } + + /// + /// The endpoint to be used for the transaction service. + /// + [JsonProperty("transactionservice_host")] + public string TransactionServiceHost { get; set; } + + /// + /// The environment/publisher lock to be used. + /// + [JsonProperty("publisher_lock")] + public string PublisherLock { get; set; } + + /// + /// Additional properties of the resource. Each resource defines its own set of fields. + /// + [JsonExtensionData] + public IDictionary AdditionalData; + #endregion + + #region Constructor + /// + /// Initializes a new . + /// + public ServiceConfig() : this(null, "", "", "", "", "", "") + { + // Initialize our additional tokens. + AdditionalData = new Dictionary(); + } + + /// + /// Initializes a new with the provided arguments. + /// + public ServiceConfig(string? apiServiceHost, string configServiceHost, string loginServiceHost, string matchingServiceHost, string? serverdbServiceHost, string transactionServiceHost, string publisherLock) + { + // Set our provided arguments. + ApiServiceHost = apiServiceHost; + ConfigServiceHost = configServiceHost; + LoginServiceHost = loginServiceHost; + MatchingServiceHost = matchingServiceHost; + ServerDBServiceHost = serverdbServiceHost; + TransactionServiceHost = transactionServiceHost; + PublisherLock = publisherLock; + + // Initialize our additional tokens. + AdditionalData = new Dictionary(); + } + #endregion + } +} diff --git a/EchoRelay.Core/Game/TeamIndex.cs b/EchoRelay.Core/Game/TeamIndex.cs new file mode 100644 index 0000000..90210e1 --- /dev/null +++ b/EchoRelay.Core/Game/TeamIndex.cs @@ -0,0 +1,38 @@ +namespace EchoRelay.Core.Game +{ + /// + /// Indicates what a given team index in the game represents in terms of user roles. + /// + public enum TeamIndex : short + { + /// + /// Indicates any team index should be assigned by the game server. + /// + Any = -1, + + /// + /// The player is assigned or requesting to be assigned to the blue team. + /// + Blue = 0, + + /// + /// The player is assigned or requesting to be assigned to the orange team. + /// + Orange = 1, + + /// + /// The player is assigned or requesting to be assigned to a spectator team, invisible to other players. + /// + Spectator = 2, + + /// + /// TODO: This is unknown. From loose memory, might've been used as the team everyone is assigned to for social lobbies? + /// + Unknown = 3, + + /// + /// The player is assigned or requesting to be assigned to a moderator team role, invisible to other players and able to fly around. + /// + Moderator = 4, + } +} diff --git a/EchoRelay.Core/Game/XPlatformId.cs b/EchoRelay.Core/Game/XPlatformId.cs new file mode 100644 index 0000000..c604b2d --- /dev/null +++ b/EchoRelay.Core/Game/XPlatformId.cs @@ -0,0 +1,179 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Game +{ + /// + /// An identifier for a user on the platform. + /// + public class XPlatformId : IStreamable + { + #region Constants + /// + /// The serialized/streamed size of this object. + /// + public const int SIZE = 16; + #endregion + + #region Properties/Fields + private ulong _platformCode; + + /// + /// The platform code the identifier belongs to. + /// + public PlatformCode PlatformCode + { + get { return (PlatformCode)_platformCode; } + set { _platformCode = (ulong)value; } + } + /// + /// The identifier of the account on the given platform. + /// + public ulong AccountId; + #endregion + + #region Constructors + /// + /// Initializes a new . + /// + public XPlatformId() + { + } + /// + /// Initializes a new with the provided arguments. + /// + /// The platform the account belongs to. + /// The unique identifier of the account. + public XPlatformId(PlatformCode platformCode, ulong accountId) + { + PlatformCode = platformCode; + AccountId = accountId; + } + #endregion + + #region Functions + /// + /// Verifies that the is well structured by ensuring it has a valid . + /// + /// Returns true if valid, otherwise false. + public bool Valid() + { + return Enum.IsDefined(typeof(PlatformCode), PlatformCode); + } + + /// + /// Parses a string into a given platform identifier. + /// + /// The string to attempt to parse as a platform id. + /// The platform identifier, or null if it could not be parsed. + public static XPlatformId? Parse(string s) + { + // Obtain the position of the last dash. + int dashIndex = s.LastIndexOf('-'); + if (dashIndex < 0) + return null; + + // Split it there + string platformCodeStr = s.Substring(0, dashIndex); + string accountIdStr = s.Substring(dashIndex + 1); + + // Determine the platform code. + PlatformCode code = PlatformCodeExtensions.Parse(platformCodeStr); + + // Try to parse the account identifier + if (!ulong.TryParse(accountIdStr, out ulong accountId)) + return null; + + // Create the identifier + XPlatformId platformId = new XPlatformId(code, accountId); + return platformId; + } + + /// + /// Streams the data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public void Stream(StreamIO io) + { + io.Stream(ref _platformCode); + io.Stream(ref AccountId); + } + + /// + /// Overloads the equality operator to ensure equality matches if the underlying fields match. + /// + /// The first object to compare. + /// The second object to compare. + /// The result of the comparison. + public static bool operator ==(XPlatformId? a, XPlatformId? b) + { + // If the references match, they match. + if (ReferenceEquals(a, b)) + return true; + + // If only one is null, they do not match. + if (((object?)a == null) || ((object?)b == null)) + { + return false; + } + + // If both fields match, they match. + return a.PlatformCode == b.PlatformCode && a.AccountId == b.AccountId; + } + /// + /// Overloads the equality operator to ensure equality matches if the underlying fields match. + /// + /// The first object to compare. + /// The second object to compare. + /// The result of the comparison. + public static bool operator !=(XPlatformId? a, XPlatformId? b) + { + // If the references match, they match. + if (!ReferenceEquals(a, b)) + return true; + + // If only one is null, they do not match. + if (((object?)a == null) || ((object?)b == null)) + { + return true; + } + + // If both fields match, they match. + return a.PlatformCode != b.PlatformCode || a.AccountId != b.AccountId; + } + + /// + /// Checks equality of this object against another. + /// This ensures two platform identifiers match even if they are different instances. + /// + /// The object to compare against. + /// The result of the equality check. + public override bool Equals(object? obj) + { + // Obtain the object as a platform id. + XPlatformId? objP = obj as XPlatformId; + if (objP == null) + return false; + + return AccountId == objP.AccountId && PlatformCode == objP.PlatformCode; + } + + /// + /// Obtains a hash code for this item to be used in dictionaries, etc. + /// + /// Returns the hashcode for this platform identifier. + public override int GetHashCode() + { + return AccountId.GetHashCode() ^ PlatformCode.GetHashCode(); + } + + /// + /// Obtains a string representation of the platform identifier (e.g. DMO-XXXXXXXXXXXXXXXXXX or OVR-ORG-XXXXXXXXXXXXXX ). + /// + /// A string representation of the platform identifier. + public override string ToString() + { + return $"{PlatformCode.GetPrefix()}-{AccountId}"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Properties/Resources.Designer.cs b/EchoRelay.Core/Properties/Resources.Designer.cs new file mode 100644 index 0000000..00c3a0b --- /dev/null +++ b/EchoRelay.Core/Properties/Resources.Designer.cs @@ -0,0 +1,63 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace EchoRelay.Core.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("EchoRelay.Core.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + } +} diff --git a/EchoRelay.Core/Properties/Resources.resx b/EchoRelay.Core/Properties/Resources.resx new file mode 100644 index 0000000..4fdb1b6 --- /dev/null +++ b/EchoRelay.Core/Properties/Resources.resx @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.3500.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/EchoRelay.Core/README.md b/EchoRelay.Core/README.md new file mode 100644 index 0000000..99449ec --- /dev/null +++ b/EchoRelay.Core/README.md @@ -0,0 +1,27 @@ +# EchoRelay.Core + +This library provides functionality to host central backend services for Echo VR. + +To install this component, read the installation instructions within the solution's [README](../README.md). + +## Features + +`EchoRelay.Core` implements various features : +- **Account operations**: The `LOGIN` service manages logins and handles requests to update accounts, fetch other user accounts, etc. +- **Server resource management**: Handling and serving of config, document, channel info, and other resources comes via the `CONFIG` and `LOGIN` services. +- **Matching operations**: The `MATCHING` service can be configured to prioritize matching clients to game servers with lowest ping, or highest population first. It establishes per-user game server packet encoding settings, e.g. encryption and verification keys. If a session does not exist, it will create one on an unallocated registered game server. +- **Game server management**: The `SERVERDB` service accepts game server registrations, enforces API key authentication, and manages game sessions. It commands registered game servers to start new sessions, expect a connection with per-user packet encoding settings, accept a player on an established connection, reject/kick them, and tracks locking/unlocking of lobbies. +- **Access control management**: Users can be kicked from lobbies, accounts can be banned until a given time, and IP addresses can be subjected to allow/deny lists. +- **Evaluation of game symbols**: The names for message identifiers and other symbols can be observed, if the game files contain them. +- **Quick launching**: Launching Echo VR in different operating modes is wrapped through a game launcher provider, in accordance with `EchoRelay.Patch`'s extended command-line argument support. + +## Known issues +- The region identifier and version lock for game servers and clients is captured but not enforced. +- Maybe spectators and moderators should not count against the game session player limit(?), but it is currently enforced that way. +- `ProcessUserServerProfileUpdateRequest` should enforce some type of authentication, currently anyone can update anyone else's server profile. +- Lacks support for at least two failure messages and simply does not respond instead: + - The `UpdateProfileFailure` response + - The failure response to `UserServerProfileUpdateRequest`. +- Server resources are the same instance when loaded from storage, not copies. e.g. Profile update operations may collide in an extremely rare scenario. + +There are likely other issues elsewhere. I did not spend time enumerating all of them, nor would it have been considered in-scope for this project to address all of them. diff --git a/EchoRelay.Core/Server/Messages/Common/TcpConnectionUnrequireEvent.cs b/EchoRelay.Core/Server/Messages/Common/TcpConnectionUnrequireEvent.cs new file mode 100644 index 0000000..2618fe3 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Common/TcpConnectionUnrequireEvent.cs @@ -0,0 +1,54 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Common +{ + /// + /// A message originating from either party, indicating a TCP event is no longer required. + /// + public class TcpConnectionUnrequireEvent : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 0x43e6963ac76beee4; + + /// + /// An unused byte sent with the message. + /// + public byte Unused; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public TcpConnectionUnrequireEvent() + { + } + /// + /// Initializes a new message. + /// + public TcpConnectionUnrequireEvent(byte unused) + { + Unused = unused; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unused); + } + + public override string ToString() + { + return $"{GetType().Name}(unused={Unused})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Config/ConfigFailurev2.cs b/EchoRelay.Core/Server/Messages/Config/ConfigFailurev2.cs new file mode 100644 index 0000000..7c1d069 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Config/ConfigFailurev2.cs @@ -0,0 +1,116 @@ +using EchoRelay.Core.Utils; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Config +{ + /// + /// A message from server to client indicating a resulted in a failure. + /// + public class ConfigFailurev2 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -7032236248796415888; + + /// + /// TODO: Unknown, 16 bytes. + /// + public UInt128 Unk0; + + /// + /// The config failure error information. + /// + public ErrorInfo Info; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public ConfigFailurev2() + { + Unk0 = 0; + Info = new ErrorInfo(); + } + + /// + /// Initializes a new message with the provided parameters. + /// + /// The type of the config resource that was requested. + /// The identifier of the config resource that was requested. + /// The error code returned as a result of the failure. + /// The error message returned as a result of the failure. + public ConfigFailurev2(string type, string identifier, long errorCode, string error) : this() + { + Info.Type = type; + Info.Identifier = identifier; + Info.ErrorCode = errorCode; + Info.Error = error; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unk0); + io.StreamJSON(ref Info, true, JSONCompressionMode.None); + } + + public override string ToString() + { + return $"{GetType().Name}(unk0={Unk0}, info={Info})"; + } + #endregion + + #region Classes + /// + /// The JSON encoded config failure error information. + /// + public class ErrorInfo + { + /// + /// The type (group) of resource being requested. + /// + [JsonProperty("type")] + public string Type { get; set; } = ""; + + /// + /// The identifier (name) of the resource being requested. + /// + [JsonProperty("id")] + public string Identifier { get; set; } = ""; + + /// + /// The error code returned for the failure. + /// + [JsonProperty("errorcode")] + public long ErrorCode { get; set; } + + /// + /// The error string returned for the failure. + /// + [JsonProperty("error")] + public string Error { get; set; } = ""; + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + public override string ToString() + { + return $""; + } + + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Config/ConfigRequestv2.cs b/EchoRelay.Core/Server/Messages/Config/ConfigRequestv2.cs new file mode 100644 index 0000000..7ce5c05 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Config/ConfigRequestv2.cs @@ -0,0 +1,97 @@ +using EchoRelay.Core.Utils; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Config +{ + /// + /// A message from client to server requesting a specific configuration resource. + /// + public class ConfigRequestv2 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -9041364331368070280; + + /// + /// The tail byte of the symbol. + /// + public byte ConfigTypeSymbolTail; + /// + /// Information which is provided for the type and identifier of resource being requested. + /// + public ConfigInfo Info; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public ConfigRequestv2() + { + Info = new ConfigInfo(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The last byte of the config resource's symbol. + /// The actual underlying information associated with the config request. + public ConfigRequestv2(byte configTypeSymbolTail, ConfigInfo info) + { + ConfigTypeSymbolTail = configTypeSymbolTail; + Info = info; + } + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref ConfigTypeSymbolTail); + io.StreamJSON(ref Info, true, JSONCompressionMode.None); + } + + public override string ToString() + { + return $"{GetType().Name}(type_symbol_tail={ConfigTypeSymbolTail}, type_info={Info})"; + } + #endregion + + #region Classes + /// + /// The JSON encoded config request information. + /// + public class ConfigInfo + { + /// + /// The type (group) of resource being requested. + /// + [JsonProperty("type")] + public string Type { get; set; } = ""; + + /// + /// The identifier (name) of the resource being requested. + /// + [JsonProperty("id")] + public string Identifier { get; set; } = ""; + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + public override string ToString() + { + return $""; + } + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Config/ConfigSuccessv2.cs b/EchoRelay.Core/Server/Messages/Config/ConfigSuccessv2.cs new file mode 100644 index 0000000..b70daab --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Config/ConfigSuccessv2.cs @@ -0,0 +1,73 @@ +using EchoRelay.Core.Server.Storage.Types; +using EchoRelay.Core.Utils; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Config +{ + /// + /// A message from server to the client indicating a succeeded. + /// It contains information about the requested config resource. + /// + public class ConfigSuccessv2 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -5058194012104830958; + + /// + /// The symbol associated with the requested identifier. + /// + public long TypeSymbol; + /// + /// The symbol associated with the requested identifier. + /// + public long IdentifierSymbol; + /// + /// The requested config resource data. This is a JSON-serializable object. + /// + public ConfigResource Resource; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public ConfigSuccessv2() + { + Resource = new ConfigResource(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The symbol associated with the requested config resource type. + /// The symbol associated with the requested config resource identifier. + /// The config resource data which was requested (must be JSON serializable). + public ConfigSuccessv2(long typeSymbol, long identifierSymbol, ConfigResource resource) + { + TypeSymbol = typeSymbol; + IdentifierSymbol = identifierSymbol; + Resource = resource; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref TypeSymbol); + io.Stream(ref IdentifierSymbol); + io.StreamJSON(ref Resource, true, JSONCompressionMode.Zstd); + } + + public override string ToString() + { + return $"{GetType().Name}(type_symbol={TypeSymbol}, id_symbol={IdentifierSymbol}, data={JObject.FromObject(Resource).ToString(Newtonsoft.Json.Formatting.None)})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/ChannelInfoRequest.cs b/EchoRelay.Core/Server/Messages/Login/ChannelInfoRequest.cs new file mode 100644 index 0000000..674b6a3 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/ChannelInfoRequest.cs @@ -0,0 +1,47 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from client to server, requesting information about the various in-game channels. + /// + public class ChannelInfoRequest : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -8037361449999850272; + + /// + /// An unused byte sent with the message. + /// + public byte Unused; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public ChannelInfoRequest() + { + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unused); + } + + public override string ToString() + { + return $"{GetType().Name}(unused={Unused})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/ChannelInfoResponse.cs b/EchoRelay.Core/Server/Messages/Login/ChannelInfoResponse.cs new file mode 100644 index 0000000..9d35ebf --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/ChannelInfoResponse.cs @@ -0,0 +1,58 @@ +using EchoRelay.Core.Server.Storage.Types; +using EchoRelay.Core.Utils; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from server to client, providing the in-game channel information requested by a previous . + /// + public class ChannelInfoResponse : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 7822496150166529221; + + /// + /// The channel info data obtained for the user. + /// + public ChannelInfoResource ChannelInfo; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public ChannelInfoResponse() + { + ChannelInfo = new ChannelInfoResource(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The in-game channel info to send to the user. + public ChannelInfoResponse(ChannelInfoResource channelInfo) + { + ChannelInfo = channelInfo; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.StreamJSON(ref ChannelInfo, false, JSONCompressionMode.Zlib); + } + + public override string ToString() + { + return $"{GetType().Name}(channel_info={JObject.FromObject(ChannelInfo).ToString(Newtonsoft.Json.Formatting.None)})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/DocumentFailure.cs b/EchoRelay.Core/Server/Messages/Login/DocumentFailure.cs new file mode 100644 index 0000000..8fb10ae --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/DocumentFailure.cs @@ -0,0 +1,68 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from server to client indicating a failed. + /// + public class DocumentFailure : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -7032236248796415888; + + // TODO: Figure this out + public ulong Unk0; + public ulong Unk1; + + /// + /// The message to return with the failure. + /// + public string Message; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public DocumentFailure() + { + Message = ""; + } + + /// + /// Initializes a new message with the provided arguments. + /// + /// Unknown. + /// Unknown. + /// The message to send with the failure. + public DocumentFailure(ulong unk0, ulong unk1, string message) + { + Unk0 = unk0; + Unk1 = unk1; + Message = message; + } + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unk0); + io.Stream(ref Unk1); + io.Stream(ref Message, true); + } + + public override string ToString() + { + return $"{GetType().Name}(unk0={Unk0}, unk1={Unk1}, msg=\"{Message}\")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/DocumentRequestv2.cs b/EchoRelay.Core/Server/Messages/Login/DocumentRequestv2.cs new file mode 100644 index 0000000..e250fe8 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/DocumentRequestv2.cs @@ -0,0 +1,65 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from client to server requesting a document resource. + /// + public class DocumentRequestv2 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -230010198603715656; + + /// + /// The language of the document being requested. + /// + public string Language; + /// + /// The name of the document being requested. + /// + public string Name; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public DocumentRequestv2() + { + Language = Game.Language.English; + Name = ""; + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The language of the document being requested. + /// The name of the document being requested. + public DocumentRequestv2(string language, string name) + { + Language = language; + Name = name; + } + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Language, true); + io.Stream(ref Name, true); + } + + public override string ToString() + { + return $"{GetType().Name}(language=\"{Language}\", name=\"{Name}\")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/DocumentSuccess.cs b/EchoRelay.Core/Server/Messages/Login/DocumentSuccess.cs new file mode 100644 index 0000000..894bfb0 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/DocumentSuccess.cs @@ -0,0 +1,66 @@ +using EchoRelay.Core.Server.Storage.Types; +using EchoRelay.Core.Utils; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from server to the client indicating a succeeded. + /// It contains information about the requested document resource. + /// + public class DocumentSuccess : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -3422738499139816183; + + /// + /// The symbol associated with the requested document name. + /// + public long DocumentNameSymbol; + /// + /// The requested document resource data to return to the client. + /// + public DocumentResource Document; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public DocumentSuccess() + { + Document = new DocumentResource(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The symbol associated with the requested document resource name. + /// The requested document resource data to return to the client. + public DocumentSuccess(long documentNameSymbol, DocumentResource document) + { + DocumentNameSymbol = documentNameSymbol; + Document = document; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref DocumentNameSymbol); + io.StreamJSON(ref Document, true, JSONCompressionMode.Zstd); + } + + public override string ToString() + { + return $"{GetType().Name}(document_symbol={DocumentNameSymbol}, data={JObject.FromObject(Document).ToString(Newtonsoft.Json.Formatting.None)})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/LoggedInUserProfileFailure.cs b/EchoRelay.Core/Server/Messages/Login/LoggedInUserProfileFailure.cs new file mode 100644 index 0000000..5c1a337 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/LoggedInUserProfileFailure.cs @@ -0,0 +1,81 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; +using System.Net; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from server to client indicating their failed. + /// + public class LoggedInUserProfileFailure : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -332370982458323871; + + /// + /// The identifier of the associated user. + /// + public XPlatformId UserId; + public ulong _statusCode; + /// + /// The status code returned with the failure. + /// + public HttpStatusCode StatusCode + { + get { return (HttpStatusCode)_statusCode; } + set { _statusCode = (ulong)value; } + } + /// + /// The message returned with the failure. + /// + public string Message; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LoggedInUserProfileFailure() + { + UserId = new XPlatformId(); + StatusCode = HttpStatusCode.InternalServerError; + Message = ""; + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The identifier of the associated user. + /// The status code returned with the failure. + /// The message returned with the failure. + public LoggedInUserProfileFailure(XPlatformId userId, HttpStatusCode statusCode, string message) + { + UserId = userId; + StatusCode = statusCode; + Message = message; + } + + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + UserId.Stream(io); + io.Stream(ref _statusCode); + io.Stream(ref Message, true); + } + + public override string ToString() + { + return $"{GetType().Name}(user_id={UserId}, status={StatusCode}, msg=\"{Message}\")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/LoggedInUserProfileRequest.cs b/EchoRelay.Core/Server/Messages/Login/LoggedInUserProfileRequest.cs new file mode 100644 index 0000000..df3cecd --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/LoggedInUserProfileRequest.cs @@ -0,0 +1,76 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from client to server requesting the user profile for their logged-in account. + /// + public class LoggedInUserProfileRequest : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -326745984434664080; + + /// + /// The user's session token. + /// + public Guid Session; + /// + /// The user identifier. + /// + public XPlatformId UserId; + /// + /// The request data for the underlying profile, indicating fields of interest. + /// + public JObject ProfileRequestData; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LoggedInUserProfileRequest() + { + Session = new Guid(); + UserId = new XPlatformId(); + ProfileRequestData = new JObject(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// A session token which the user may have from an existing session. + /// The identifier of the logged-in user requesting their profile. + /// The request data for the underlying profile, indicating fields of interest. + /// An exception is thrown if the session token is not the correct length. + public LoggedInUserProfileRequest(Guid session, XPlatformId userId, JObject profileRequestData) + { + Session = session; + UserId = userId; + ProfileRequestData = profileRequestData; + } + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Session); + UserId.Stream(io); + io.StreamJSON(ref ProfileRequestData, true, JSONCompressionMode.None); + } + + public override string ToString() + { + return $"{GetType().Name}(session={Session}, user_id={UserId}, profile_request={ProfileRequestData.ToString(Newtonsoft.Json.Formatting.None)})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/LoggedInUserProfileSuccess.cs b/EchoRelay.Core/Server/Messages/Login/LoggedInUserProfileSuccess.cs new file mode 100644 index 0000000..17ea393 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/LoggedInUserProfileSuccess.cs @@ -0,0 +1,68 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Storage.Types; +using EchoRelay.Core.Utils; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from server to the client indicating a succeeded. + /// It contains profile information about the logged-in user. + /// + public class LoggedInUserProfileSuccess : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -327009806726689417; + + /// + /// The user identifier associated with the logged-in profile. + /// + public XPlatformId UserId; + /// + /// The requested logged-in user profile data to return to the client. + /// + public AccountResource.AccountProfile Profile; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LoggedInUserProfileSuccess() + { + UserId = new XPlatformId(); + Profile = new AccountResource.AccountProfile(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The user identifier associated with the logged-in profile. + /// The requested logged-in user profile data to return to the client. + public LoggedInUserProfileSuccess(XPlatformId userId, AccountResource.AccountProfile profile) + { + UserId = userId; + Profile = profile; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + UserId.Stream(io); + io.StreamJSON(ref Profile, true, JSONCompressionMode.Zstd); + } + + public override string ToString() + { + return $"{GetType().Name}(user_id={UserId}, profile={JObject.FromObject(Profile).ToString(Newtonsoft.Json.Formatting.None)})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/LoginFailure.cs b/EchoRelay.Core/Server/Messages/Login/LoginFailure.cs new file mode 100644 index 0000000..ff22452 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/LoginFailure.cs @@ -0,0 +1,83 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; +using System.Net; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from server to client indicating their failed. + /// + public class LoginFailure : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -6504933290668142767; + + /// + /// The identifier of the associated user. + /// + public XPlatformId UserId; + + public ulong _statusCode; + /// + /// The status code returned with the failure. + /// + public HttpStatusCode StatusCode + { + get { return (HttpStatusCode)_statusCode; } + set { _statusCode = (ulong)value; } + } + + /// + /// The message returned with the failure. + /// + public string Message; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LoginFailure() + { + UserId = new XPlatformId(); + StatusCode = HttpStatusCode.InternalServerError; + Message = ""; + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The identifier of the associated user. + /// The status code returned with the failure. + /// The message returned with the failure. + public LoginFailure(XPlatformId userId, HttpStatusCode statusCode, string message) + { + UserId = userId; + StatusCode = statusCode; + Message = message; + } + + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + UserId.Stream(io); + io.Stream(ref _statusCode); + io.Stream(ref Message, true); + } + + public override string ToString() + { + return $"{GetType().Name}(user_id={UserId}, status={StatusCode}, msg=\"{Message}\")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/LoginRequest.cs b/EchoRelay.Core/Server/Messages/Login/LoginRequest.cs new file mode 100644 index 0000000..3a64178 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/LoginRequest.cs @@ -0,0 +1,157 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from client to server requesting for a user sign-in. + /// + public class LoginRequest : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -4777159589668118518; + + /// + /// The user's session token. + /// + public Guid Session; + /// + /// The user identifier. + /// + public XPlatformId UserId; + /// + /// The client account information supplied for the sign-in request. + /// + public LoginAccountInfo AccountInfo; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LoginRequest() + { + AccountInfo = new LoginAccountInfo(); + Session = new Guid(); + UserId = new XPlatformId(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// A session token which the user may have from an existing session. + /// The identifier of the user requesting log-in. + /// The account data supplied with the sign-in request. + /// An exception is thrown if the session token is not the correct length. + public LoginRequest(Guid session, XPlatformId userId, LoginAccountInfo accountData) + { + Session = session; + UserId = userId; + AccountInfo = accountData; + } + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Session); + UserId.Stream(io); + io.StreamJSON(ref AccountInfo, true, JSONCompressionMode.None); + } + + public override string ToString() + { + return $"{GetType().Name}(session={Session}, user_id={UserId}, account_data={JObject.FromObject(AccountInfo).ToString(Newtonsoft.Json.Formatting.None)})"; + } + #endregion + + #region Classes + /// + /// A structure containing information about the client, provided for authentication. + /// + public class LoginAccountInfo + { + /// + /// The account identifier. + /// + [JsonProperty("accountid")] + public ulong AccountId { get; set; } + + /// + /// The user's display name. + /// + [JsonProperty("displayname")] + public string? DisplayName { get; set; } + + /// + /// TODO: Unknown + /// + [JsonProperty("bypassauth")] + public bool? BypassAuth { get; set; } = false; + + /// + /// Oculus-related access token for authentication. + /// + [JsonProperty("access_token")] + public string? AccessToken { get; set; } + + /// + /// Authentication-related nonce. + /// + [JsonProperty("nonce")] + public string? Nonce { get; set; } + + /// + /// Client build version. + /// + [JsonProperty("buildversion")] + public long BuildVersion { get; set; } + + /// + /// The lobby build timestamp. + /// + [JsonProperty("lobbyversion")] + public ulong? LobbyVersion { get; set; } + + /// + /// The identifier for the application. + /// + [JsonProperty("appid")] + public ulong? AppId { get; set; } + + /// + /// An environment lock for different sandboxes. + /// + [JsonProperty("publisher_lock")] + public string? PublisherLock { get; set; } = "rad15_live"; + + /// + /// Headset serial number + /// + [JsonProperty("hmdserialnumber")] + public string? HMDSerialNumber { get; set; } + + /// + /// Requested version for clients to receive their profile in. + /// + [JsonProperty("desiredclientprofileversion")] + public long? DesiredClientProfileVersion { get; set; } + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/LoginSettings.cs b/EchoRelay.Core/Server/Messages/Login/LoginSettings.cs new file mode 100644 index 0000000..8d8930b --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/LoginSettings.cs @@ -0,0 +1,58 @@ +using EchoRelay.Core.Server.Storage.Types; +using EchoRelay.Core.Utils; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from server to client, providing the settings for the user after a . + /// + public class LoginSettings : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -1343230735030331919; + + /// + /// The settings data supplied for the user. + /// + public LoginSettingsResource Resource; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LoginSettings() + { + Resource = new LoginSettingsResource(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The user settings to send to the user post-login. + public LoginSettings(LoginSettingsResource resource) + { + Resource = resource; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.StreamJSON(ref Resource, false, JSONCompressionMode.Zlib); + } + + public override string ToString() + { + return $"{GetType().Name}(settings={JObject.FromObject(Resource).ToString(Newtonsoft.Json.Formatting.None)})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/LoginSuccess.cs b/EchoRelay.Core/Server/Messages/Login/LoginSuccess.cs new file mode 100644 index 0000000..8908c11 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/LoginSuccess.cs @@ -0,0 +1,68 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from server to client indicating their succeeded, providing them a new session token. + /// + public class LoginSuccess : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -6508614429644632505; + + /// + /// The user's session token. + /// + public Guid Session; + /// + /// The identifier of the associated user. + /// + public XPlatformId UserId; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LoginSuccess() + { + UserId = new XPlatformId(); + Session = new Guid(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The identifier of the associated user. + /// The session token granted for the user. + /// An exception is thrown if the session token is not the correct length. + public LoginSuccess(XPlatformId userId, Guid session) + { + UserId = userId; + Session = session; + } + + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Session); + UserId.Stream(io); + } + + public override string ToString() + { + return $"{GetType().Name}(user_id={UserId}, session={Session})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/OtherUserProfileFailure.cs b/EchoRelay.Core/Server/Messages/Login/OtherUserProfileFailure.cs new file mode 100644 index 0000000..128495f --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/OtherUserProfileFailure.cs @@ -0,0 +1,81 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; +using System.Net; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from server to client indicating their failed. + /// + public class OtherUserProfileFailure : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 1307472398732561827; + + /// + /// The identifier of the associated user. + /// + public XPlatformId UserId; + public ulong _statusCode; + /// + /// The status code returned with the failure. + /// + public HttpStatusCode StatusCode + { + get { return (HttpStatusCode)_statusCode; } + set { _statusCode = (ulong)value; } + } + /// + /// The message returned with the failure. + /// + public string Message; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public OtherUserProfileFailure() + { + UserId = new XPlatformId(); + StatusCode = HttpStatusCode.InternalServerError; + Message = ""; + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The identifier of the associated user. + /// The status code returned with the failure. + /// The message returned with the failure. + public OtherUserProfileFailure(XPlatformId userId, HttpStatusCode statusCode, string message) + { + UserId = userId; + StatusCode = statusCode; + Message = message; + } + + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + UserId.Stream(io); + io.Stream(ref _statusCode); + io.Stream(ref Message, true); + } + + public override string ToString() + { + return $"{GetType().Name}(user_id={UserId}, status={StatusCode}, msg=\"{Message}\")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/OtherUserProfileRequest.cs b/EchoRelay.Core/Server/Messages/Login/OtherUserProfileRequest.cs new file mode 100644 index 0000000..be3d69b --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/OtherUserProfileRequest.cs @@ -0,0 +1,67 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from client to server requesting the user profile for another user. + /// + public class OtherUserProfileRequest : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 1310854393570331826; + + /// + /// The user identifier. + /// + public XPlatformId UserId; + /// + /// The request data for the underlying profile, indicating fields of interest. + /// + public JObject ProfileRequestData; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public OtherUserProfileRequest() + { + UserId = new XPlatformId(); + ProfileRequestData = new JObject(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The identifier of the user requesting another user's profile. + /// The request data for the underlying profile, indicating fields of interest. + public OtherUserProfileRequest(XPlatformId userId, JObject profileRequestData) + { + UserId = userId; + ProfileRequestData = profileRequestData; + } + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + UserId.Stream(io); + io.StreamJSON(ref ProfileRequestData, true, JSONCompressionMode.None); + } + + public override string ToString() + { + return $"{GetType().Name}(user_id={UserId}, profile_request={ProfileRequestData.ToString(Newtonsoft.Json.Formatting.None)})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/OtherUserProfileSuccess.cs b/EchoRelay.Core/Server/Messages/Login/OtherUserProfileSuccess.cs new file mode 100644 index 0000000..6327ae4 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/OtherUserProfileSuccess.cs @@ -0,0 +1,68 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Storage.Types; +using EchoRelay.Core.Utils; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from server to the client indicating a succeeded. + /// It contains profile information about the requested user. + /// + public class OtherUserProfileSuccess : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 1310555403549215925; + + /// + /// The user identifier associated with the logged-in profile. + /// + public XPlatformId UserId; + /// + /// The requested other user profile data to return to the client. + /// + public AccountResource.AccountServerProfile Profile; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public OtherUserProfileSuccess() + { + UserId = new XPlatformId(); + Profile = new AccountResource.AccountServerProfile(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The user identifier associated with the requested profile. + /// The requested other user profile data to return to the client. + public OtherUserProfileSuccess(XPlatformId userId, AccountResource.AccountServerProfile profile) + { + UserId = userId; + Profile = profile; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + UserId.Stream(io); + io.StreamJSON(ref Profile, true, JSONCompressionMode.Zstd); + } + + public override string ToString() + { + return $"{GetType().Name}(user_id={UserId}, profile={JObject.FromObject(Profile).ToString(Newtonsoft.Json.Formatting.None)})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/RemoteLogSetv3.cs b/EchoRelay.Core/Server/Messages/Login/RemoteLogSetv3.cs new file mode 100644 index 0000000..a5e68bc --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/RemoteLogSetv3.cs @@ -0,0 +1,165 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from client to the server logging client-side data, as established by login profile data that tells the client how verbosely to log. + /// It contains arbitrary log data about informational state changes, warnings, and errors. + /// + public class RemoteLogSetv3 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 2615262521988737761; + + /// + /// The user identifier associated with the request. + /// + public XPlatformId UserId; + + // TODO: Unknown + public ulong Unk0; + public ulong Unk1; + public ulong Unk2; + public ulong Unk3; + + private ulong _logLevel; + /// + /// The verbosity level which the log is targeting. + /// + public LoggingLevel LogLevel + { + get + { + return (LoggingLevel)_logLevel; + } + set + { + _logLevel = (ulong)value; + } + } + + /// + /// The client-side logs provided to the server. + /// + public string[] Logs; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public RemoteLogSetv3() + { + UserId = new XPlatformId(); + Logs = Array.Empty(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The user identifier associated with the request. + /// Unknown. + /// Unknown. + /// Unknown. + /// Unknown. + /// The verbosity level which the log is targeting. + /// The underlying client-side logs. + public RemoteLogSetv3(XPlatformId userId, ulong unk0, ulong unk1, ulong unk2, ulong unk3, LoggingLevel logLevel, string[] logs) + { + UserId = userId; + Unk0 = unk0; + Unk1 = unk1; + Unk2 = unk2; + Unk3 = unk3; + LogLevel = logLevel; + Logs = logs; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + // Stream the first simple values + UserId.Stream(io); + io.Stream(ref Unk0); + io.Stream(ref Unk1); + io.Stream(ref Unk2); + io.Stream(ref Unk3); + io.Stream(ref _logLevel); + + // Next switch on our stream mode for more complex serialization. + if (io.StreamMode == StreamMode.Read) + { + // Read the log count + ulong logCount = io.ReadUInt64(); + + // Read the list of offsets. These are offsets relative to the end of this offset table. + // The offsets are only included for items after the first, as its location is trivially known. + // We allocate an extra uint here to account for that, but start reading into the second position of our offset array. + uint[] offsets = new uint[logCount]; + for (uint i = 1; i < offsets.Length; i++) + offsets[i] = io.ReadUInt32(); + + // Define the start of the encoded JSON buffer messages + long jsonBufferStart = io.Position; + + // Loop for each offset and read the JSON data there. + Logs = new string[logCount]; + for (int i = 0; i < offsets.Length; i++) + { + io.Position = jsonBufferStart + offsets[i]; + Logs[i] = io.ReadString(true); + } + } + else + { + // Write our count of logs + io.Write((ulong)Logs.Length); + + // The next part has (1) a list of offsets to JSON data, and (2) the JSON data for each log, encoded null-terminated, one after another. + // We encode the JSON data here in a separate buffer, while writing the offsets to every log (except the first, it is trivial/known, so it is intuitively not part of this message format). + StreamIO encodedBufferIO = new StreamIO(io.DefaultByteOrder, StreamMode.Write); + for (int i = 0; i < Logs.Length; i++) + { + // Write into our internal buffer + encodedBufferIO.Write(Logs[i], true); + + // If this isn't the end of the stream (last item), we write the pointer to the next item. + io.Write((uint)encodedBufferIO.Position); + } + + // Write the entire buffer out. + io.Write(encodedBufferIO.ToArray()); + encodedBufferIO.Close(); + } + } + + public override string ToString() + { + return $"{GetType().Name}(user_id={UserId}, unk0={Unk0}, unk1={Unk1}, unk2={Unk2}, unk3={Unk3}, log_level={LogLevel}, logs=[{string.Join(", ", Logs.Select(x => $"\"{x}\"").AsEnumerable())}])"; + } + #endregion + + #region Enums + /// + /// The verbosity level which a log or logger is targeting. + /// + public enum LoggingLevel : int + { + Debug = 0x1, + Info = 0x2, + Warning = 0x4, + Error = 0x8, + Default = 0xE, + Any = 0xF, + }; + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/UpdateProfile.cs b/EchoRelay.Core/Server/Messages/Login/UpdateProfile.cs new file mode 100644 index 0000000..2cee46f --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/UpdateProfile.cs @@ -0,0 +1,77 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Storage.Types; +using EchoRelay.Core.Utils; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from client to server requesting the server update the user's client profile. + /// + public class UpdateProfile : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 7878099332047717397; + + /// + /// The user's session token. + /// + public Guid Session; + /// + /// The user identifier. + /// + public XPlatformId UserId; + /// + /// The profile data requesting to be updated on the server-side. + /// + public AccountResource.AccountClientProfile ClientProfile; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public UpdateProfile() + { + Session = new Guid(); + UserId = new XPlatformId(); + ClientProfile = new AccountResource.AccountClientProfile(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// A session token which the user may have from an existing session. + /// The identifier of the logged-in user requesting their profile. + /// The profile data requesting to be updated on the server. + /// An exception is thrown if the session token is not the correct length. + public UpdateProfile(Guid session, XPlatformId userId, AccountResource.AccountClientProfile clientProfile) + { + Session = session; + UserId = userId; + ClientProfile = clientProfile; + } + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Session); + UserId.Stream(io); + io.StreamJSON(ref ClientProfile, true, JSONCompressionMode.None); + } + + public override string ToString() + { + return $"{GetType().Name}(session={Session}, user_id={UserId}, profile_request={JObject.FromObject(ClientProfile).ToString(Newtonsoft.Json.Formatting.None)})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/UpdateProfileSuccess.cs b/EchoRelay.Core/Server/Messages/Login/UpdateProfileSuccess.cs new file mode 100644 index 0000000..4550ba4 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/UpdateProfileSuccess.cs @@ -0,0 +1,59 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from server to client indicating their request succeeded. + /// + public class UpdateProfileSuccess : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -985002095917729961; + + /// + /// The identifier of the associated user. + /// + public XPlatformId UserId; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public UpdateProfileSuccess() + { + UserId = new XPlatformId(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The identifier of the associated user. + public UpdateProfileSuccess(XPlatformId userId) + { + UserId = userId; + } + + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + UserId.Stream(io); + } + + public override string ToString() + { + return $"{GetType().Name}(user_id={UserId})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/UserServerProfileUpdateRequest.cs b/EchoRelay.Core/Server/Messages/Login/UserServerProfileUpdateRequest.cs new file mode 100644 index 0000000..869d717 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/UserServerProfileUpdateRequest.cs @@ -0,0 +1,101 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from client to server requesting the server update the user's client profile. + /// + public class UserServerProfileUpdateRequest : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -3271750463532589966; + + /// + /// The user identifier for the user to update the server profile for. + /// + public XPlatformId UserId; + /// + /// The server profile update information. + /// + public ServerProfileUpdateInfo UpdateInfo; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public UserServerProfileUpdateRequest() + { + UserId = new XPlatformId(); + UpdateInfo = new ServerProfileUpdateInfo(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The identifier of the logged-in user requesting their profile. + /// The profile data requesting to be updated on the server. + /// An exception is thrown if the session token is not the correct length. + public UserServerProfileUpdateRequest(XPlatformId userId, ServerProfileUpdateInfo serverUpdateInfo) + { + UserId = userId; + UpdateInfo = serverUpdateInfo; + } + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + UserId.Stream(io); + io.StreamJSON(ref UpdateInfo, true, JSONCompressionMode.None); + } + + public override string ToString() + { + return $"{GetType().Name}(user_id={UserId}, update_info={JObject.FromObject(UpdateInfo).ToString(Newtonsoft.Json.Formatting.None)})"; + } + #endregion + + #region Classes + /// + /// A structure containing information about the server profile information to be updated. + /// + public class ServerProfileUpdateInfo + { + /// + /// The identifier of the current session from which the update was requested. + /// + [JsonProperty("sessionid")] + public string? SessionId { get; set; } + + /// + /// The game type associated with the session. + /// + [JsonProperty("matchtype")] + public string? MatchType { get; set; } + + /// + /// The server profile components to be updated (merged into the full server profile). + /// + [JsonProperty("update")] + public JObject? Update { get; set; } + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Login/UserServerProfileUpdateSuccess.cs b/EchoRelay.Core/Server/Messages/Login/UserServerProfileUpdateSuccess.cs new file mode 100644 index 0000000..9f04e6f --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Login/UserServerProfileUpdateSuccess.cs @@ -0,0 +1,59 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Login +{ + /// + /// A message from server to client indicating their request succeeded. + /// + public class UserServerUpdateProfileSuccess : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -3271451319295304587; + + /// + /// The identifier of the associated user. + /// + public XPlatformId UserId; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public UserServerUpdateProfileSuccess() + { + UserId = new XPlatformId(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The identifier of the associated user. + public UserServerUpdateProfileSuccess(XPlatformId userId) + { + UserId = userId; + } + + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + UserId.Stream(io); + } + + public override string ToString() + { + return $"{GetType().Name}(user_id={UserId})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/FindServerRegionInfo.cs b/EchoRelay.Core/Server/Messages/Matching/FindServerRegionInfo.cs new file mode 100644 index 0000000..2c1e6b7 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/FindServerRegionInfo.cs @@ -0,0 +1,72 @@ +using EchoRelay.Core.Utils; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from server to the client providing information on servers available in different regions. + /// This is not necessary for a client to operate. + /// + public class FindServerRegionInfo : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -8261057723629147028; + + // TODO: Unknown + public ushort Unk0; + public ushort Unk1; + public ushort Unk2; + + /// + /// The region information for various servers. + /// + public JObject RegionInfo; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public FindServerRegionInfo() + { + RegionInfo = new JObject(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// Unknown. + /// Unknown. + /// Unknown. + /// The region-based information for servers. + public FindServerRegionInfo(ushort unk0, ushort unk1, ushort unk2, JObject regionInfo) + { + Unk0 = unk0; + Unk1 = unk1; + Unk2 = unk2; + RegionInfo = regionInfo; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unk0); + io.Stream(ref Unk1); + io.Stream(ref Unk2); + io.StreamJSON(ref RegionInfo, false, JSONCompressionMode.None); + } + + public override string ToString() + { + return $"{GetType().Name}(unk0={Unk0}, unk1={Unk1}, unk2={Unk2}, region_info={JObject.FromObject(RegionInfo).ToString(Newtonsoft.Json.Formatting.None)})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbyCreateSessionRequestv9.cs b/EchoRelay.Core/Server/Messages/Matching/LobbyCreateSessionRequestv9.cs new file mode 100644 index 0000000..a5b2607 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbyCreateSessionRequestv9.cs @@ -0,0 +1,153 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Messages.ServerDB; +using EchoRelay.Core.Utils; +using Newtonsoft.Json.Linq; +using static EchoRelay.Core.Server.Messages.ServerDB.ERGameServerStartSession; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from client to server requesting the creation of a new game session. + /// + public class LobbyCreateSessionRequestv9 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 6456590782678944787; + + // TODO + public ulong Unk0; + + /// + /// The version of the client, prevents mismatches in matching. + /// + public long VersionLock; + /// + /// A symbol representing the gametype requested for the session. + /// + public long GameTypeSymbol; + /// + /// A symbol representing the level requested for the session. + /// + public long LevelSymbol; + /// + /// A symbol representing the platform requested for the session. + /// + public long PlatformSymbol; + /// + /// The user's session token. + /// + public Guid Session; + + // TODO + public ulong Unk1; + + public uint _lobbyType; + /// + /// The visibility of the session to create. + /// + public LobbyType LobbyType + { + get { return (LobbyType)_lobbyType; } + set { _lobbyType = (uint)value; } + } + + public uint Unk2; + + /// + /// The channel requested for the session. + /// + public Guid ChannelUUID; + /// + /// The session information supplied for the request. + /// + public ERGameServerStartSession.SessionSettings SessionSettings; + /// + /// The user identifier. + /// + public XPlatformId UserId; + + /// + /// The team index the player is requesting to join. + /// + public short TeamIndex; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyCreateSessionRequestv9() + { + Unk0 = 0; + VersionLock = 0; + GameTypeSymbol = -1; + LevelSymbol = -1; + PlatformSymbol = -1; + Session = new Guid(); + Unk1 = 0; + LobbyType = LobbyType.Public; + Unk2 = 0; + ChannelUUID = new Guid(); + SessionSettings = new ERGameServerStartSession.SessionSettings(); + UserId = new XPlatformId(); + TeamIndex = -1; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unk0); + io.Stream(ref VersionLock); + io.Stream(ref GameTypeSymbol); + io.Stream(ref LevelSymbol); + io.Stream(ref PlatformSymbol); + io.Stream(ref Session); + io.Stream(ref Unk1); + io.Stream(ref _lobbyType); + io.Stream(ref Unk2); + io.Stream(ref ChannelUUID); + io.StreamJSON(ref SessionSettings, true, JSONCompressionMode.None); + UserId.Stream(io); + + // TODO: Figure this out properly + if (io.StreamMode == StreamMode.Read) + { + if (io.Length - io.Position >= 2) + TeamIndex = io.ReadInt16(); + } + else + { + if (TeamIndex != -1) + io.Write(TeamIndex); + } + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"unk0={Unk0}, " + + $"version_lock={VersionLock}, " + + $"game_type={GameTypeSymbol}, " + + $"level={LevelSymbol}, " + + $"platform={PlatformSymbol}, " + + $"session={Session}, " + + $"unk1={Unk1}, " + + $"lobby_type={LobbyType}, " + + $"unk2={Unk2}, " + + $"channel={ChannelUUID}, " + + $"session_settings={JObject.FromObject(SessionSettings).ToString(Newtonsoft.Json.Formatting.None)}, " + + $"user_id={UserId}, " + + $"team_index={TeamIndex}" + + $")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbyFindSessionRequestv11.cs b/EchoRelay.Core/Server/Messages/Matching/LobbyFindSessionRequestv11.cs new file mode 100644 index 0000000..c44dbdf --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbyFindSessionRequestv11.cs @@ -0,0 +1,133 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Messages.ServerDB; +using EchoRelay.Core.Utils; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from client to server requesting finding of an existing game session that + /// matches the message's underlying arguments. + /// + public class LobbyFindSessionRequestv11 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 3543253192791466997; + + /// + /// The version of the client, prevents mismatches in matching. + /// + public ulong VersionLock; + /// + /// A symbol representing the gametype requested for the session. + /// + public long GameTypeSymbol; + /// + /// A symbol representing the level requested for the session. + /// + public long LevelSymbol; + /// + /// A symbol representing the platform requested for the session. + /// + public long PlatformSymbol; + /// + /// The user's session token. + /// + public Guid Session; + + // TODO + public ulong Unk1; + public UInt128 Unk2; + + /// + /// The channel requested for the session. + /// + public Guid ChannelUUID; + /// + /// The session information supplied for the request. + /// + public ERGameServerStartSession.SessionSettings SessionSettings; + /// + /// The user identifier. + /// + public XPlatformId UserId; + + /// + /// The team index the player is requesting to join. + /// + public short TeamIndex; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyFindSessionRequestv11() + { + VersionLock = 0; + GameTypeSymbol = -1; + LevelSymbol = -1; + PlatformSymbol = -1; + Session = new Guid(); + Unk1 = 0; + Unk2 = 0; + ChannelUUID = new Guid(); + SessionSettings = new ERGameServerStartSession.SessionSettings(); + UserId = new XPlatformId(); + TeamIndex = -1; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref VersionLock); + io.Stream(ref GameTypeSymbol); + io.Stream(ref LevelSymbol); + io.Stream(ref PlatformSymbol); + io.Stream(ref Session); + io.Stream(ref Unk1); + io.Stream(ref Unk2); + io.Stream(ref ChannelUUID); + io.StreamJSON(ref SessionSettings, true, JSONCompressionMode.None); + UserId.Stream(io); + + // TODO: Figure this out properly + if (io.StreamMode == StreamMode.Read) + { + if (io.Length - io.Position >= 2) + TeamIndex = io.ReadInt16(); + } + else + { + if (TeamIndex != -1) + io.Write(TeamIndex); + } + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"version_lock={VersionLock}, " + + $"game_type={GameTypeSymbol}, " + + $"level={LevelSymbol}, " + + $"platform={PlatformSymbol}, " + + $"session={Session}, " + + $"unk1={Unk1}, " + + $"unk2={Unk2}, " + + $"channel={ChannelUUID}, " + + $"session_settings={JObject.FromObject(SessionSettings).ToString(Newtonsoft.Json.Formatting.None)}, " + + $"user_id={UserId}, " + + $"team_index={TeamIndex}" + + $")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbyJoinSessionRequestv7.cs b/EchoRelay.Core/Server/Messages/Matching/LobbyJoinSessionRequestv7.cs new file mode 100644 index 0000000..13a8fa6 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbyJoinSessionRequestv7.cs @@ -0,0 +1,119 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Messages.ServerDB; +using EchoRelay.Core.Utils; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from client to server requesting joining of a specified game session that + /// matches the message's underlying arguments. + /// + public class LobbyJoinSessionRequestv7 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 3387628926720258577; + + /// + /// The lobby requested for the session. + /// + public Guid LobbyUUID; + /// + /// The version of the client, prevents mismatches in matching. + /// + public long VersionLock; + /// + /// A symbol representing the platform requested for the session. + /// + public long PlatformSymbol; + /// + /// The user's session token. + /// + public Guid Session; + + // TODO + public ulong Unk1; + public ulong Unk2; + + /// + /// The session information supplied for the request. + /// + public ERGameServerStartSession.SessionSettings SessionSettings; + /// + /// The user identifier. + /// + public XPlatformId UserId; + + /// + /// The team index the player is requesting to join. + /// + public short TeamIndex; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyJoinSessionRequestv7() + { + LobbyUUID = new Guid(); + VersionLock = 0; + PlatformSymbol = -1; + Session = new Guid(); + Unk1 = 0; + Unk2 = 0; + SessionSettings = new ERGameServerStartSession.SessionSettings(); + UserId = new XPlatformId(); + TeamIndex = -1; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref LobbyUUID); + io.Stream(ref VersionLock); + io.Stream(ref PlatformSymbol); + io.Stream(ref Session); + io.Stream(ref Unk1); + io.Stream(ref Unk2); + io.StreamJSON(ref SessionSettings, true, JSONCompressionMode.None); + UserId.Stream(io); + + // TODO: Figure this out properly + if (io.StreamMode == StreamMode.Read) + { + if (io.Length - io.Position >= 2) + TeamIndex = io.ReadInt16(); + } + else + { + if (TeamIndex != -1) + io.Write(TeamIndex); + } + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"lobby={LobbyUUID}, " + + $"version_lock={VersionLock}, " + + $"platform={PlatformSymbol}, " + + $"session={Session}, " + + $"unk1={Unk1}, " + + $"unk2={Unk2}, " + + $"info={JObject.FromObject(SessionSettings).ToString(Newtonsoft.Json.Formatting.None)}, " + + $"user_id={UserId}, " + + $"team_index={TeamIndex}" + + $")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbyMatchmakerStatus.cs b/EchoRelay.Core/Server/Messages/Matching/LobbyMatchmakerStatus.cs new file mode 100644 index 0000000..06839df --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbyMatchmakerStatus.cs @@ -0,0 +1,55 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from server to the client, providing the status of a previously sent . + /// + public class LobbyMatchmakerStatus : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -8131021305597149493; + + /// + /// The status code representing the matchmaker's status creating/finding/joining a session. + /// + public uint StatusCode; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyMatchmakerStatus() + { + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The status code associated with the matchmaker status. + public LobbyMatchmakerStatus(uint statusCode) + { + StatusCode = statusCode; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref StatusCode); + } + + public override string ToString() + { + return $"{GetType().Name}(status={StatusCode})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbyMatchmakerStatusRequest.cs b/EchoRelay.Core/Server/Messages/Matching/LobbyMatchmakerStatusRequest.cs new file mode 100644 index 0000000..058a8d1 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbyMatchmakerStatusRequest.cs @@ -0,0 +1,55 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from client to server, requesting the status of a pending matchmaking operation. + /// + public class LobbyMatchmakerStatusRequest : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 1336293084088743504; + + /// + /// An unused byte sent with the message. + /// + public byte Unused; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyMatchmakerStatusRequest() + { + } + /// + /// Initializes a new message with the provided arguments. + /// + /// An unused byte in the request + public LobbyMatchmakerStatusRequest(byte unused) + { + Unused = unused; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unused); + } + + public override string ToString() + { + return $"{GetType().Name}(unused={Unused})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbyPendingSessionCancel.cs b/EchoRelay.Core/Server/Messages/Matching/LobbyPendingSessionCancel.cs new file mode 100644 index 0000000..415c1dc --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbyPendingSessionCancel.cs @@ -0,0 +1,56 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from client to the server, indicating intent to cancel pending matchmaker operations. + /// + public class LobbyPendingSessionCancel : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -8238795091130540074; + + /// + /// The user's session token. + /// + public Guid Session; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyPendingSessionCancel() + { + Session = new Guid(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The user's session token. + public LobbyPendingSessionCancel(Guid session) + { + Session = session; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Session); + } + + public override string ToString() + { + return $"{GetType().Name}(session={Session})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbyPingRequestv3.cs b/EchoRelay.Core/Server/Messages/Matching/LobbyPingRequestv3.cs new file mode 100644 index 0000000..ef1bb22 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbyPingRequestv3.cs @@ -0,0 +1,161 @@ +using EchoRelay.Core.Utils; +using System.Net; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from server to client, requesting the client ping a set of endpoints to determine + /// the optimal game server to connect to. + /// + public class LobbyPingRequestv3 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -378478809818600461; + + // TODO + public ushort Unk0; + public ushort Unk1; + public uint Unk2; + + /// + /// The endpoints which the client should be asked to ping. + /// + public EndpointData[] Endpoints; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyPingRequestv3() + { + Unk0 = 0; + Unk1 = 0; + Unk2 = 0; + Endpoints = Array.Empty(); + } + /// + /// Initializes a new with the provided arguments. + /// + /// TODO: Unknown. + /// TODO: Unknown. + /// TODO: Unknown. + /// The endpoints to provide to the client for the ping request. + public LobbyPingRequestv3(ushort unk0, ushort unk1, uint unk2, EndpointData[] endpoints) + { + Unk0 = unk0; + Unk1 = unk1; + Unk2 = unk2; + Endpoints = endpoints; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unk0); + io.Stream(ref Unk1); + io.Stream(ref Unk2); + + // If we're reading, initialize our buffer to the correct size. + if (io.StreamMode == StreamMode.Read) + { + int endpointCount = (int)(io.Length - io.Position) / 12; // 10 byte struct + 2 byte padding + Endpoints = new EndpointData[endpointCount]; + } + + // Stream all endpoints + for (int i = 0; i < Endpoints.Length; i++) + { + // Stream data in/out. If the endpoint isn't initialized, we're likely reading, so we initialize it first. + if (Endpoints[i] == null) + Endpoints[i] = new EndpointData(); + Endpoints[i].Stream(io); + + // Stream 2 bytes of padding + ushort unused = 0; + io.Stream(ref unused); + } + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"unk0={Unk0}, " + + $"unk1={Unk1}, " + + $"unk2={Unk2}, " + + $"endpoints=[{string.Join(", ", Endpoints.AsEnumerable())}]" + + $")"; + } + #endregion + + #region Classes + /// + /// Endpoint-related data, describing a game server peer. + /// + public class EndpointData : IStreamable + { + #region Fields + /// + /// The internal/private address of the endpoint. + /// + public IPAddress InternalAddress; + /// + /// The external/public address of the endpoint. + /// + public IPAddress ExternalAddress; + /// + /// The port used by the endpoint. + /// + public ushort Port; + #endregion + + #region Constructor + /// + /// Initializes a new . + /// + public EndpointData() + { + InternalAddress = new IPAddress(0); + ExternalAddress = new IPAddress(0); + Port = 0; + } + /// + /// Initializes a new with the provided arguments. + /// + /// The internal/private address of the endpoint. + /// The external/public address of the endpoint. + /// The port used by the endpoint. + public EndpointData(IPAddress internalAddress, IPAddress externalAddress, ushort port) + { + InternalAddress = internalAddress; + ExternalAddress = externalAddress; + Port = port; + } + #endregion + + #region Functions + public void Stream(StreamIO io) + { + // Network byte order is used here (big endian). + io.Stream(ref InternalAddress, ByteOrder.BigEndian); + io.Stream(ref ExternalAddress, ByteOrder.BigEndian); + io.Stream(ref Port, ByteOrder.BigEndian); + } + + public override string ToString() + { + return $"{GetType().Name}(int_ip={InternalAddress}, ext_ip={ExternalAddress}, port={Port})"; + } + #endregion + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbyPingResponse.cs b/EchoRelay.Core/Server/Messages/Matching/LobbyPingResponse.cs new file mode 100644 index 0000000..12d7172 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbyPingResponse.cs @@ -0,0 +1,118 @@ +using EchoRelay.Core.Utils; +using System.Net; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from client to server, providing the results of a ping request. + /// This tells the server which game servers are optimal for the client. + /// + public class LobbyPingResponse : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 6937742467394678351; + + /// + /// The endpoints which the client should be asked to ping. + /// + public EndpointPingResult[] Results; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyPingResponse() + { + Results = Array.Empty(); + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + // If we're reading, initialize our buffer to the correct size. + if (io.StreamMode == StreamMode.Read) + { + ulong resultCount = io.ReadUInt64(); + Results = new EndpointPingResult[resultCount]; + } + else + { + io.Write((ulong)Results.Length); + } + + // Stream all results + for (int i = 0; i < Results.Length; i++) + { + // Stream data in/out. If the endpoint isn't initialized, we're likely reading, so we initialize it first. + if (Results[i] == null) + Results[i] = new EndpointPingResult(); + Results[i].Stream(io); + } + } + + public override string ToString() + { + return $"{GetType().Name}(results=[{string.Join(", ", Results.AsEnumerable())}])"; + } + #endregion + + #region Classes + /// + /// The result of a to an individual game server. + /// + public class EndpointPingResult : IStreamable + { + #region Fields + /// + /// The internal/private address of the endpoint. + /// + public IPAddress InternalAddress; + /// + /// The external/public address of the endpoint. + /// + public IPAddress ExternalAddress; + /// + /// The ping time the client took to reach the game server, in milliseconds. + /// + public uint PingMilliseconds; + #endregion + + #region Constructor + /// + /// Initializes a new . + /// + public EndpointPingResult() + { + InternalAddress = new IPAddress(0); + ExternalAddress = new IPAddress(0); + PingMilliseconds = 0; + } + #endregion + + #region Functions + public void Stream(StreamIO io) + { + // Network byte order is used here (big endian). + io.Stream(ref InternalAddress, ByteOrder.BigEndian); + io.Stream(ref ExternalAddress, ByteOrder.BigEndian); + io.Stream(ref PingMilliseconds); + } + + public override string ToString() + { + return $"{GetType().Name}(int_ip={InternalAddress}, ext_ip={ExternalAddress}, ping_ms={PingMilliseconds})"; + } + #endregion + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsRequestv5.cs b/EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsRequestv5.cs new file mode 100644 index 0000000..4b020d2 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsRequestv5.cs @@ -0,0 +1,95 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from client to server, asking it to obtain game server sessions for a given list of user identifiers. + /// + public class LobbyPlayerSessionsRequestv5 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -7281482002396079611; + + /// + /// The user's session token. + /// + public Guid Session; + /// + /// The user identifier. + /// + public XPlatformId UserId; + /// + /// The matching-related session token for the current matchmaker operation. + /// + public Guid MatchingSession; + /// + /// A symbol representing the platform requested for the session. + /// + public long PlatformSymbol; + /// + /// The user identifiers for the players to obtain sessions for. + /// + public XPlatformId[] PlayerUserIds; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyPlayerSessionsRequestv5() + { + Session = new Guid(); + UserId = new XPlatformId(); + PlayerUserIds = Array.Empty(); + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Session); + UserId.Stream(io); + io.Stream(ref MatchingSession); + io.Stream(ref PlatformSymbol); + + // Read/write our count and ensure the array is prepared to stream its data. + if (io.StreamMode == StreamMode.Read) + { + ulong playerUserIds = io.ReadUInt64(); + PlayerUserIds = new XPlatformId[playerUserIds]; + } + else + { + io.Write((ulong)PlayerUserIds.Length); + } + + // Stream all user id data. + for (int i = 0; i < PlayerUserIds.Length; i++) + { + if (PlayerUserIds[i] == null) + PlayerUserIds[i] = new XPlatformId(); + PlayerUserIds[i].Stream(io); + } + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"session={Session}, " + + $"user_id={UserId}, " + + $"matching_session={MatchingSession}, " + + $"platform={PlatformSymbol}, " + + $"player_user_ids=[{string.Join(", ", PlayerUserIds.AsEnumerable())}]" + + $")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsSuccessUnk1.cs b/EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsSuccessUnk1.cs new file mode 100644 index 0000000..8ec6566 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsSuccessUnk1.cs @@ -0,0 +1,85 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from server to client, indicating a for sessions for given user identifiers succeeded. + /// It contains the sessions for a given list of user identifiers. + /// + public class LobbyPlayerSessionsSuccessUnk1 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -40104227197879335; + + /// + /// The matching-related session token for the current matchmaker operation. + /// + public Guid MatchingSession; + + /// + /// The player session token obtained for the requested player user identifier. + /// + public Guid[] PlayerSessions; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyPlayerSessionsSuccessUnk1() + { + PlayerSessions = Array.Empty(); + } + /// + /// Initializes a new with the provided arguments. + /// + /// The matching-related session token for the current matchmaker operation. + /// The player session token obtained for the requested player user identifier. + public LobbyPlayerSessionsSuccessUnk1(Guid matchingSession, Guid[] playerSessions) + { + MatchingSession = matchingSession; + PlayerSessions = playerSessions; + } + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + // TODO: This was never verified to be a count, so if its wrong, it may produce a crash. + // This may just be an unrelated integer and the size of the structure may not change normally. + if (io.StreamMode == StreamMode.Read) + { + ulong count = io.ReadUInt64(); + PlayerSessions = new Guid[count]; + } + else + { + io.Write((ulong)PlayerSessions.Length); + } + + // Stream the match session + io.Stream(ref MatchingSession); + + // Stream all player sessions + for (int i = 0; i < PlayerSessions.Length; i++) + io.Stream(ref PlayerSessions[i]); + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"matching_session={MatchingSession}, " + + $"player_sessions=[{string.Join(", ", PlayerSessions.AsEnumerable())}]" + + $")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsSuccessv2.cs b/EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsSuccessv2.cs new file mode 100644 index 0000000..0807f7e --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsSuccessv2.cs @@ -0,0 +1,76 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from server to client, indicating a for sessions for given user identifiers succeeded. + /// It contains the sessions for a given list of user identifiers. + /// + public class LobbyPlayerSessionsSuccessv2 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -6793175491028678296; + + // TODO + public byte Unk0; + + /// + /// The user identifier. + /// + public XPlatformId UserId; + /// + /// The player session token obtained for the requested player user identifier. + /// + public Guid PlayerSession; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyPlayerSessionsSuccessv2() + { + UserId = new XPlatformId(); + } + /// + /// Initializes a new message. + /// + /// TODO: Unknown. + /// The user identifier. + /// The player session token obtained for the requested player user identifier. + public LobbyPlayerSessionsSuccessv2(byte unk0, XPlatformId userId, Guid playerSession) + { + Unk0 = unk0; + UserId = userId; + PlayerSession = playerSession; + } + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unk0); + UserId.Stream(io); + io.Stream(ref PlayerSession); + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"unk0={Unk0}, " + + $"user_id={UserId}, " + + $"player_session={PlayerSession}" + + $")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsSuccessv3.cs b/EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsSuccessv3.cs new file mode 100644 index 0000000..d121e74 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbyPlayerSessionsSuccessv3.cs @@ -0,0 +1,95 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from server to client, indicating a for sessions for given user identifiers succeeded. + /// It contains the sessions for a given list of user identifiers. + /// + public class LobbyPlayerSessionsSuccessv3 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -6793175491028678295; + + // TODO + public byte Unk0; + + /// + /// The user identifier. + /// + public XPlatformId UserId; + /// + /// The player session token obtained for the requested player user identifier. + /// + public Guid PlayerSession; + + /// + /// The team index the player is being assigned on initial matching. + /// + public short TeamIndex; + + // TODO + public ushort Unk1; + public uint Unk2; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyPlayerSessionsSuccessv3() + { + UserId = new XPlatformId(); + } + /// + /// Initializes a new with the provided arguments. + /// + /// TODO: Unknown. + /// The user identifier. + /// The player session token obtained for the requested player user identifier. + /// TODO: Unknown. + public LobbyPlayerSessionsSuccessv3(byte unk0, XPlatformId userId, Guid playerSession, short teamIndex, ushort unk1, uint unk2) + { + Unk0 = unk0; + UserId = userId; + PlayerSession = playerSession; + TeamIndex = teamIndex; + Unk1 = unk1; + Unk2 = unk2; + } + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unk0); + UserId.Stream(io); + io.Stream(ref PlayerSession); + io.Stream(ref TeamIndex); + io.Stream(ref Unk1); + io.Stream(ref Unk2); + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"unk0={Unk0}, " + + $"user_id={UserId}, " + + $"player_session={PlayerSession}, " + + $"team_index={TeamIndex}, " + + $"unk1={Unk1}, " + + $"unk2={Unk2}" + + $")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailureErrorCode.cs b/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailureErrorCode.cs new file mode 100644 index 0000000..a58f6a8 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailureErrorCode.cs @@ -0,0 +1,24 @@ +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// Indicates a failure code for a lobby session request. + /// This is sent from server to client, the client will display a message corresponding to this enum. + /// + public enum LobbySessionFailureErrorCode : int + { + Timeout0 = 0, + UpdateRequired = 1, + BadRequest = 2, + Timeout3 = 3, + ServerDoesNotExist = 4, + ServerIsIncompatible = 5, + ServerFindFailed = 6, + ServerIsLocked = 7, + ServerIsFull = 8, + InternalError = 9, + MissingEntitlement = 10, + BannedFromLobbyGroup = 11, + KickedFromLobbyGroup = 12, + NotALobbyGroupMod = 13, + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev1.cs b/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev1.cs new file mode 100644 index 0000000..f451862 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev1.cs @@ -0,0 +1,66 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from server to client indicating a lobby session request failed. + /// + public class LobbySessionFailurev1 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -5071315040643272207; + + private byte _errorCode; + /// + /// The error code to return with the failure. + /// + public LobbySessionFailureErrorCode ErrorCode + { + get + { + return (LobbySessionFailureErrorCode)_errorCode; + } + set + { + _errorCode = (byte)value; + } + } + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbySessionFailurev1() + { + } + /// + /// Initializes a new with the provided arguments. + /// + /// The error code to send with the failure. + public LobbySessionFailurev1(LobbySessionFailureErrorCode errorCode) + { + ErrorCode = errorCode; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref _errorCode); + } + + public override string ToString() + { + return $"{GetType().Name}(error_code={ErrorCode})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev2.cs b/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev2.cs new file mode 100644 index 0000000..43215f3 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev2.cs @@ -0,0 +1,78 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from server to client indicating a lobby session request failed. + /// + public class LobbySessionFailurev2 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 5397623933917067626; + + /// + /// The channel requested for the session. + /// + public Guid ChannelUUID; + + private uint _errorCode; + /// + /// The error code to return with the failure. + /// + public LobbySessionFailureErrorCode ErrorCode + { + get + { + return (LobbySessionFailureErrorCode)_errorCode; + } + set + { + _errorCode = (uint)value; + } + } + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbySessionFailurev2() + { + ChannelUUID = new Guid(); + } + /// + /// Initializes a new with the provided arguments. + /// + /// The channel that the matching failed for. + /// The error code to send with the failure. + public LobbySessionFailurev2(Guid channel, LobbySessionFailureErrorCode errorCode) + { + ChannelUUID = channel; + ErrorCode = errorCode; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref ChannelUUID); + io.Stream(ref _errorCode); + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"channel={ChannelUUID}, " + + $"error_code={ErrorCode}" + + $")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev3.cs b/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev3.cs new file mode 100644 index 0000000..fa9c5c1 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev3.cs @@ -0,0 +1,93 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from server to client indicating a lobby session request failed. + /// + public class LobbySessionFailurev3 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 5397623933917067627; + + /// + /// A symbol representing the gametype requested for the session. + /// + public long GameTypeSymbol; + /// + /// The channel requested for the session. + /// + public Guid ChannelUUID; + + private uint _errorCode; + /// + /// The error code to return with the failure. + /// + public LobbySessionFailureErrorCode ErrorCode + { + get + { + return (LobbySessionFailureErrorCode)_errorCode; + } + set + { + _errorCode = (uint)value; + } + } + + // TODO + public uint Unk0; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbySessionFailurev3() + { + ChannelUUID = new Guid(); + } + /// + /// Initializes a new with the provided arguments. + /// + /// The gametype that the matching failed for. + /// The channel that the matching failed for. + /// The error code to send with the failure. + /// Unknown. + public LobbySessionFailurev3(long gameTypeSymbol, Guid channel, LobbySessionFailureErrorCode errorCode, uint unk0) + { + GameTypeSymbol = gameTypeSymbol; + ChannelUUID = channel; + ErrorCode = errorCode; + Unk0 = unk0; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref GameTypeSymbol); + io.Stream(ref ChannelUUID); + io.Stream(ref _errorCode); + io.Stream(ref Unk0); + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"game_type={GameTypeSymbol}, " + + $"channel={ChannelUUID}, " + + $"error_code={ErrorCode}, " + + $"unk0={Unk0}" + + $")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev4.cs b/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev4.cs new file mode 100644 index 0000000..0408606 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbySessionFailurev4.cs @@ -0,0 +1,103 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from server to client indicating a lobby session request failed. + /// + public class LobbySessionFailurev4 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 5397623933917067628; + + /// + /// A symbol representing the gametype requested for the session. + /// + public long GameTypeSymbol; + /// + /// The channel requested for the session. + /// + public Guid ChannelUUID; + + private uint _errorCode; + /// + /// The error code to return with the failure. + /// + public LobbySessionFailureErrorCode ErrorCode + { + get + { + return (LobbySessionFailureErrorCode)_errorCode; + } + set + { + _errorCode = (uint)value; + } + } + + // TODO + public uint Unk0; + + /// + /// The message sent with the failure. + /// + public string Message; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbySessionFailurev4() + { + ChannelUUID = new Guid(); + Message = ""; + } + /// + /// Initializes a new with the provided arguments. + /// + /// The gametype that the matching failed for. + /// The channel that the matching failed for. + /// The error code to send with the failure. + /// Unknown. + /// The error message to send with the failure. + public LobbySessionFailurev4(long gameTypeSymbol, Guid channel, LobbySessionFailureErrorCode errorCode, uint unk0, string message) + { + GameTypeSymbol = gameTypeSymbol; + ChannelUUID = channel; + ErrorCode = errorCode; + Unk0 = unk0; + Message = message; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref GameTypeSymbol); + io.Stream(ref ChannelUUID); + io.Stream(ref _errorCode); + io.Stream(ref Unk0); + io.Stream(ref Message, 72); + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"game_type={GameTypeSymbol}, " + + $"channel={ChannelUUID}, " + + $"error_code={ErrorCode}, " + + $"unk0={Unk0}, " + + $"msg={Message}, " + + $")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbySessionSuccessv4.cs b/EchoRelay.Core/Server/Messages/Matching/LobbySessionSuccessv4.cs new file mode 100644 index 0000000..92b611c --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbySessionSuccessv4.cs @@ -0,0 +1,164 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from server to client indicating that a request to create/join/find a game server + /// session succeeded. + /// + public class LobbySessionSuccessv4 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 7876201346521829646; + + /// + /// A symbol representing the gametype requested for the session. + /// + public long GameTypeSymbol; + /// + /// The matching-related session token for the current matchmaker operation. + /// + public Guid MatchingSession; + /// + /// The server-selected game server endpoint that the client should connect to. + /// + public LobbyPingRequestv3.EndpointData Endpoint; + + + /// + /// The team index of the player. -1 for the server to assign a team. + /// + public short TeamIndex; + + // TODO + public uint Unk1; + + /// + /// Flags indicating the parameters for packet encoding for the server. + /// + public ulong ServerEncoderFlags; + /// + /// Flags indicating the parameters for packet encoding for the server. + /// + public ulong ClientEncoderFlags; + /// + /// The sequence id that the server should start with when a connection is established. + /// + public ulong ServerSequenceId; + /// + /// The HMAC key that should be used by the server. + /// + public byte[] ServerMacKey; + /// + /// The AES key that should be used by the server. + /// + public byte[] ServerEncKey; + /// + /// The random key used to seed the Keccak1600-F sponge construction RNG, which steps for each packet + /// to generate AES initialization vectors to encrypt it. + /// + public byte[] ServerRandomKey; + /// + /// The sequence id that the client should start with when a connection is established. + /// + public ulong ClientSequenceId; + /// + /// The HMAC key that should be used by the client. + /// + public byte[] ClientMacKey; + /// + /// The AES key that should be used by the server. + /// + public byte[] ClientEncKey; + /// + /// The random key used to seed the Keccak1600-F sponge construction RNG, which steps for each packet + /// to generate AES initialization vectors to encrypt it. + /// + public byte[] ClientRandomKey; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbySessionSuccessv4() + { + Endpoint = new LobbyPingRequestv3.EndpointData(); + ServerMacKey = new byte[0x20]; + ServerEncKey = new byte[0x20]; + ServerRandomKey = new byte[0x20]; + ClientMacKey = new byte[0x20]; + ClientEncKey = new byte[0x20]; + ClientRandomKey = new byte[0x20]; + } + public LobbySessionSuccessv4(long gameTypeSymbol, Guid matchingSession, LobbyPingRequestv3.EndpointData endpoint, short teamIndex, uint unk1, ulong serverEncoderFlags, ulong clientEncoderFlags, ulong serverSequenceId, byte[] serverMacKey, byte[] serverEncKey, byte[] serverRandomKey, ulong clientSequenceId, byte[] clientMacKey, byte[] clientEncKey, byte[] clientRandomKey) + { + GameTypeSymbol = gameTypeSymbol; + MatchingSession = matchingSession; + Endpoint = endpoint; + TeamIndex = teamIndex; + Unk1 = unk1; + ServerEncoderFlags = serverEncoderFlags; + ClientEncoderFlags = clientEncoderFlags; + ServerSequenceId = serverSequenceId; + ServerMacKey = serverMacKey; + ServerEncKey = serverEncKey; + ServerRandomKey = serverRandomKey; + ClientSequenceId = clientSequenceId; + ClientMacKey = clientMacKey; + ClientEncKey = clientEncKey; + ClientRandomKey = clientRandomKey; + } + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref GameTypeSymbol); + io.Stream(ref MatchingSession); + Endpoint.Stream(io); + io.Stream(ref TeamIndex); + io.Stream(ref Unk1); + io.Stream(ref ServerEncoderFlags); + io.Stream(ref ClientEncoderFlags); + io.Stream(ref ServerSequenceId); + io.Stream(ref ServerMacKey); + io.Stream(ref ServerEncKey); + io.Stream(ref ServerRandomKey); + io.Stream(ref ClientSequenceId); + io.Stream(ref ClientMacKey); + io.Stream(ref ClientEncKey); + io.Stream(ref ClientRandomKey); + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"game_type={GameTypeSymbol}, " + + $"matching_session={MatchingSession}, " + + $"endpoint={Endpoint}, " + + $"team_index={TeamIndex}, " + + $"unk1={Unk1}, " + + $"server_encoder_flags={ServerEncoderFlags}, " + + $"client_encoder_flags={ClientEncoderFlags}, " + + $"server_seq_id={ServerSequenceId}, " + + $"server_mac_key={Convert.ToHexString(ServerMacKey)}, " + + $"server_enc_key={Convert.ToHexString(ServerEncKey)}, " + + $"server_random_key={Convert.ToHexString(ServerRandomKey)}, " + + $"client_seq_id={ClientSequenceId}, " + + $"client_mac_key={Convert.ToHexString(ClientMacKey)}, " + + $"client_enc_key={Convert.ToHexString(ClientEncKey)}, " + + $"client_random_key={Convert.ToHexString(ClientRandomKey)}" + + $")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbySessionSuccessv5.cs b/EchoRelay.Core/Server/Messages/Matching/LobbySessionSuccessv5.cs new file mode 100644 index 0000000..0f7fad0 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbySessionSuccessv5.cs @@ -0,0 +1,170 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from server to client indicating that a request to create/join/find a game server + /// session succeeded. + /// + public class LobbySessionSuccessv5 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 7876201346521829647; + + /// + /// A symbol representing the gametype requested for the session. + /// + public long GameTypeSymbol; + /// + /// The matching-related session token for the current matchmaker operation. + /// + public Guid MatchingSession; + /// + /// The channel requested for the session. + /// + public Guid ChannelUUID; + /// + /// The server-selected game server endpoint that the client should connect to. + /// + public LobbyPingRequestv3.EndpointData Endpoint; + + /// + /// The team index of the player. -1 for the server to assign a team. + /// + public short TeamIndex; + + // TODO + public uint Unk1; + + /// + /// Flags indicating the parameters for packet encoding for the server. + /// + public ulong ServerEncoderFlags; + /// + /// Flags indicating the parameters for packet encoding for the server. + /// + public ulong ClientEncoderFlags; + /// + /// The sequence id that the server should start with when a connection is established. + /// + public ulong ServerSequenceId; + /// + /// The HMAC key that should be used by the server. + /// + public byte[] ServerMacKey; + /// + /// The AES key that should be used by the server. + /// + public byte[] ServerEncKey; + /// + /// The random key used to seed the Keccak1600-F sponge construction RNG, which steps for each packet + /// to generate AES initialization vectors to encrypt it. + /// + public byte[] ServerRandomKey; + /// + /// The sequence id that the client should start with when a connection is established. + /// + public ulong ClientSequenceId; + /// + /// The HMAC key that should be used by the client. + /// + public byte[] ClientMacKey; + /// + /// The AES key that should be used by the server. + /// + public byte[] ClientEncKey; + /// + /// The random key used to seed the Keccak1600-F sponge construction RNG, which steps for each packet + /// to generate AES initialization vectors to encrypt it. + /// + public byte[] ClientRandomKey; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbySessionSuccessv5() + { + Endpoint = new LobbyPingRequestv3.EndpointData(); + ServerMacKey = new byte[0x20]; + ServerEncKey = new byte[0x20]; + ServerRandomKey = new byte[0x20]; + ClientMacKey = new byte[0x20]; + ClientEncKey = new byte[0x20]; + ClientRandomKey = new byte[0x20]; + } + public LobbySessionSuccessv5(long gameTypeSymbol, Guid matchingSession, Guid channelUUID, LobbyPingRequestv3.EndpointData endpoint, short teamIndex, uint unk1, ulong serverEncoderFlags, ulong clientEncoderFlags, ulong serverSequenceId, byte[] serverMacKey, byte[] serverEncKey, byte[] serverRandomKey, ulong clientSequenceId, byte[] clientMacKey, byte[] clientEncKey, byte[] clientRandomKey) + { + GameTypeSymbol = gameTypeSymbol; + MatchingSession = matchingSession; + ChannelUUID = channelUUID; + Endpoint = endpoint; + TeamIndex = teamIndex; + Unk1 = unk1; + ServerEncoderFlags = serverEncoderFlags; + ClientEncoderFlags = clientEncoderFlags; + ServerSequenceId = serverSequenceId; + ServerMacKey = serverMacKey; + ServerEncKey = serverEncKey; + ServerRandomKey = serverRandomKey; + ClientSequenceId = clientSequenceId; + ClientMacKey = clientMacKey; + ClientEncKey = clientEncKey; + ClientRandomKey = clientRandomKey; + } + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref GameTypeSymbol); + io.Stream(ref MatchingSession); + io.Stream(ref ChannelUUID); + Endpoint.Stream(io); + io.Stream(ref TeamIndex); + io.Stream(ref Unk1); + io.Stream(ref ServerEncoderFlags); + io.Stream(ref ClientEncoderFlags); + io.Stream(ref ServerSequenceId); + io.Stream(ref ServerMacKey); + io.Stream(ref ServerEncKey); + io.Stream(ref ServerRandomKey); + io.Stream(ref ClientSequenceId); + io.Stream(ref ClientMacKey); + io.Stream(ref ClientEncKey); + io.Stream(ref ClientRandomKey); + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"game_type={GameTypeSymbol}, " + + $"matching_session={MatchingSession}, " + + $"channel={ChannelUUID}, " + + $"endpoint={Endpoint}, " + + $"team_index={TeamIndex}, " + + $"unk1={Unk1}, " + + $"server_encoder_flags={ServerEncoderFlags}, " + + $"client_encoder_flags={ClientEncoderFlags}, " + + $"server_seq_id={ServerSequenceId}, " + + $"server_mac_key={Convert.ToHexString(ServerMacKey)}, " + + $"server_enc_key={Convert.ToHexString(ServerEncKey)}, " + + $"server_random_key={Convert.ToHexString(ServerRandomKey)}, " + + $"client_seq_id={ClientSequenceId}, " + + $"client_mac_key={Convert.ToHexString(ClientMacKey)}, " + + $"client_enc_key={Convert.ToHexString(ClientEncKey)}, " + + $"client_random_key={Convert.ToHexString(ClientRandomKey)}" + + $")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Matching/LobbyStatusNotifyv2.cs b/EchoRelay.Core/Server/Messages/Matching/LobbyStatusNotifyv2.cs new file mode 100644 index 0000000..c72f5ae --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Matching/LobbyStatusNotifyv2.cs @@ -0,0 +1,115 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Matching +{ + /// + /// A message from server to client notifying them of some status (e.g. the reason they were kicked). + /// + public class LobbyStatusNotifyv2 : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -1965344278184031864; + + /// + /// The channel which the status notification applies to. + /// + public Guid Channel; + + /// + /// A message describing the status update. This is a maximum of 64 bytes, UTF-8 encoded. + /// + public string Message; + + // TODO: Create a DateTime property for ExpiryTime. + + /// + /// The time the status change takes effect until. + /// + private ulong _expiryTime64; + + private ulong _reason; + /// + /// The reason for the status notification. + /// + public StatusUpdateReason Reason + { + get + { + return (StatusUpdateReason)_reason; + } + set + { + _reason = (ulong)value; + } + } + + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyStatusNotifyv2() + { + Channel = new Guid(); + Message = ""; + _expiryTime64 = 0; + _reason = 0; + } + /// + /// Initializes a new with the provided arguments. + /// + /// The channel which the status notification applies to. + /// A message describing the status update. + /// The time at which the status change expires. + /// The reason for the status notification. + public LobbyStatusNotifyv2(Guid channel, string message, ulong expiryTime, StatusUpdateReason reason) + { + Channel = channel; + Message = message; + _expiryTime64 = expiryTime; + Reason = reason; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Channel); + io.Stream(ref Message, 64); + io.Stream(ref _expiryTime64); + io.Stream(ref _reason); + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"channel={Channel}, " + + $"message={Message}, " + + $"until={_expiryTime64}, " + + $"reason={Reason}" + + $")"; + } + #endregion + + #region Enums + /// + /// Describes the reason for the status update in a message. + /// + public enum StatusUpdateReason + { + Banned, + Kicked, + Demoted, + Unknown + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Message.cs b/EchoRelay.Core/Server/Messages/Message.cs new file mode 100644 index 0000000..1d6503c --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Message.cs @@ -0,0 +1,107 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages +{ + /// + /// One of many websocket messages sent between the client and server in a . + /// + public abstract class Message : IStreamable + { + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public abstract long MessageTypeSymbol { get; } + + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public abstract void Stream(StreamIO io); + + /// + /// Encodes the message into bytes. + /// + /// Returns the encoded message bytes. + public byte[] Encode() + { + // Create a new stream and write our data to it. + StreamIO io = new StreamIO(ByteOrder.LittleEndian, StreamMode.Write); + Stream(io); + io.Close(); + + // Return the streamed data. + return io.ToArray(); + } + + /// + /// Decodes the message from provided bytes. + /// + /// The data to decode the message from. + public void Decode(byte[] data) + { + // Create a stream for this data. + StreamIO io = new StreamIO(data, ByteOrder.LittleEndian, StreamMode.Read); + + // Stream the data. If we're debugging, assert we read ALL the data (flagging incorrect implementation). + Stream(io); + + #if DEBUG + if (io.Position != io.Length) + { + //throw new IOException($"[DEBUGGING] Message decoding did not read all data for message type: {GetType().Name}"); + } + #endif + + // Close the stream. + io.Close(); + } + } + + /// + /// An unimplemented message. This stores the message identifier and data in a raw form. + /// + public class UnimplementedMessage : Message + { + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol { get; } + + /// + /// The data contained within the unimplemented message. + /// + public byte[] Data; + + + /// + /// Initializes a new message. + /// + public UnimplementedMessage(long messageId) + { + MessageTypeSymbol = messageId; + Data = new byte[0]; + } + + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + // If we're reading, allocate enough space to read in the rest of the stream. + if (io.StreamMode == StreamMode.Read) + { + Data = new byte[io.Length - io.Position]; + } + + // Stream our data in/out. + io.Stream(ref Data); + } + + public override string ToString() + { + return $"{GetType().Name}(message_type_symbol={MessageTypeSymbol}, data={Convert.ToHexString(Data)})"; + } + + } +} \ No newline at end of file diff --git a/EchoRelay.Core/Server/Messages/MessageTypes.cs b/EchoRelay.Core/Server/Messages/MessageTypes.cs new file mode 100644 index 0000000..9f61f30 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/MessageTypes.cs @@ -0,0 +1,162 @@ +using System.Collections.Concurrent; +using System.Reflection; + +namespace EchoRelay.Core.Server.Messages +{ + /// + /// A lookup of to s. This is used by a to determine the types of underlying messages to decode. + /// + public abstract class MessageTypes + { + #region Fields + /// + /// A lookup of types to s. + /// + private static ConcurrentDictionary _typesToSymbols = new ConcurrentDictionary(); + + /// + /// A lookup of identifiers to type. + /// + private static ConcurrentDictionary _symbolsToTypes = new ConcurrentDictionary(); + #endregion + + #region Constructor + /// + /// Loads all classes which inherit from . + /// + static MessageTypes() + { + // Obtain this assembly. + Assembly? currentAssembly = Assembly.GetAssembly(typeof(Message)); + + // If our current assembly is not null, obtain our types for it. + if (currentAssembly != null) + { + var assemblyMessageTypes = currentAssembly.GetTypes().Where(t => typeof(Message).IsAssignableFrom(t)); + + // Register each type in our assembly. + foreach (var type in assemblyMessageTypes) + { + // Skip the unimplemented message type + if (type == typeof(UnimplementedMessage) || type == typeof(Message)) + continue; + + // Register the message type. + Register(type); + } + } + } + #endregion + + #region Functions + /// + /// Indicates whether the provided type is that of a . + /// + /// The type to check is a . + /// Returns a boolean indicating whether the provided type is a type. + public static bool IsMessageType(Type type) + { + // Check if the type is assignable. + return typeof(Message).IsAssignableFrom(type); + } + + + /// + /// Creates a of the type which corresponds to the given identifier. + /// + /// A symbol indicating the type of to create. + /// If the message identifier provided is unknown, throw an exception rather than returning an type. + /// Returns an instance of a of the type associated with the provided message identifier. + /// An exception is thrown if the message identifier is unknown or the message could not be instantiated. + public static Message CreateMessage(long typeSymbol, bool errorIfUnimplemented = false) + { + // Obtain the type for this identifier. + Message? message = null; + bool typeRegistered = _symbolsToTypes.TryGetValue(typeSymbol, out var type); + if (!typeRegistered || type == null) + { + if (errorIfUnimplemented) + { + throw new ArgumentException($"Failed to create message from type symbol. No message type was registered for symbol {typeSymbol}"); + } + return new UnimplementedMessage(typeSymbol); + } + + // Create an instance of the message. + message = (Message?)Activator.CreateInstance(type); + if (message == null) + { + throw new ArgumentException($"Failed to create message from type symbol. {type.Name} could not be instantiated as a {nameof(Message)} type."); + } + return message; + } + + /// + /// Registers a given type for deserialization operations. + /// + /// The type to register. + public static void Register() where T : Message + { + Register(typeof(T)); + } + /// + /// Registers a given type for deserialization operations. + /// + /// The type to register. + /// An exception is thrown if the type provided is not a type or it could not be instantiated with its non-paramaterized constructor. + public static void Register(Type type) + { + // If the type is not deriving from our message type, throw an exception. + if (!IsMessageType(type) || type == typeof(UnimplementedMessage) || type == typeof(Message)) + { + throw new ArgumentException($"Failed to register message type. {type.Name} is not a valid {nameof(Message)} type to register."); + } + + + // Create an instance of the message. + Message? defaultMessage = (Message?)Activator.CreateInstance(type); + if (defaultMessage == null) + { + throw new ArgumentException($"Failed to register message type. {type.Name} could not be instantiated as a {nameof(Message)} type."); + } + + // Set it in our lookups. + _typesToSymbols[type] = defaultMessage.MessageTypeSymbol; + _symbolsToTypes[defaultMessage.MessageTypeSymbol] = type; + } + + /// + /// Unregisters a given type from deserialization operations. + /// + /// The type to unregister. + public static void Unregister() where T: Message + { + Unregister(typeof(T)); + } + /// + /// Unregisters a given type from deserialization operations. + /// + /// The type to unregister. + /// An exception is thrown if the type provided is not a type or it could not be instantiated with its non-paramaterized constructor. + public static void Unregister(Type type) + { + // If the type is not deriving from our message type, throw an exception. + if (!IsMessageType(type)) + { + throw new ArgumentException($"Failed to unregister message type. {type.Name} is not a valid {nameof(Message)} type."); + } + + // Create an instance of the message. + Message? defaultMessage = (Message?)Activator.CreateInstance(type); + if (defaultMessage == null) + { + throw new ArgumentException($"Failed to unregister message type. {type.Name} could not be instantiated as a {nameof(Message)} type."); + } + + // Remove the type from the lookups. + _typesToSymbols.Remove(type, out _); + _symbolsToTypes.Remove(defaultMessage.MessageTypeSymbol, out _); + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Packet.cs b/EchoRelay.Core/Server/Messages/Packet.cs new file mode 100644 index 0000000..8369234 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Packet.cs @@ -0,0 +1,113 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages +{ + /// + /// Describes a packet sent across a websocket service connection. It wraps a set of underlying messages. + /// + public class Packet : List + { + /// + /// The 64-bit header identifier for any . + /// + public const ulong HEADER_ID = 0xbb8ce7a278bb40f6; + + /// + /// The max size that any may be. + /// + public const int MAX_SIZE = 0x8000; + + /// + /// Creates a with any provided messages initially added to it. + /// + /// The messages to add to the on construction. + public Packet(params Message[] messages) + { + // Add any provided messages to the packet. + AddRange(messages); + } + + + /// + /// Encodes a packet into bytes. + /// + /// Returns the encoded packet data. + public byte[] Encode() + { + // Create a new stream + StreamIO io = new StreamIO(ByteOrder.LittleEndian); + + // Write every message in our packet + foreach (Message message in this) + { + // Write the message out. + io.Write(HEADER_ID); + io.Write(message.MessageTypeSymbol); + + // Encode the message, write the length, then the encoded message data. + byte[] encodedMessage = message.Encode(); + io.Write((ulong)encodedMessage.Length); + io.Write(encodedMessage); + } + + // Obtain the bytes from our stream + byte[] result = io.ToArray(); + return result; + } + + /// + /// Decodes a packet from the provided byte data. + /// + /// The data to decode a packet from. + /// Returns the decoded packet. + /// This exception may occur if the packet failed to be decoded from the stream due to the packet format being malformed. + public static Packet Decode(byte[] data) + { + // Create a stream out of the data, to read underlying messages. + StreamIO io = new StreamIO(data, ByteOrder.LittleEndian); + + // Create a packet to store our messages. + Packet packet = new Packet(); + + // Read messages until we are at the end of our stream. + try + { + while (io.Position != io.Length) + { + // Verify the header identifier + if (io.ReadUInt64() != HEADER_ID) + { + throw new IOException("Invalid websocket packet header identifier"); + } + + // Read the message type and data length + long messageId = io.ReadInt64(); + ulong messageDataLength = io.ReadUInt64(); + + // Verify the message data can be read from the rest of the stream. + if ((ulong)(io.Length - io.Position) < messageDataLength) + { + throw new IOException("Invalid websocket packet message length"); + } + + // Read the underlying data. + byte[] messageData = io.ReadBytes((int)messageDataLength); + + // Create an instance of this message type and parse it. + Message message = MessageTypes.CreateMessage(messageId, false); + message.Decode(messageData); + + // Add the successfully parsed message to our packet + packet.Add(message); + } + } + finally + { + // Whether we encounter an exception or not, close our IO prior to returning. + io.Close(); + } + + return packet; + } + } +} diff --git a/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerAcceptPlayers.cs b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerAcceptPlayers.cs new file mode 100644 index 0000000..49b5cf4 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerAcceptPlayers.cs @@ -0,0 +1,69 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.ServerDB +{ + /// + /// A message from game server to server, indicating a number of player sessions had been accepted by the game server. + /// NOTE: This is an unofficial message created for Echo Relay. + /// + public class ERGameServerAcceptPlayers : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 0x7777777777770500; + + /// + /// The players sessions which were accepted by the game server. + /// + public Guid[] PlayerSessions; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public ERGameServerAcceptPlayers() + { + PlayerSessions = Array.Empty(); + } + /// + /// Initializes a new with the provided arguments. + /// + /// The player sessions which were accepted by the game server. + public ERGameServerAcceptPlayers(Guid[] playerSessions) + { + PlayerSessions = playerSessions; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + // Read/write our array size + if (io.StreamMode == StreamMode.Read) + { + int count = (int)(io.Length - io.Position) / 16; + PlayerSessions = new Guid[count]; + } + + // Stream all player sessions + for (int i = 0; i < PlayerSessions.Length; i++) + { + // Stream the player id data in/out. + io.Stream(ref PlayerSessions[i]); + } + } + + public override string ToString() + { + return $"{GetType().Name}(player_sessions=[{string.Join(", ", PlayerSessions.AsEnumerable())}])"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerChallengeRequest.cs b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerChallengeRequest.cs new file mode 100644 index 0000000..ef4ee7e --- /dev/null +++ b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerChallengeRequest.cs @@ -0,0 +1,59 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.ServerDB +{ + /// + /// A message from server to game server, providing a challenge to the game server to complete prior to registration. + /// NOTE: This is an unofficial message created for Echo Relay. + /// + public class ERGameServerChallengeRequest : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 0x7777777777770900; + + /// + /// The data to be sent to the client, which they will be expected to transform accordingly. + /// + public byte[] InputPayload; + #endregion + + #region Constructor + /// + /// Initializes a new . + /// + public ERGameServerChallengeRequest() + { + InputPayload = Array.Empty(); + } + /// + /// Initializes a new with the provided arguments. + /// + /// The data to be sent to the client, which they will be expected to transform accordingly. + public ERGameServerChallengeRequest(byte[] inputPayload) + { + InputPayload = inputPayload; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + if (io.StreamMode == StreamMode.Read) + InputPayload = new byte[io.Length - io.Position]; + io.Stream(ref InputPayload); + } + + public override string ToString() + { + return $"{GetType().Name}(input_payload={Convert.ToHexString(InputPayload)})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerChallengeResponse.cs b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerChallengeResponse.cs new file mode 100644 index 0000000..ba7c51c --- /dev/null +++ b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerChallengeResponse.cs @@ -0,0 +1,59 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.ServerDB +{ + /// + /// A message from game server to server, providing a response to to be validated before registration. + /// NOTE: This is an unofficial message created for Echo Relay. + /// + public class ERGameServerChallengeResponse : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 0x7777777777770A00; + + /// + /// The + /// + public byte[] InputPayload; + #endregion + + #region Constructor + /// + /// Initializes a new . + /// + public ERGameServerChallengeResponse() + { + InputPayload = Array.Empty(); + } + /// + /// Initializes a new with the provided arguments. + /// + /// The data to be sent to the client, which they will be expected to transform accordingly. + public ERGameServerChallengeResponse(byte[] inputPayload) + { + InputPayload = inputPayload; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + if (io.StreamMode == StreamMode.Read) + InputPayload = new byte[io.Length - io.Position]; + io.Stream(ref InputPayload); + } + + public override string ToString() + { + return $"{GetType().Name}(input_payload={Convert.ToHexString(InputPayload)})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerEndSession.cs b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerEndSession.cs new file mode 100644 index 0000000..6285c37 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerEndSession.cs @@ -0,0 +1,39 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.ServerDB +{ + /// + /// A message from game server to server, indicating the game server's session ended. + /// NOTE: This is an unofficial message created for Echo Relay. + /// + public class ERGameServerEndSession : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 0x7777777777770200; + + /// + /// An unused byte sent with the packet. + /// + public byte Unused; + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unused); + } + + public override string ToString() + { + return $"{GetType().Name}(unused={Unused})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayerSessionsLocked.cs b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayerSessionsLocked.cs new file mode 100644 index 0000000..701fa17 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayerSessionsLocked.cs @@ -0,0 +1,39 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.ServerDB +{ + /// + /// A message from game server to server, indicating player sessions have been locked on the game server. + /// NOTE: This is an unofficial message created for Echo Relay. + /// + public class ERGameServerPlayerSessionsLocked : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 0x7777777777770300; + + /// + /// An unused byte sent with the packet. + /// + public byte Unused; + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unused); + } + + public override string ToString() + { + return $"{GetType().Name}(unused={Unused})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayerSessionsUnlocked.cs b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayerSessionsUnlocked.cs new file mode 100644 index 0000000..67cf682 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayerSessionsUnlocked.cs @@ -0,0 +1,39 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.ServerDB +{ + /// + /// A message from game server to server, indicating player sessions have been unlocked on the game server. + /// NOTE: This is an unofficial message created for Echo Relay. + /// + public class ERGameServerPlayerSessionsUnlocked : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 0x7777777777770400; + + /// + /// An unused byte sent with the packet. + /// + public byte Unused; + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unused); + } + + public override string ToString() + { + return $"{GetType().Name}(unused={Unused})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayersAccepted.cs b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayersAccepted.cs new file mode 100644 index 0000000..af926d5 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayersAccepted.cs @@ -0,0 +1,73 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.ServerDB +{ + /// + /// A message from server to game server, indicating a list of players should be accepted by the game server. + /// NOTE: This is an unofficial message created for Echo Relay. + /// + public class ERGameServerPlayersAccepted : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 0x7777777777770600; + + public byte Unk0; + + /// + /// The players which should be accepted by the game server. + /// + public Guid[] PlayerSessions; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public ERGameServerPlayersAccepted() + { + PlayerSessions = Array.Empty(); + } + /// + /// Initializes a new with the provided arguments. + /// + /// The players to be accepted by the game server. + public ERGameServerPlayersAccepted(Guid[] playerSessions) + { + PlayerSessions = playerSessions; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unk0); + + // Read/write our array size + if (io.StreamMode == StreamMode.Read) + { + int count = (int)(io.Length - io.Position) / 16; + PlayerSessions = new Guid[count]; + } + + // Stream all player sessions + for (int i = 0; i < PlayerSessions.Length; i++) + { + // Stream the player session data in/out. + io.Stream(ref PlayerSessions[i]); + } + } + + public override string ToString() + { + return $"{GetType().Name}(player_sessions=[{string.Join(", ", PlayerSessions.AsEnumerable())}])"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayersRejected.cs b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayersRejected.cs new file mode 100644 index 0000000..03d7bcc --- /dev/null +++ b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerPlayersRejected.cs @@ -0,0 +1,103 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.ServerDB +{ + /// + /// A message from server to game server, indicating a list of players should be kicked/rejected by the game server. + /// NOTE: This is an unofficial message created for Echo Relay. + /// + public class ERGameServerPlayersRejected : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 0x7777777777770700; + + private byte _errorCode; + /// + /// The error code indicates the underlying reason for the player session rejection. + /// + public PlayerSessionError ErrorCode + { + get { return (PlayerSessionError)_errorCode; } + set { _errorCode = (byte)value; } + } + + /// + /// The players which were rejected. + /// + public Guid[] PlayerSessions; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public ERGameServerPlayersRejected() + { + PlayerSessions = Array.Empty(); + } + /// + /// Initializes a new with the provided arguments. + /// + /// The error code indicates the underlying reason for the player session rejection. + /// The players which were rejected. + public ERGameServerPlayersRejected(PlayerSessionError errorCode, Guid[] playerSessions) + { + ErrorCode = errorCode; + PlayerSessions = playerSessions; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + // Read/write our error code + io.Stream(ref _errorCode); + + // Read/write our array size + if (io.StreamMode == StreamMode.Read) + { + int count = (int)(io.Length - io.Position) / 16; + PlayerSessions = new Guid[count]; + } + + // Stream all player sessions + for (int i = 0; i < PlayerSessions.Length; i++) + { + // Stream the player session data in/out. + io.Stream(ref PlayerSessions[i]); + } + } + + public override string ToString() + { + return $"{GetType().Name}(error_code={ErrorCode}, player_sessions=[{string.Join(", ", PlayerSessions.AsEnumerable())}])"; + } + #endregion + + #region Enums + /// + /// Describes the error reason for rejecting a player session. + /// + public enum PlayerSessionError : int + { + Internal = 0x0, + BadRequest = 0x1, + Timeout = 0x2, + Duplicate = 0x3, + LobbyLocked = 0x4, + LobbyFull = 0x5, + LobbyEnding = 0x6, + KickedFromServer = 0x7, + Disconnected = 0x8, + Inactive = 0x9, + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerRegistrationRequest.cs b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerRegistrationRequest.cs new file mode 100644 index 0000000..3bde1a6 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerRegistrationRequest.cs @@ -0,0 +1,78 @@ +using EchoRelay.Core.Utils; +using System.Net; + +namespace EchoRelay.Core.Server.Messages.ServerDB +{ + /// + /// A message from game server to server, requesting game server registration so clients can match with it. + /// NOTE: This is an unofficial message created for Echo Relay. + /// + public class ERGameServerRegistrationRequest : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 0x7777777777777777; + + /// + /// The identifier of the game server. + /// + public ulong ServerId; + /// + /// The internal/private IP address of the game server. + /// + public IPAddress InternalAddress; + /// + /// The UDP port that the game server is broadcasting on. + /// + public ushort Port; + /// + /// A symbol indicating the region of the server. + /// + public long RegionSymbol; + /// + /// The version of the server, prevents mismatches in matching. + /// + public long VersionLock; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public ERGameServerRegistrationRequest() + { + InternalAddress = new IPAddress(0); + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref ServerId); + io.Stream(ref InternalAddress, ByteOrder.BigEndian); + io.Stream(ref Port); + ushort pad2 = 0; // 2 bytes of padding + io.Stream(ref pad2); + io.Stream(ref RegionSymbol); + io.Stream(ref VersionLock); + } + + public override string ToString() + { + return $"{GetType().Name}(" + + $"server_id={ServerId}, " + + $"internal_ip={InternalAddress}, " + + $"port={Port}, " + + $"region={RegionSymbol}, " + + $"version_lock={VersionLock}" + + $")"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerRemovePlayer.cs b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerRemovePlayer.cs new file mode 100644 index 0000000..dc7651a --- /dev/null +++ b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerRemovePlayer.cs @@ -0,0 +1,57 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.ServerDB +{ + /// + /// A message from game server to server, indicating a player was removed by the game server. + /// NOTE: This is an unofficial message created for Echo Relay. + /// + public class ERGameServerRemovePlayer : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 0x7777777777770800; + + /// + /// The player session which was removed by the game server. + /// + public Guid PlayerSession; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public ERGameServerRemovePlayer() + { + PlayerSession = new Guid(); + } + /// + /// Initializes a new with the provided arguments. + /// + /// The player session removed by the game server session. + public ERGameServerRemovePlayer(Guid playerSession) + { + PlayerSession = playerSession; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref PlayerSession); + } + + public override string ToString() + { + return $"{GetType().Name}(player_session={PlayerSession})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerSessionStarted.cs b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerSessionStarted.cs new file mode 100644 index 0000000..281db92 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerSessionStarted.cs @@ -0,0 +1,39 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.ServerDB +{ + /// + /// A message from game server to server, indicating a session has been started. + /// NOTE: This is an unofficial message created for Echo Relay. + /// + public class ERGameServerSessionStarted : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 0x7777777777770100; + + /// + /// An unused byte sent with the packet. + /// + public byte Unused; + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Unused); + } + + public override string ToString() + { + return $"{GetType().Name}(unused={Unused})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerStartSession.cs b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerStartSession.cs new file mode 100644 index 0000000..052a862 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/ServerDB/ERGameServerStartSession.cs @@ -0,0 +1,170 @@ +using EchoRelay.Core.Utils; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.ServerDB +{ + /// + /// A message from server to game server, indicating it should start a session on its end. + /// NOTE: This is an unofficial message created for Echo Relay. + /// + public class ERGameServerStartSession : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 0x7777777777770000; + + /// + /// The identifier for the game server session to start. + /// + public Guid SessionId; + + /// + /// TODO: Unknown. In offline play, this is zero. + /// + public Guid Unk0; + + /// + /// The maximum amount of players allowed to join the lobby. + /// + public byte PlayerLimit; + + /// + /// TODO: Unknown, this is some count for a struct that follows the JSON. The size per each is 0x24. + /// + public byte Unk2; + + private byte _lobbyType; + /// + /// The type of lobby + /// + public LobbyType Type + { + get + { + return (LobbyType)_lobbyType; + } + set + { + _lobbyType = (byte)value; + } + } + + /// + /// The JSON settings associated with the session. + /// + public SessionSettings Settings; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public ERGameServerStartSession() + { + Settings = new SessionSettings(); + } + /// + /// Initializes a new with the provided arguments. + /// + /// + /// + /// + /// + public ERGameServerStartSession(Guid sessionId, Guid unk0, byte playerLimit, LobbyType type, SessionSettings settings) + { + SessionId = sessionId; + Unk0 = unk0; + PlayerLimit = playerLimit; + Type = type; + Settings = settings; + } + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + byte pad1 = 0; + + io.Stream(ref SessionId); + io.Stream(ref Unk0); + io.Stream(ref PlayerLimit); + io.Stream(ref Unk2); + io.Stream(ref _lobbyType); + io.Stream(ref pad1); + io.StreamJSON(ref Settings, true, JSONCompressionMode.None); + } + + public override string ToString() + { + return $"{GetType().Name}(session_id={SessionId}, player_limit={PlayerLimit}, lobby_type={Type}, settings={Settings})"; + } + #endregion + + #region Classes + /// + /// The type of lobby for the created session. + /// + public enum LobbyType : int + { + Public = 0x0, + Private = 0x1, + Unassigned = 0x2, + } + + /// + /// Session settings for the session to be started by a message. + /// + public class SessionSettings + { + /// + /// Identifier of the application. + /// + [JsonProperty("appid")] + public string? AppId { get; set; } + + /// + /// A symbol representing the gametype of the session to create. + /// + [JsonProperty("gametype")] + public long? GameType { get; set; } + + /// + /// A symbol representing the level of the session to create. + /// + [JsonProperty("level")] + public long? Level { get; set; } + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + public SessionSettings() + { + + } + public SessionSettings(string? appId, long? gametype, long? level, IDictionary? additionalData = null) + { + AppId = appId; + GameType = gametype; + Level = level; + AdditionalData = additionalData ?? AdditionalData; + } + + public override string ToString() + { + return $""; + } + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/ServerDB/LobbyRegistrationFailure.cs b/EchoRelay.Core/Server/Messages/ServerDB/LobbyRegistrationFailure.cs new file mode 100644 index 0000000..af5d4e8 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/ServerDB/LobbyRegistrationFailure.cs @@ -0,0 +1,82 @@ +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.ServerDB +{ + /// + /// A message from server to game server, indicating a game server registration request had failed. + /// + public class LobbyRegistrationFailure : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -5373034290044534839; + + private byte _result; + /// + /// The failure code for the lobby registration. + /// + public FailureCode Result + { + get { return (FailureCode)_result; } + set { _result = (byte)value; } + } + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyRegistrationFailure() + { + + } + /// + /// Initializes a new with the provided arguments. + /// + /// The failure code for the lobby registration. + public LobbyRegistrationFailure(FailureCode result) + { + Result = result; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref _result); + } + + public override string ToString() + { + return $"{GetType().Name}(result={Result})"; + } + #endregion + + #region Enums + /// + /// Indicates the type of game server registration failure that occurred. + /// + public enum FailureCode : int + { + InvalidRequest = 0, + Timeout = 1, + CryptographyError = 2, + DatabaseError = 3, + AccountDoesNotExist = 4, + ConnectionFailed = 5, + ConnectionLost = 6, + ProviderError = 7, + Restricted = 8, + Unknown = 9, + Failure = 10, + Success = 11, + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/ServerDB/LobbyRegistrationSuccess.cs b/EchoRelay.Core/Server/Messages/ServerDB/LobbyRegistrationSuccess.cs new file mode 100644 index 0000000..62dc8e0 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/ServerDB/LobbyRegistrationSuccess.cs @@ -0,0 +1,67 @@ +using EchoRelay.Core.Utils; +using System.Net; + +namespace EchoRelay.Core.Server.Messages.ServerDB +{ + /// + /// A message from server to game server, indicating a game server registration request had succeeded. + /// + public class LobbyRegistrationSuccess : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => -5369924845641990433; + + /// + /// The identifier of the game server. + /// + public ulong ServerId; + /// + /// The external/public IP address of the game server. + /// + public IPAddress ExternalAddress; + + public ulong Unk0; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public LobbyRegistrationSuccess() + { + ExternalAddress = new IPAddress(0); + } + /// + /// Initializes a new with the provided arguments. + /// + /// The identifier of the game server. + /// The external/public IP address of the game server. + public LobbyRegistrationSuccess(ulong serverId, IPAddress externalAddr) + { + ServerId = serverId; + ExternalAddress = externalAddr; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref ServerId); + io.Stream(ref ExternalAddress); + io.Stream(ref Unk0); + } + + public override string ToString() + { + return $"{GetType().Name}(server_id={ServerId}, external_ip={ExternalAddress})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Transaction/ReconcileIAP.cs b/EchoRelay.Core/Server/Messages/Transaction/ReconcileIAP.cs new file mode 100644 index 0000000..d673582 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Transaction/ReconcileIAP.cs @@ -0,0 +1,69 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Messages.Login; +using EchoRelay.Core.Utils; + +namespace EchoRelay.Core.Server.Messages.Transaction +{ + /// + /// TODO: In-app purchase related request + /// + public class ReconcileIAP : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 2004379208746620732; + + /// + /// The user's session token. + /// + public Guid Session; + /// + /// The identifier of the associated user. + /// + public XPlatformId UserId; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public ReconcileIAP() + { + UserId = new XPlatformId(); + Session = new Guid(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The identifier of the associated user. + /// The session token granted for the user. + /// An exception is thrown if the session token is not the correct length. + public ReconcileIAP(XPlatformId userId, Guid session) + { + UserId = userId; + Session = session; + } + + + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + io.Stream(ref Session); + UserId.Stream(io); + } + + public override string ToString() + { + return $"{GetType().Name}(user_id={UserId}, session={Session})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Messages/Transaction/ReconcileIAPResult.cs b/EchoRelay.Core/Server/Messages/Transaction/ReconcileIAPResult.cs new file mode 100644 index 0000000..d574ad3 --- /dev/null +++ b/EchoRelay.Core/Server/Messages/Transaction/ReconcileIAPResult.cs @@ -0,0 +1,66 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Utils; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Messages.Transaction +{ + /// + /// TODO: In-app purchase related response + /// + public class ReconcileIAPResult : Message + { + #region Fields + /// + /// The unique 64-bit symbol denoting the type of message. + /// + public override long MessageTypeSymbol => 985094533933992578; + + /// + /// The user identifier associated with the logged-in profile. + /// + public XPlatformId UserId; + /// + /// The in-app purchase related data. + /// + public JObject IAPData; + #endregion + + #region Constructor + /// + /// Initializes a new message. + /// + public ReconcileIAPResult() + { + UserId = new XPlatformId(); + IAPData = new JObject(); + } + /// + /// Initializes a new message with the provided arguments. + /// + /// The user identifier associated with the request. + /// The requested in-app purchase related data. + public ReconcileIAPResult(XPlatformId userId, JObject iapData) + { + UserId = userId; + IAPData = iapData; + } + #endregion + + #region Functions + /// + /// Streams the message data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + public override void Stream(StreamIO io) + { + UserId.Stream(io); + io.StreamJSON(ref IAPData, true, JSONCompressionMode.None); + } + + public override string ToString() + { + return $"{GetType().Name}(user_id={UserId}, iap_data={IAPData.ToString(Newtonsoft.Json.Formatting.None)})"; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Server.cs b/EchoRelay.Core/Server/Server.cs new file mode 100644 index 0000000..87b931d --- /dev/null +++ b/EchoRelay.Core/Server/Server.cs @@ -0,0 +1,345 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Messages; +using EchoRelay.Core.Server.Services; +using EchoRelay.Core.Server.Services.Config; +using EchoRelay.Core.Server.Services.Login; +using EchoRelay.Core.Server.Services.Matching; +using EchoRelay.Core.Server.Services.ServerDB; +using EchoRelay.Core.Server.Services.Transaction; +using EchoRelay.Core.Server.Storage; +using EchoRelay.Core.Server.Storage.Resources; +using EchoRelay.Core.Server.Storage.Types; +using EchoRelay.Core.Utils; +using System.Collections.ObjectModel; +using System.Net; +using System.Net.WebSockets; +using static EchoRelay.Core.Server.Services.Service; + +namespace EchoRelay.Core.Server +{ + /// + /// A websocket server which implements Echo VR web services. + /// + public class Server + { + #region Properties + /// + /// The running state of the server. + /// + public bool Running { get; private set; } + /// + /// The source used to generate cancellation tokens for the server start/stop operations. + /// + public CancellationTokenSource? _cancellationTokenSource; + + /// + /// The settings for the server to operate under. + /// + public ServerSettings Settings { get; private set; } + /// + /// The persistent storage layer for the server. + /// + public ServerStorage Storage { get; set; } + + /// + /// A cache of symbols to use during server operations. + /// This is reloaded from storage when the server is started. + /// + public SymbolCache SymbolCache { get; private set; } + + /// + /// The service hosted by this server. + /// + public ConfigService ConfigService { get; private set; } + + /// + /// The service hosted by this server. + /// + public LoginService LoginService { get; private set; } + + /// + /// The service hosted by this server. + /// + public MatchingService MatchingService { get; private set; } + + /// + /// The service hosted by this server. + /// + public ServerDBService ServerDBService { get; private set; } + + /// + /// The service hosted by this server. + /// + public TransactionService TransactionService { get; private set; } + /// + /// The IP address of the current server. This is obtained by querying an online service. If it fails to fetch, it will be null. + /// + public IPAddress? PublicIPAddress { get; private set; } + + /// + /// A map of request paths to s which serve them. + /// + private ReadOnlyDictionary _serviceMap; + #endregion + + #region Events + /// + /// Event for a having been started. + /// + /// The which was started. + public delegate void ServerStartedEventHandler(Server server); + /// + /// Event for a having been started. + /// + public event ServerStartedEventHandler? OnServerStarted; + + /// + /// Event for a having been stopped. + /// + /// The which was stopped. + public delegate void ServerStoppedEventHandler(Server server); + /// + /// Event for a having been stopped. + /// + public event ServerStoppedEventHandler? OnServerStopped; + + /// + /// Event for a client having their IP address authorized by the Access Control Lists (ACLs), before connecting to a service. + /// + /// The which the authorization took place under. + /// The IP endpoint which attempted to authorize themselves with the server. + /// The result of the authorization. + public delegate void AuthorizationResultEventHandler(Server server, IPEndPoint client, bool authorized); + /// + /// Event for a having their IP address authorized by the Access Control Lists (ACLs), before connecting to a service. + /// + public event AuthorizationResultEventHandler? OnAuthorizationResult; + + /// + /// Forwarded event from all connected services: . + /// + public event PeerConnectedEventHandler? OnServicePeerConnected; + /// + /// Forwarded event from all connected services: . + /// + public event PeerDisconnectedEventHandler? OnServicePeerDisconnected; + /// + /// Event for a authenticating within a , with a given . + /// + public event Peer.AuthenticatedEventHandler? OnServicePeerAuthenticated; + /// + /// Forwarded event from all connected services: . + /// + public event Peer.PacketReceivedEventHandler? OnServicePacketReceived; + /// + /// Forwarded event from all connected services: . + /// + public event Peer.PacketSentEventHandler? OnServicePacketSent; + #endregion + + #region Constructor + /// + /// Initializes a new with the provided arguments. + /// + /// The port to bind the websocket server to. + public Server(ServerStorage storage, ServerSettings settings) + { + // Set our properties. + Storage = storage; + Settings = settings; + PublicIPAddress = null; + + // Create our services + ConfigService = new ConfigService(this); + RegisterServiceEvents(ConfigService); + + LoginService = new LoginService(this); + RegisterServiceEvents(LoginService); + + MatchingService = new MatchingService(this); + RegisterServiceEvents(MatchingService); + + ServerDBService = new ServerDBService(this); + RegisterServiceEvents(ServerDBService); + + TransactionService = new TransactionService(this); + RegisterServiceEvents(TransactionService); + + // Create a map of our services + _serviceMap = new Dictionary + { + { Settings.ConfigServicePath.ToLower(), ConfigService }, + { Settings.LoginServicePath.ToLower(), LoginService }, + { Settings.MatchingServicePath.ToLower(), MatchingService }, + { Settings.ServerDBServicePath.ToLower(), ServerDBService }, + { Settings.TransactionServicePath.ToLower(), TransactionService }, + }.AsReadOnly(); + } + #endregion + + #region Functions + /// + /// Starts the server and its underlying services. + /// + /// An optional cancellation token which will be used to stop the server. + /// A task representing the server execution. + /// An exception thrown if the server is already started when this method is called. + public async Task Start(CancellationTokenSource? cancellationTokenSource = null) + { + // If we are running already, throw an exception. + if (Running) + { + throw new InvalidOperationException("Server cannot be started if it is already in a running state."); + } + + // Obtain our public IP address + PublicIPAddress = await IPAddressUtils.GetExternalIPAddress(); + + // Set our state to running + _cancellationTokenSource = cancellationTokenSource ?? new CancellationTokenSource(); + Running = true; + + // Load the symbol cache from our storage + SymbolCache = Storage.SymbolCache.Get() ?? new SymbolCache(); + + // Create an HTTP listener that hosts over the provided port. + HttpListener listener = new HttpListener(); + listener.Prefixes.Add($"http://*:{Settings.Port}/"); + + // Start the listener + listener.Start(); + + // Fire our started event + OnServerStarted?.Invoke(this); + + // Enter a loop to accept new web socket connections. + try + { + while (!_cancellationTokenSource.IsCancellationRequested) + { + // Upon receipt of a connection request, obtain the context and verify it is a web socket request. + HttpListenerContext listenerContext = await listener.GetContextAsync().WaitAsync(_cancellationTokenSource.Token); + + // Verify the request is a web socket request + if (!listenerContext.Request.IsWebSocketRequest) + { + // Return a bad request HTTP status code. + listenerContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; + listenerContext.Response.Close(); + + // TODO: Log the interaction. + + // Do not accept this client. + continue; + } + + // Verify the IP is authorized against the ACL (if we have no ACL, we accept no connections). + AccessControlListResource? acl = Storage.AccessControlList.Get(); + bool authorized = acl?.CheckAuthorized(listenerContext.Request.RemoteEndPoint.Address) ?? false; + OnAuthorizationResult?.Invoke(this, listenerContext.Request.RemoteEndPoint, authorized); + if (!authorized) + { + // TODO: Log the interaction. + + // Do not accept this client. + continue; + } + + // Attempt to accept the web socket connection. + WebSocketContext webSocketContext; + try + { + webSocketContext = await listenerContext.AcceptWebSocketAsync(subProtocol: null); + } + catch (Exception e) + { + // Return an internal server error HTTP status code. + listenerContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; + listenerContext.Response.Close(); + + // TODO: Log the exception. + continue; + } + + // Try to obtain a service for this request path. If we could not, return an error to the client. + if (listenerContext.Request.Url == null || !_serviceMap.TryGetValue(listenerContext.Request.Url.LocalPath.ToLower() ?? "", out var service)) + { + // Return a not found HTTP status code. + listenerContext.Response.StatusCode = (int)HttpStatusCode.NotFound; + listenerContext.Response.Close(); + + // TODO: Log the interaction. + continue; + } + + // Create a task to handle the new connection asynchronously (we do not await, so we can continue accepting connections). + var handleConnectionTask = Task.Run(() => service.HandleConnection(listenerContext, webSocketContext.WebSocket)); + } + } + catch (TimeoutException) + { + } + catch (TaskCanceledException) + { + } + + // Ensure our listener is closed + listener.Close(); + + // Set our state to not running. + Running = false; + + // Fire our stopped event + OnServerStopped?.Invoke(this); + } + + /// + /// Stops the server and its underlying services. + /// Note: Servers are run in another task. This method may return before the server has stopped. + /// + public void Stop() + { + // Cancel any cancellation token we have now. + _cancellationTokenSource?.Cancel(); + } + + /// + /// Registers a 's events to be forwarded to ones provided on a -level here. + /// + /// The to forward events from. + public void RegisterServiceEvents(Service service) + { + // Forward all events from the service. + service.OnPeerConnected += Service_OnPeerConnected; + service.OnPeerDisconnected += Service_OnPeerDisconnected; + service.OnPeerAuthenticated += Service_OnPeerAuthenticated; + service.OnPacketSent += Service_OnPacketSent; + service.OnPacketReceived += Service_OnPacketReceived; + } + #endregion + + #region Event Handlers + private void Service_OnPeerConnected(Service service, Peer peer) + { + OnServicePeerConnected?.Invoke(service, peer); + } + private void Service_OnPeerDisconnected(Service service, Peer peer) + { + OnServicePeerDisconnected?.Invoke(service, peer); + } + private void Service_OnPeerAuthenticated(Service service, Peer peer, XPlatformId userId) + { + OnServicePeerAuthenticated?.Invoke(service, peer, userId); + } + private void Service_OnPacketReceived(Service service, Peer sender, Packet packet) + { + OnServicePacketReceived?.Invoke(service, sender, packet); + } + + private void Service_OnPacketSent(Service service, Peer sender, Packet packet) + { + OnServicePacketSent?.Invoke(service, sender, packet); + } + #endregion + } +} \ No newline at end of file diff --git a/EchoRelay.Core/Server/ServerSettings.cs b/EchoRelay.Core/Server/ServerSettings.cs new file mode 100644 index 0000000..e46d5da --- /dev/null +++ b/EchoRelay.Core/Server/ServerSettings.cs @@ -0,0 +1,128 @@ +using EchoRelay.Core.Game; +using System.Web; + +namespace EchoRelay.Core.Server +{ + /// + /// Settings for a given to execute. + /// + public class ServerSettings + { + #region Properties + /// + /// The port which the websocket server is bound to. + /// + public ushort Port { get; } + + /// + /// The path at which the API service processes requests. + /// + public string ApiServicePath { get; } + + /// + /// The path at which the processes requests. + /// + public string ConfigServicePath { get; } + + /// + /// The path at which the processes requests. + /// + public string LoginServicePath { get; } + + /// + /// The path at which the processes requests. + /// + public string MatchingServicePath { get; } + + /// + /// The path at which the processes requests. + /// + public string ServerDBServicePath { get; } + + /// + /// The path at which the processes requests. + /// + public string TransactionServicePath { get; } + + /// + /// The grace period for which a disconnected peer may reconnect and use the same session token. + /// Sessions after this time will expire for clients which disconnected. + /// + public TimeSpan SessionDisconnectedTimeout { get; } + + /// + /// An API key which if set (non-null), must be provided as a query parameter when + /// connecting to ServerDB to successfully pass game server registration. + /// + public string? ServerDBApiKey { get; } + + /// + /// Indicates whether the matching service should try to force the user into any available game server, + /// in the event that there are not enough game servers to create the requested session. + /// + public bool ForceIntoAnySessionIfCreationFails { get; } + + /// + /// Indicates whether the matching service should prefer to match peers to game servers which + /// have more players within them, then by ping, if true. If false, then ping is prioritized + /// before player count. + /// + public bool FavorPopulationOverPing { get; } + #endregion + + #region Constructor + public ServerSettings(ushort port = 777, string apiServicePath = "/api", string configServicePath = "/config", + string loginServicePath = "/login", string matchingServicePath = "/matching", + string serverdbServicePath = "/serverdb", string transactionServicePath = "/transaction", TimeSpan? disconnectedSessionTimeout = null, + string? serverDbApiKey = null, bool forceIntoAnySessionIfCreationFails = false, bool favorPopulationOverPing = true) + { + Port = port; + ApiServicePath = apiServicePath; + ConfigServicePath = configServicePath; + LoginServicePath = loginServicePath; + MatchingServicePath = matchingServicePath; + ServerDBServicePath = serverdbServicePath; + TransactionServicePath = transactionServicePath; + + SessionDisconnectedTimeout = disconnectedSessionTimeout ?? TimeSpan.FromMinutes(1); + ServerDBApiKey = string.IsNullOrEmpty(serverDbApiKey) ? null : serverDbApiKey; + ForceIntoAnySessionIfCreationFails = forceIntoAnySessionIfCreationFails; + FavorPopulationOverPing = favorPopulationOverPing; + } + #endregion + + #region Functions + /// + /// Generates a new with the provided address string (e.g. "localhost", "123.123.123.123", etc) + /// + /// The address or domain to use for the base URL for all host endpoints. + /// Indicates whether sensitive gameserver-only fields should be included in the config. + /// The publisher/environment lock to generate with. + /// Returns the generated . + public ServiceConfig GenerateServiceConfig(string address, bool serverConfig = true, string publisherLock = "rad15_live") + { + // Obtain our base host + string webSocketHost = $"ws://{address}:{Port}"; + string httpHost = $"http://{address}:{Port}"; + + // Construct our ServerDB path + string serverDBHost = webSocketHost + ServerDBServicePath; + if (ServerDBApiKey != null) + { + serverDBHost += $"?api_key={HttpUtility.UrlEncode(ServerDBApiKey)}"; + } + + // Return a new service config + return new ServiceConfig( + apiServiceHost: httpHost + ApiServicePath, + configServiceHost: webSocketHost + ConfigServicePath, + loginServiceHost: webSocketHost + LoginServicePath + $"?auth=AccountPassword&displayname=AccountName", + matchingServiceHost: webSocketHost + MatchingServicePath, + serverdbServiceHost: serverConfig ? serverDBHost : null, + transactionServiceHost: webSocketHost + TransactionServicePath, + publisherLock: publisherLock + ); + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Services/Config/ConfigService.cs b/EchoRelay.Core/Server/Services/Config/ConfigService.cs new file mode 100644 index 0000000..bd22d35 --- /dev/null +++ b/EchoRelay.Core/Server/Services/Config/ConfigService.cs @@ -0,0 +1,84 @@ +using EchoRelay.Core.Server.Messages; +using EchoRelay.Core.Server.Messages.Common; +using EchoRelay.Core.Server.Messages.Config; +using EchoRelay.Core.Server.Storage.Types; + +namespace EchoRelay.Core.Server.Services.Config +{ + /// + /// The config service is used to obtain game configurations. It does not maintain sessions or state per user. + /// + public class ConfigService : Service + { + #region Constructor + /// + /// Initializes a new with the provided arguments. + /// + /// The server which this service is bound to. + public ConfigService(Server server) : base(server, "CONFIG") + { + + } + #endregion + + #region Functions + /// + /// Handles a packet being received by a peer. + /// This is called after all events have been fired for . + /// + /// The peer which sent the packet. + /// The packet sent by the peer. + protected override async Task HandlePacket(Peer sender, Packet packet) + { + // Loop for each message received in the packet + foreach (Message message in packet) + { + switch (message) + { + case ConfigRequestv2 configRequestv2: + await ProcessConfigRequestv2(sender, configRequestv2); + break; + } + } + } + + /// + /// Processes a by attempting to obtain the requested config resource and returning it to the sender. + /// If it is obtained, a response is sent, otherwise a is sent. + /// + /// The peer which sent the request. + /// The request made by the peer. + /// + private async Task ProcessConfigRequestv2(Peer sender, ConfigRequestv2 request) + { + // Obtain the symbols for this config resource type/identifier. + long? typeSymbol = SymbolCache.GetSymbol(request.Info.Type); + long? identifierSymbol = SymbolCache.GetSymbol(request.Info.Identifier); + + // If either symbol could not be obtained, return an error. + if (typeSymbol == null) + { + await sender.Send(new ConfigFailurev2(request.Info.Type, request.Info.Identifier, 1, $"Could not resolve symbol for type (type = {request.Info.Type}, id = {request.Info.Identifier})")); + return; + } + if (identifierSymbol == null) + { + await sender.Send(new ConfigFailurev2(request.Info.Type, request.Info.Identifier, 1, $"Could not resolve symbol for type (type = {request.Info.Type}, id = {request.Info.Identifier})")); + return; + } + + // Try to obtain the requested config resource. + ConfigResource? configData = Storage.Configs.Get((request.Info.Type, request.Info.Identifier)); + if (configData == null) + { + await sender.Send(new ConfigFailurev2(request.Info.Type, request.Info.Identifier, 1, $"Could not find specified config data with the provided identifier (type = {request.Info.Type}, id = {request.Info.Identifier})")); + return; + } + + // Send the config success message. + await sender.Send(new ConfigSuccessv2(typeSymbol.Value, identifierSymbol.Value, configData)); + await sender.Send(new TcpConnectionUnrequireEvent()); + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Services/Login/LoginService.cs b/EchoRelay.Core/Server/Services/Login/LoginService.cs new file mode 100644 index 0000000..f39ae23 --- /dev/null +++ b/EchoRelay.Core/Server/Services/Login/LoginService.cs @@ -0,0 +1,440 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Messages; +using EchoRelay.Core.Server.Messages.Common; +using EchoRelay.Core.Server.Messages.Login; +using EchoRelay.Core.Server.Storage.Types; +using EchoRelay.Core.Utils; +using Jitbit.Utils; +using System.Collections.Specialized; +using System.Net; +using System.Security.Cryptography; +using System.Web; + +namespace EchoRelay.Core.Server.Services.Login +{ + /// + /// The login service is used to sign in, obtain a session, obtain logged in/other user profiles, update logged in profile, etc. + /// + public class LoginService : Service + { + #region Fields + /// + /// A cache of user sessions, with expiry upon peer disconnect. + /// + private FastCache _userSessions; + #endregion + + #region Constructor + /// + /// Initializes a new with the provided arguments. + /// + /// The server which this service is bound to. + public LoginService(Server server) : base(server, "LOGIN") + { + _userSessions = new FastCache(); + OnPeerDisconnected += LoginService_OnPeerDisconnected; + Server.OnServerStopped += Server_OnServerStopped; + } + #endregion + + #region Functions + /// + /// Checks if a provided user session token is valid. + /// + /// The user session to verify. + /// The account identifier of the user. + /// Returns true if the session for this user exists, false otherwise. + public bool CheckUserSessionValid(Guid session, XPlatformId userId) + { + // If the session doesn't exist in cache and we can't obtain the associated user identifier, + // it is not a valid session. + if (!_userSessions.TryGet(session, out XPlatformId storedUserId)) + return false; + + // If the session exists, the user identifiers must match too. + return userId == storedUserId; + } + + /// + /// Invalidates a connected peer's session token. + /// + /// The peer to invalidate the token for. + private void InvalidatePeerUserSession(Peer peer) + { + // If the peer had a session token, remove it. + Guid? session = peer.GetSessionData(); + if (session != null) + { + _userSessions.Remove(session.Value); + } + peer.ClearSessionData(); + } + + /// + /// An event handler triggered when a peer disconnects from the service. + /// + /// The service the peer disconnected from. + /// The peer that disconnected. + private void LoginService_OnPeerDisconnected(Service service, Peer peer) + { + // If the peer had a session token, update its expiry time. + Guid? session = peer.GetSessionData(); + if (session != null && _userSessions.TryGet(session.Value, out XPlatformId userId)) + { + _userSessions.AddOrUpdate(session.Value, userId, Server.Settings.SessionDisconnectedTimeout); + } + } + + /// + /// An event handler which fires when the server is stopped. + /// + /// The server which has stopped. + private void Server_OnServerStopped(Server server) + { + // Clear all sessions on server stop. + _userSessions.Clear(); + } + + /// + /// Handles a packet being received by a peer. + /// This is called after all events have been fired for . + /// + /// The peer which sent the packet. + /// The packet sent by the peer. + protected override async Task HandlePacket(Peer sender, Packet packet) + { + // Loop for each message received in the packet + foreach (Message message in packet) + { + switch (message) + { + case LoginRequest loginRequest: + await ProcessLoginRequest(sender, loginRequest); + break; + case LoggedInUserProfileRequest loggedInUserProfileRequest: + await ProcessLoggedInUserProfileRequest(sender, loggedInUserProfileRequest); + break; + case DocumentRequestv2 documentRequestv2: + await ProcessDocumentRequestv2(sender, documentRequestv2); + break; + case ChannelInfoRequest channelInfoRequest: + await ProcessChannelInfoRequest(sender, channelInfoRequest); + break; + case UpdateProfile updateProfileRequest: + await ProcessUpdateProfile(sender, updateProfileRequest); + break; + case OtherUserProfileRequest otherUserProfileRequest: + await ProcessOtherUserProfileRequest(sender, otherUserProfileRequest); + break; + case UserServerProfileUpdateRequest userServerProfileUpdateRequest: + await ProcessUserServerProfileUpdateRequest(sender, userServerProfileUpdateRequest); + break; + } + } + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessLoginRequest(Peer sender, LoginRequest request) + { + // If we have existing session data for this peer's connection, invalidate it. + // Note: The client may have multiple connections, represented as different peers. + // This only invalidates the current connection prior to accepting a new login. + InvalidatePeerUserSession(sender); + + // Validate the user identifier + if(!request.UserId.Valid()) + { + await sender.Send(new LoginFailure(request.UserId, HttpStatusCode.BadRequest, "User identifier invalid")); + return; + } + + // Validate the user identifier + // TODO: Revisit this, these are not the same values. Should AccountId be the one we actually index accounts by? Can Platform ID change with time..? + if (false && request.AccountInfo.AccountId != request.UserId.AccountId) + { + await sender.Send(new LoginFailure(request.UserId, HttpStatusCode.BadRequest, "Authentication failed")); + return; + } + + // Get the current timestamp + ulong currentTimestamp = (ulong)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + // Try to obtain a user from the storage layer. + // If the user doesn't exist, we create them. + AccountResource? account = Storage.Accounts.Get(request.UserId); + if (account == null) + { + // Create a default username for this user. + string displayName = request.UserId.PlatformCode == PlatformCode.DMO ? "Anonymous [DEMO]" : $"User [{RandomNumberGenerator.GetInt32(int.MaxValue).ToString("X")}]"; + + // Create an account for this user id. We use the platform identifier string as the display name. + account = new AccountResource(request.UserId, displayName, true, true, false); + account.Profile.Server.CreateTime = currentTimestamp; + } + else + { + // Real authentication can't be performed here against Oculus API. We are given an Oculus access token and nonce from client. + // Next, our server should be reaching out to Oculus servers with the access token and nonce to perform validation, however, this + // requires an app secret that only the real server would have, and which we wouldn't. + // Reference: https://developer.oculus.com/documentation/unity/ps-ownership/ + + // Note: It would be an anti-goal of this project to integrate with Oculus services anyways, so this is just a note for research. + } + + // Obtain our login service query parameters, so we can check for account display name overrides, authentication info, etc. + NameValueCollection queryStrings = HttpUtility.ParseQueryString(sender.RequestUri.Query); + string? displayNameOverride = queryStrings.Get("displayname"); + string? authPassword = queryStrings.Get("auth") ?? queryStrings.Get("password"); + + // Authenticate to the account. If this is the first time an authentication lock/password + // was provided, it will be set for future authentication. + if(!account.Authenticate(authPassword)) + { + await sender.Send(new LoginFailure(request.UserId, HttpStatusCode.BadRequest, $"Invalid account password/authentication lock")); + return; + } + + // Check if the user is banned + if (account.Banned) + { + await sender.Send(new LoginFailure(request.UserId, HttpStatusCode.BadRequest, $"Banned until: {account.BannedUntil!.Value:MM/dd/yyyy @ hh:mm:ss tt} (UTC)")); + return; + } + else + { + account.BannedUntil = null; + } + + // If we have a display name override, update the display name. + if (displayNameOverride != null) + { + displayNameOverride = displayNameOverride.Trim(); + if (displayNameOverride.Length > 0) + { + // Limit the maximum display name length. + if (displayNameOverride.Length > 20) + displayNameOverride = displayNameOverride.Substring(0, 20); + + // If this is a demo account, wrap the name for distinction + if (account.AccountIdentifier.PlatformCode == PlatformCode.DMO) + displayNameOverride = $"{displayNameOverride} [DEMO]"; + + account.Profile.SetDisplayName(displayNameOverride); + } + } + + // Update the server profile's logintime and updatetime. + account.Profile.Server.LobbyVersion = request.AccountInfo.LobbyVersion; + account.Profile.Server.LoginTime = currentTimestamp; + account.Profile.Server.UpdateTime = currentTimestamp; + account.Profile.Server.ModifyTime = currentTimestamp; + + // Store the account data + Storage.Accounts.Set(account); + + // Create a session token that will practically not expire. + // Set it for the peer. If they disconnect, an actual timeout will be set on the session before it expires. + Guid session = SecureGuidGenerator.Generate(); + _userSessions.AddOrUpdate(session, request.UserId, TimeSpan.FromDays(3000)); + sender.SetSessionData(session); + + // Obtain the login settings + LoginSettingsResource? loginSettings = Storage.LoginSettings.Get(); + + // Set the authenticated user identifier + sender.UpdateUserAuthentication(request.UserId, account.Profile.Server.DisplayName); + + // Send login success response. + await sender.Send(new LoginSuccess(request.UserId, session)); + await sender.Send(new TcpConnectionUnrequireEvent()); + + // Send login settings if we were able to obtain them. + if (loginSettings != null) + { + await sender.Send(new LoginSettings(loginSettings)); + } + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessLoggedInUserProfileRequest(Peer sender, LoggedInUserProfileRequest request) + { + // Verify the session details provided + if (!CheckUserSessionValid(request.Session, request.UserId)) + { + await sender.Send(new LoggedInUserProfileFailure(request.UserId, HttpStatusCode.BadRequest, "Authentication failed")); + return; + } + + // Obtain the account associated with the request. + AccountResource? account = Storage.Accounts.Get(request.UserId); + if (account == null) + { + await sender.Send(new LoggedInUserProfileFailure(request.UserId, HttpStatusCode.BadRequest, "Failed to obtain profile")); + return; + } + + // Send the account profile to the user. + await sender.Send(new LoggedInUserProfileSuccess(request.UserId, account.Profile)); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessOtherUserProfileRequest(Peer sender, OtherUserProfileRequest request) + { + // Obtain the account associated with the request. + AccountResource? account = Storage.Accounts.Get(request.UserId); + if (account == null) + { + await sender.Send(new OtherUserProfileFailure(request.UserId, HttpStatusCode.BadRequest, "Failed to obtain profile")); + return; + } + + // Send the account profile to the user. + await sender.Send(new OtherUserProfileSuccess(request.UserId, account.Profile.Server)); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessUserServerProfileUpdateRequest(Peer sender, UserServerProfileUpdateRequest request) + { + // Obtain the account associated with the request. + AccountResource? account = Storage.Accounts.Get(request.UserId); + if (account == null) + { + // TODO: Failure message! + return; + } + + // Merge the update information with the user. + if (request.UpdateInfo.Update != null) + { + // Obtain the merged profile + AccountResource.AccountServerProfile? mergedProfile = JsonUtils.MergeObjects(account.Profile.Server, request.UpdateInfo.Update); + + // Verify we have an account and the identifier didn't change (avoids overwriting another profile in storage, as it is the storage key). + if (mergedProfile == null || mergedProfile.XPlatformId != request.UserId.ToString()) + { + // TODO: Send UpdateProfileFailure(?) + return; + } + + // Update the server profile in the account and set it in storage. + account.Profile.Server = mergedProfile; + Storage.Accounts.Set(account); + } + + // Send the account profile to the user. + await sender.Send(new UserServerUpdateProfileSuccess(request.UserId)); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessUpdateProfile(Peer sender, UpdateProfile request) + { + // Verify the session details provided + if (!CheckUserSessionValid(request.Session, request.UserId)) + { + // TODO: Send UpdateProfileFailure(?) + return; + } + + // Obtain the account associated with the request. + AccountResource? account = Storage.Accounts.Get(request.UserId); + if (account == null) + { + // TODO: Send UpdateProfileFailure(?) + return; + } + + // Verify the account identifier did not change (avoids overwriting another profile in storage, as it is the storage key). + if (request.ClientProfile.XPlatformId != request.UserId.ToString()) + { + // TODO: Send UpdateProfileFailure(?) + return; + } + + // Get the current timestamp + ulong currentTimestamp = (ulong)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + // TODO: For now, we just trust all the update data and merge it in. We should scrutinize it more. + account.Profile.Client = request.ClientProfile; + + // Update the account. + account.Profile.Server.UpdateTime = currentTimestamp; + account.Profile.Server.ModifyTime = currentTimestamp; + Storage.Accounts.Set(account); + + // Send the account profile to the user. + await sender.Send(new UpdateProfileSuccess(request.UserId)); + await sender.Send(new TcpConnectionUnrequireEvent()); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessChannelInfoRequest(Peer sender, ChannelInfoRequest request) + { + // Try to obtain our channel info + ChannelInfoResource? channelInfo = Storage.ChannelInfo.Get(); + if (channelInfo != null) + await sender.Send(new ChannelInfoResponse(channelInfo)); + await sender.Send(new TcpConnectionUnrequireEvent()); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessDocumentRequestv2(Peer sender, DocumentRequestv2 request) + { + // Obtain the symbols for the document name and language. + long? nameSymbol = SymbolCache.GetSymbol(request.Name); + long? languageSymbol = SymbolCache.GetSymbol(request.Language); + + // If we couldn't resolve the name or language, return a failure. + if (nameSymbol == null) + { + await sender.Send(new DocumentFailure(1, 0, $"Could not resolve symbol for document name")); + return; + } + if (languageSymbol == null) + { + await sender.Send(new DocumentFailure(1, 0, $"Could not resolve symbol for document language")); + return; + } + + // Fetch the document from storage + DocumentResource? resource = Storage.Documents.Get((request.Name, request.Language)); + if (resource == null) + { + await sender.Send(new DocumentFailure(1, 0, $"Could not find document")); + return; + } + + // Send the document in response. + await sender.Send(new DocumentSuccess(nameSymbol.Value, resource)); + await sender.Send(new TcpConnectionUnrequireEvent()); + } + #endregion + } +} \ No newline at end of file diff --git a/EchoRelay.Core/Server/Services/Matching/MatchingService.cs b/EchoRelay.Core/Server/Services/Matching/MatchingService.cs new file mode 100644 index 0000000..d030105 --- /dev/null +++ b/EchoRelay.Core/Server/Services/Matching/MatchingService.cs @@ -0,0 +1,362 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Messages; +using EchoRelay.Core.Server.Messages.Common; +using EchoRelay.Core.Server.Messages.Matching; +using EchoRelay.Core.Server.Services.ServerDB; +using EchoRelay.Core.Server.Storage.Types; +using EchoRelay.Core.Utils; +using static EchoRelay.Core.Server.Messages.ServerDB.ERGameServerStartSession; + +namespace EchoRelay.Core.Server.Services.Matching +{ + public class MatchingService : Service + { + public MatchingService(Server server) : base(server, "MATCHING") + { + } + + /// + /// Handles a packet being received by a peer. + /// This is called after all events have been fired for . + /// + /// The peer which sent the packet. + /// The packet sent by the peer. + protected override async Task HandlePacket(Peer sender, Packet packet) + { + // Loop for each message received in the packet + foreach (Message message in packet) + { + switch (message) + { + case LobbyCreateSessionRequestv9 createSessionRequestv9: + await ProcessCreateSessionRequestv9(sender, createSessionRequestv9); + break; + case LobbyFindSessionRequestv11 findSessionRequestv11: + await ProcessFindSessionRequestv11(sender, findSessionRequestv11); + break; + case LobbyJoinSessionRequestv7 joinSessionRequestv7: + await ProcessJoinSessionRequestv7(sender, joinSessionRequestv7); + break; + case LobbyPendingSessionCancel pendingSessionCancel: + await ProcessPendingSessionCancel(sender, pendingSessionCancel); + break; + case LobbyMatchmakerStatusRequest matchmakerStatusRequest: + break; + case LobbyPingResponse pingResponse: + await ProcessPingResponse(sender, pingResponse); + break; + case LobbyPlayerSessionsRequestv5 playerSessionsRequestv5: + await ProcessPlayerSessionsRequestv5(sender, playerSessionsRequestv5); + break; + } + } + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessCreateSessionRequestv9(Peer sender, LobbyCreateSessionRequestv9 request) + { + // Set the matching data for our user to provide context to matching operations moving forward. + sender.SetSessionData(MatchingSession.FromCreateSessionCriteria(request.UserId, request.ChannelUUID, request.GameTypeSymbol, request.LevelSymbol, request.LobbyType, (TeamIndex)request.TeamIndex, request.SessionSettings)); + + // Process the underlying request. + await ProcessMatchingSession(sender, request.Session, request.UserId); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessFindSessionRequestv11(Peer sender, LobbyFindSessionRequestv11 request) + { + // Set the matching data for our user to provide context to matching operations moving forward. + sender.SetSessionData(MatchingSession.FromFindSessionCriteria(request.UserId, request.ChannelUUID, request.GameTypeSymbol, (TeamIndex)request.TeamIndex, request.SessionSettings)); + + // Process the underlying request. + await ProcessMatchingSession(sender, request.Session, request.UserId); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessJoinSessionRequestv7(Peer sender, LobbyJoinSessionRequestv7 request) + { + // Set the matching data for our user to provide context to matching operations moving forward. + sender.SetSessionData(MatchingSession.FromJoinSpecificSessionCriteria(request.UserId, request.LobbyUUID, (TeamIndex)request.TeamIndex, request.SessionSettings)); + + // Process the underlying request. + await ProcessMatchingSession(sender, request.Session, request.UserId); + } + + /// + /// Processes the underlying data derived from , + /// , or . + /// + /// The sender of the request. + private async Task ProcessMatchingSession(Peer sender, Guid session, XPlatformId userId) + { + // Verify the session details provided + if (!Server.LoginService.CheckUserSessionValid(session, userId)) + { + await SendLobbySessionFailure(sender, LobbySessionFailureErrorCode.BadRequest, "Unauthorized"); + return; + } + + // Verify the account behind the request. + AccountResource? account = Storage.Accounts.Get(userId); + if (account == null) + { + await SendLobbySessionFailure(sender, LobbySessionFailureErrorCode.BadRequest, "Failed to obtain profile"); + return; + } + + // Check if the user is banned, if so, disallow them from matching. + if (account.Banned) + { + await SendLobbySessionFailure(sender, LobbySessionFailureErrorCode.BannedFromLobbyGroup, $"Banned until: {account.BannedUntil!.Value:MM/dd/yyyy @ hh:mm:ss tt} (UTC)"); + return; + } + + // Set the authenticated user information. + sender.UpdateUserAuthentication(userId, account.Profile.Server.DisplayName); + + // Obtain the user's matching session + MatchingSession? matchingSession = sender.GetSessionData(); + if (matchingSession == null) + { + await SendLobbySessionFailure(sender, LobbySessionFailureErrorCode.InternalError, "Cannot process session request, no matching session exists"); + return; + } + + // Validate the user is not requesting to be a moderator when they are not one. + if (matchingSession.TeamIndex == TeamIndex.Moderator && !account.IsModerator) + { + await SendLobbySessionFailure(sender, LobbySessionFailureErrorCode.NotALobbyGroupMod, "User is not a moderator"); + return; + } + + // Send the status to the user. + // TODO: This should be a response to LobbyMatchmakerStatusRequest and should be relocated. + // That request is sent along with this request we are currently processing, so it is technically fine to respond here (for the client), but it's just ugly in terms of code correctness. + await sender.Send(new LobbyMatchmakerStatus(0)); + + // If we were provided a lobby/session identifier (via LobbyJoinSessionRequestv7), search for the game server directly. + // This uses a special lookup method using the lobby id, that is faster than filtering all game servers. + if (matchingSession.LobbyId != null) + { + RegisteredGameServer? requestedGameServer = Server.ServerDBService.Registry.GetGameServer(matchingSession.LobbyId.Value); + if (requestedGameServer == null) + { + await SendLobbySessionFailure(sender, LobbySessionFailureErrorCode.ServerDoesNotExist, "Could not find requested lobby id"); + return; + } + + // Obtain our session from the game server. + await requestedGameServer.ProcessLobbySessionRequest(sender); + return; + } + + // This is a create lobby, or find lobby request. We will try to find an existing server that matches the request. + // Filter game servers, produce ping request endpoint data. + // We limit the amount to 100, to avoid the response hitting the max packet size. + var gameServers = Server.ServerDBService.Registry.FilterGameServers( + findMax: 100, + sessionId: matchingSession.LobbyId, + gameTypeSymbol: matchingSession.GameTypeSymbol, + levelSymbol: matchingSession.LevelSymbol, + channel: matchingSession.Channel, + locked: false, + lobbyTypes: matchingSession.SearchLobbyTypes, + unfilledServerOnly: true + ); + + // If we only have one game server, immediately connect the peer. Otherwise, perform a ping request to determine the lowest ping server. + if (gameServers.Count() == 1) + { + // Process the new session request, to get the peer the information it needs to connect to the lobby. + await gameServers.First().ProcessLobbySessionRequest(sender); + } + else + { + // Construct our endpoint data for the ping request, from the game servers we got in our previous query. + var pingEndpoints = new LobbyPingRequestv3.EndpointData[gameServers.Count()]; + int current = 0; + foreach (var gameServer in gameServers) + { + pingEndpoints[current++] = new LobbyPingRequestv3.EndpointData( + gameServer.InternalAddress, + gameServer.ExternalAddress, + gameServer.Port + ); + } + + // Send a ping request to the peer. + await sender.Send(new LobbyPingRequestv3(0, 4, 100, pingEndpoints)); + } + await sender.Send(new TcpConnectionUnrequireEvent()); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessPendingSessionCancel(Peer sender, LobbyPendingSessionCancel request) + { + // Clear the matching session data for this peer. + sender.ClearSessionData(); + await sender.Send(new TcpConnectionUnrequireEvent()); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessPingResponse(Peer sender, LobbyPingResponse request) + { + // Obtain the user's matching session + MatchingSession? matchingSession = sender.GetSessionData(); + if (matchingSession == null) + { + await SendLobbySessionFailure(sender, LobbySessionFailureErrorCode.InternalError, "Ping response given, but no matching session exists"); + return; + } + + // Try to select a game server + RegisteredGameServer? selectedGameServer = null; + + // If we have no results, there are likely no game servers available to serve the request. + // See if the user specified that they wish to force users in this scenario to join any available server. + if (request.Results.Length == 0) + { + if (Server.Settings.ForceIntoAnySessionIfCreationFails) + { + // Resolve the most populated available game server with open space and select it. + selectedGameServer = Server.ServerDBService.Registry.FilterGameServers(locked: false, unfilledServerOnly: true, lobbyTypes: new LobbyType[] {LobbyType.Unassigned, LobbyType.Public}) + .MaxBy(x => (float)x.SessionPlayerCount / x.SessionPlayerLimit); + } + else + { + await SendLobbySessionFailure(sender, LobbySessionFailureErrorCode.ServerFindFailed, "Could not receive a ping response from any game servers"); + return; + } + } + else + { + // Convert the ping results to a lookup + Dictionary<(uint InternalAddress, uint ExternalAddress), uint> pingResultLookup = request.Results.ToDictionary(x => (x.InternalAddress.ToUInt32(), x.ExternalAddress.ToUInt32()), x => x.PingMilliseconds); + + // Resolve game servers matching this address with any other provided lookup criteria. + var gameServers = Server.ServerDBService.Registry.FilterGameServers( + addresses: pingResultLookup.Keys.ToHashSet(), + sessionId: matchingSession.LobbyId, + gameTypeSymbol: matchingSession.GameTypeSymbol, + levelSymbol: matchingSession.LevelSymbol, + channel: matchingSession.Channel, + locked: false, + lobbyTypes: matchingSession.SearchLobbyTypes, + unfilledServerOnly: true + ); + + // All servers should either have no session started, or match the criteria we filtered for. + // Depending on our matching strategy, we will first sort by population or ping, followed by the latter. + // The most optimal game server will be selected. + if (Server.Settings.FavorPopulationOverPing) + { + // Select the game server which is most full. + selectedGameServer = gameServers.MaxBy(x => (float)x.SessionPlayerCount / x.SessionPlayerLimit); + } + else + { + // Sort the game servers with preference of filters: session started, lowest ping, highest player count. + var sortedGameServers = gameServers.Select(gameServer => { + uint? pingMilliseconds = pingResultLookup.TryGetValue((gameServer.InternalAddress.ToUInt32(), gameServer.ExternalAddress.ToUInt32()), out uint p) ? p : uint.MaxValue; + return (gameServer, pingMilliseconds); + }).OrderBy(x => x.gameServer.SessionStarted ? 0 : 1).ThenBy(x => x.pingMilliseconds).ThenBy(x => (float)x.gameServer.SessionPlayerCount / x.gameServer.SessionPlayerLimit); + + // Select the first game server. + selectedGameServer = sortedGameServers.FirstOrDefault().gameServer; + } + } + + // Verify that a game server candidate was found. + if (selectedGameServer == null) + { + await SendLobbySessionFailure(sender, LobbySessionFailureErrorCode.ServerFindFailed, "Could not obtain registered game server to serve request."); + return; + } + + // Process the new session request, to get the peer the information it needs to connect to the lobby. + await selectedGameServer.ProcessLobbySessionRequest(sender); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessPlayerSessionsRequestv5(Peer sender, LobbyPlayerSessionsRequestv5 request) + { + // Verify the session details provided + if (!Server.LoginService.CheckUserSessionValid(request.Session, request.UserId)) + { + await SendLobbySessionFailure(sender, LobbySessionFailureErrorCode.BadRequest, "Unauthorized"); + return; + } + + // Obtain the user's matching session + MatchingSession? matchingSession = sender.GetSessionData(); + if (matchingSession == null) + { + await SendLobbySessionFailure(sender, LobbySessionFailureErrorCode.InternalError, "Player sessions requested, but no matching session exists"); + return; + } + + if (matchingSession.MatchedGameServer == null) + { + await SendLobbySessionFailure(sender, LobbySessionFailureErrorCode.InternalError, "Player sessions requested, but no matched game server exists"); + return; + } + + // Coordinate the player session request with the game server. + await matchingSession.MatchedGameServer.ProcessPlayerSessionRequest(sender, request.UserId, matchingSession.Channel ?? new Guid()); + sender.ClearSessionData(); + } + + /// + /// Sends all versions of the lobby session failure message to a peer, indicating that + /// a matching operation failed. This method does nothing if the peer has no active matching session. + /// + /// The peer to send the message to. + /// The error code to send for the failure. + /// The error message to send. + /// + internal async Task SendLobbySessionFailure(Peer peer, LobbySessionFailureErrorCode errorCode, string errorMessage) + { + // Obtain the peer's matching session data. + MatchingSession? matchingSession = peer.GetSessionData(); + if (matchingSession == null) + return; + + // Clear the matching session data. + peer.ClearSessionData(); + + // Define the arguments for our failure messages. + long gameTypeSymbol = matchingSession.GameTypeSymbol ?? -1; + Guid channel = matchingSession.Channel ?? matchingSession.LobbyId ?? new Guid(); + + // Send the failure messages. + await peer.Send(new LobbySessionFailurev1(errorCode)); + await peer.Send(new LobbySessionFailurev2(channel, errorCode)); + await peer.Send(new LobbySessionFailurev3(gameTypeSymbol, channel, errorCode, 0)); + await peer.Send(new LobbySessionFailurev4(gameTypeSymbol, channel, errorCode, 0, errorMessage)); + } + } +} diff --git a/EchoRelay.Core/Server/Services/Matching/MatchingSession.cs b/EchoRelay.Core/Server/Services/Matching/MatchingSession.cs new file mode 100644 index 0000000..fcf767f --- /dev/null +++ b/EchoRelay.Core/Server/Services/Matching/MatchingSession.cs @@ -0,0 +1,54 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Messages.ServerDB; +using EchoRelay.Core.Server.Services.ServerDB; +using static EchoRelay.Core.Server.Messages.ServerDB.ERGameServerStartSession; + +namespace EchoRelay.Core.Server.Services.Matching +{ + public class MatchingSession + { + public XPlatformId UserId { get; private set; } + public Guid? LobbyId { get; private set; } + public Guid? Channel { get; private set; } + public long? GameTypeSymbol { get; private set; } + public long? LevelSymbol { get; private set; } + public LobbyType NewSessionLobbyType { get; private set; } + public LobbyType[] SearchLobbyTypes + { + get + { + return (NewSessionLobbyType == LobbyType.Private) ? new LobbyType[] { LobbyType.Unassigned } : new LobbyType[] { LobbyType.Unassigned, LobbyType.Public }; + } + } + public ERGameServerStartSession.SessionSettings SessionSettings { get; private set; } + public TeamIndex TeamIndex { get; private set; } + + public RegisteredGameServer? MatchedGameServer { get; set; } + public Guid? MatchedSessionId { get; set; } + private MatchingSession(XPlatformId userId, Guid? lobbyId, Guid? channel, long? gameTypeSymbol, long? levelSymbol, LobbyType newSessionLobbyType, TeamIndex teamIndex, ERGameServerStartSession.SessionSettings sessionSettings) + { + UserId = userId; + LobbyId = lobbyId; + Channel = channel; + GameTypeSymbol = gameTypeSymbol; + LevelSymbol = levelSymbol; + NewSessionLobbyType = newSessionLobbyType; + TeamIndex = teamIndex; + SessionSettings = sessionSettings; + } + + public static MatchingSession FromCreateSessionCriteria(XPlatformId userId, Guid? channel, long? gameTypeSymbol, long? levelSymbol, LobbyType lobbyType, TeamIndex teamIndex, ERGameServerStartSession.SessionSettings sessionSettings) + { + return new MatchingSession(userId, null, channel, gameTypeSymbol, levelSymbol, lobbyType, teamIndex, sessionSettings); + } + public static MatchingSession FromFindSessionCriteria(XPlatformId userId, Guid? channel, long? gameTypeSymbol, TeamIndex teamIndex, ERGameServerStartSession.SessionSettings sessionSettings) + { + return new MatchingSession(userId, null, channel, gameTypeSymbol, null, LobbyType.Public, teamIndex, sessionSettings); + } + + public static MatchingSession FromJoinSpecificSessionCriteria(XPlatformId userId, Guid? lobbyId, TeamIndex teamIndex, ERGameServerStartSession.SessionSettings sessionSettings) + { + return new MatchingSession(userId, lobbyId, null, null, null, LobbyType.Public, teamIndex, sessionSettings); + } + } +} diff --git a/EchoRelay.Core/Server/Services/Peer.cs b/EchoRelay.Core/Server/Services/Peer.cs new file mode 100644 index 0000000..862b405 --- /dev/null +++ b/EchoRelay.Core/Server/Services/Peer.cs @@ -0,0 +1,214 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Messages; +using EchoRelay.Core.Utils; +using System.Net; +using System.Net.WebSockets; + +namespace EchoRelay.Core.Server.Services +{ + /// + /// A peer which has established a connection with the websocket server and begun engaging with a . + /// + /// + public class Peer + { + #region Properties + /// + /// The which the peer is connected to. + /// + public Server Server { get; } + /// + /// The which the peer is connected to. + /// + public Service Service { get; } + /// + /// The websocket connection with the peer. + /// + internal WebSocket Connection { get; } + /// + /// Session data associated with a given . + /// + private object? _sessionData; + + /// + /// The remote address of the connected peer. + /// + public IPAddress Address { get; } + /// + /// The remote TCP port of the connected peer. + /// + public ushort Port { get; } + /// + /// The URI of the request made to connect to the . + /// + public Uri RequestUri { get; } + /// + /// A unique identifier for the peer on this service. + /// + public string Id { get; } + /// + /// Thread synchronization to avoid multiple sends at the same time, which will throw an exception. + /// + private AsyncLock _sendLock; + + /// + /// The user identifier for the peer. This is set when the peer authenticates to a + /// user account. Otherwise, it is null. + /// + public XPlatformId? UserId { get; private set; } + /// + /// The user display name for the peer. This is set when the peer authenticates to a + /// user account. Otherwise, it is null. + /// + public string? UserDisplayName { get; private set; } + #endregion + + #region Events + /// + /// Event for a authenticating within a , with a given . + /// + /// The the authenticated to. + /// The which authenticated itself. + public delegate void AuthenticatedEventHandler(Service service, Peer peer, XPlatformId userId); + /// + /// Event for a authenticating within a , with a given . + /// + public event AuthenticatedEventHandler? OnPeerAuthenticated; + + /// + /// Event for a packet being received from a connected to the . + /// + /// The the connected to. + /// The which sent the . + /// The set by the . + public delegate void PacketReceivedEventHandler(Service service, Peer peer, Packet packet); + /// + /// Event for a packet being received from a connected to the . + /// + public event PacketReceivedEventHandler? OnPacketReceived; + /// + /// Event for a packet being sent from a connected to the . + /// + /// The the connected to. + /// The which is being sent the . + /// The set by the . + public delegate void PacketSentEventHandler(Service service, Peer peer, Packet packet); + /// + /// Event for a packet being sent from a connected to the . + /// + public event PacketSentEventHandler? OnPacketSent; + #endregion + + #region Constructor + /// + /// Initializes a with the provided arguments. + /// + /// The used to accept the connection request. + public Peer(Server server, Service service, HttpListenerContext context, WebSocket connection) + { + // Set our provided arguments. + Server = server; + Service = service; + Address = context.Request.RemoteEndPoint.Address; + Port = (ushort)context.Request.RemoteEndPoint.Port; + RequestUri = context.Request.Url!; + Connection = connection; + Id = $"{service.Name}:{Address}:{Port}"; + _sessionData = null; + _sendLock = new AsyncLock(); + } + #endregion + + #region Functions + /// + /// Obtains the session data of the provided type for the peer. It must match the type of data used in . + /// + /// The type of the session data to obtain. + /// Returns the session data as the requested type. + public T? GetSessionData() + { + // Obtain the potentially null session data. + if (_sessionData == null) + return default; + return (T?)_sessionData; + } + /// + /// Sets session data for the provided peer. + /// + /// The type of the session data to set. + /// The session data to set. + public void SetSessionData(T? sessionData) + { + // Set session data. + _sessionData = sessionData; + } + /// + /// Clear session data for the provided peer. + /// + /// The type of the session data to clear. + /// The session data to clear. + public void ClearSessionData() + { + // Clear session data. + _sessionData = null; + } + + /// + /// Updates the peer with a user identifier which they were authenticated to, on their current service. + /// This triggers relevant event handlers in the peer and service if the user identifier was changed by this method. + /// + /// The user identifier which the peer was authenticated to. + /// The display name for the user account authenticated to. + public void UpdateUserAuthentication(XPlatformId userId, string userDisplayName) + { + // Determine if we're changing the state of the user id. + bool changed = false; + if (UserId != userId || UserDisplayName != userDisplayName) + changed = true; + + // Set the user id and display name. + UserId = userId; + UserDisplayName = userDisplayName; + + // If the platform id changed + if (changed) + OnPeerAuthenticated?.Invoke(Service, this, UserId); + } + + /// + /// Sends a provided packet to the peer through the websocket. + /// + /// The messages to wrap in a packet to send to the peer. + /// A task representing the send operation state. + public async Task Send(params Message[] messages) + { + // Wrap the messages in a packet and send it. + await Send(new Packet(messages)); + } + /// + /// Sends a provided packet to the peer through the websocket. + /// + /// The packet to send to the peer. + /// A task representing the send operation state. + public async Task Send(Packet packet) + { + // Send the provided packet through the client websocket connection. + await _sendLock.ExecuteLocked(async() => { + await Connection.SendAsync(new ArraySegment(packet.Encode()), WebSocketMessageType.Binary, true, CancellationToken.None); + }); + + // Fire the packet sent event. + OnPacketSent?.Invoke(Service, this, packet); + } + /// + /// Receives a provided packet and fires relevant event handlers. + /// + /// The packet received from the peer. + internal void InvokeReceiveEventHandler(Packet packet) + { + // Fire the packet received event. + OnPacketReceived?.Invoke(Service, this, packet); + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Services/ServerDB/GameServerRegistry.cs b/EchoRelay.Core/Server/Services/ServerDB/GameServerRegistry.cs new file mode 100644 index 0000000..bd41320 --- /dev/null +++ b/EchoRelay.Core/Server/Services/ServerDB/GameServerRegistry.cs @@ -0,0 +1,122 @@ +using EchoRelay.Core.Server.Messages.ServerDB; +using EchoRelay.Core.Utils; +using System.Collections.Concurrent; +using static EchoRelay.Core.Server.Messages.ServerDB.ERGameServerStartSession; + +namespace EchoRelay.Core.Server.Services.ServerDB +{ + public class GameServerRegistry + { + #region Properties + public static readonly Guid ZeroGuid = new Guid("00000000-0000-0000-0000-000000000000"); + public ConcurrentDictionary RegisteredGameServers { get; } + public ConcurrentDictionary RegisteredGameServersBySessionId { get; } + #endregion + + #region Events + /// + /// Event a game server being registered/unregistered with the central server. + /// + /// The which was registered/unregistered with the central server. + public delegate void GameServerRegistrationChangedEventHandler(RegisteredGameServer gameServer); + /// + /// Event a game server being registered. + /// + public event GameServerRegistrationChangedEventHandler? OnGameServerRegistered; + /// + /// Event a game server being unregistered. + /// + public event GameServerRegistrationChangedEventHandler? OnGameServerUnregistered; + #endregion + + #region Constructor + public GameServerRegistry() + { + RegisteredGameServers = new ConcurrentDictionary(); + RegisteredGameServersBySessionId = new ConcurrentDictionary(); + } + #endregion + + #region Functions + public RegisteredGameServer RegisterGameServer(Peer peer, ERGameServerRegistrationRequest request) + { + // Create a new registered server and set it in our lookup. + RegisteredGameServer registeredGameServer = new RegisteredGameServer(this, peer, request); + RegisteredGameServers[registeredGameServer.ServerId] = registeredGameServer; + + // Fire the relevant event for the game server being registered. + OnGameServerRegistered?.Invoke(registeredGameServer); + return registeredGameServer; + } + + public RegisteredGameServer? GetGameServer(ulong serverId) + { + RegisteredGameServers.TryGetValue(serverId, out RegisteredGameServer? registeredGameServer); + return registeredGameServer; + } + + public RegisteredGameServer? GetGameServer(Guid sessionId) + { + RegisteredGameServersBySessionId.TryGetValue(sessionId, out RegisteredGameServer? registeredGameServer); + return registeredGameServer; + } + + public void RemoveGameServer(RegisteredGameServer registeredGameServer) + { + RemoveGameServer(registeredGameServer.ServerId); + } + public void RemoveGameServer(ulong serverId) + { + // Try to remove any registered game server with this server identifier. + RegisteredGameServers.Remove(serverId, out var unregisteredGameServer); + + // Fire the relevant event for the game server being registered. + if (unregisteredGameServer != null) + OnGameServerUnregistered?.Invoke(unregisteredGameServer); + } + + public IEnumerable FilterGameServers(int? findMax = null, ulong? serverId = null, Guid? sessionId = null, + HashSet<(uint InternalAddr, uint ExternalAddr)>? addresses = null, ushort? port = null, + long? gameTypeSymbol = null, long? levelSymbol = null, Guid? channel = null, bool? locked = null, LobbyType[]? lobbyTypes = null, bool unfilledServerOnly = true) + { + // Filter through all game servers + List filteredGameServers = new List(); + foreach(RegisteredGameServer gameServer in RegisteredGameServers.Values) + { + // If we hit any set limit for game servers found, stop. + if (findMax != null && filteredGameServers.Count >= findMax) + break; + + // Filter for each field supplied, skip to the next game server if any filter doesn't match. + if (serverId != null && gameServer.ServerId != serverId) + continue; + else if (addresses != null && !addresses.Contains((gameServer.InternalAddress.ToUInt32(), gameServer.ExternalAddress.ToUInt32()))) + continue; + else if (port != null && gameServer.Peer.Port != port) + continue; + + // If the session is started, filter on that criteria. + if (gameServer.SessionStarted) + { + if (gameTypeSymbol != null && gameTypeSymbol != gameServer.SessionGameTypeSymbol) + continue; + else if (levelSymbol != null && levelSymbol != gameServer.SessionLevelSymbol) + continue; + else if (channel != null && channel != ZeroGuid && gameServer.SessionChannel != ZeroGuid && channel != gameServer.SessionChannel) // zero guid means it is not a social lobby, etc, so we do not filter. + continue; + else if (locked != null && locked != gameServer.SessionLocked) + continue; + else if (lobbyTypes != null && !lobbyTypes.Contains(gameServer.SessionLobbyType)) + continue; + else if (unfilledServerOnly && gameServer.SessionPlayerCount >= gameServer.SessionPlayerLimit) + continue; + } + + // The game server matched all filters, add it to the list + filteredGameServers.Add(gameServer); + } + return filteredGameServers; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Services/ServerDB/PacketEncoderSettings.cs b/EchoRelay.Core/Server/Services/ServerDB/PacketEncoderSettings.cs new file mode 100644 index 0000000..a14f8f5 --- /dev/null +++ b/EchoRelay.Core/Server/Services/ServerDB/PacketEncoderSettings.cs @@ -0,0 +1,85 @@ +namespace EchoRelay.Core.Server.Services.ServerDB +{ + /// + /// Describes packet encoding settings for one party in a game server <-> client connection. + /// + public class PacketEncoderSettings + { + #region Properties + /// + /// Indicates whether encryption should be used for each packet to ensure confidentiality. + /// + public bool EncryptionEnabled { get; } + /// + /// Indicates whether MACs should be attached to each packet to verify their integrity. + /// + public bool MacEnabled { get; } + /// + /// The byte size of the MAC output packets should use. It must not exceed 64 (512 bit), + /// as the MAC is cut from the front of the HMAC-SHA512 digest. + /// + public int MacDigestSize { get; } + /// + /// The iteration count, if set to zero, dictates MAC should be plain HMAC-SHA512. + /// TODO: (Unverified) If iteration count is greater than zero, PBKDF2 HMAC-SHA512 will be + /// used with the given number of iterations. + /// + public int MacPBKDF2IterationCount { get; } + /// + /// The byte size of the HMAC-SHA512 key that should be used. + /// + public int MacKeySize { get; } + /// + /// The byte size of the AES-CBC key to be used. Default is 32 (AES-256-CBC). + /// + public int EncryptionKeySize { get; } + /// + /// The byte size of the random key which the sponge/duplex construction Keccak-F permutation (1600-bit) + /// random number generator (RNG) should ingest to seed itself. Both parties are provided eachother's packet encoding + /// settings. Each packet sent should be encrypted/decrypted using the party's encryption key, where the 16-byte initialization + /// vector (IV) is generated newly by the RNG for every step in sequence id. + /// + public int RandomKeySize { get; } + #endregion + + #region Constructors + public PacketEncoderSettings(bool encryptionEnabled = true, bool macEnabled = true, int macDigestSize = 64, int macPBKDF2IterationCounter = 0, int macKeySize = 32, int encryptionKeySize = 32, int randomKeySize = 32) + { + // Set our provided fields + EncryptionEnabled = encryptionEnabled; + MacEnabled = macEnabled; + MacDigestSize = macDigestSize; + MacPBKDF2IterationCount = macPBKDF2IterationCounter; + MacKeySize = macKeySize; + EncryptionKeySize = encryptionKeySize; + RandomKeySize = randomKeySize; + } + public PacketEncoderSettings(ulong flags) + { + // Decode our information. + EncryptionEnabled = (flags & 1) != 0; + MacEnabled = (flags & 2) != 0; + MacDigestSize = (int)(flags >> 2) & 0xFFF; + MacPBKDF2IterationCount = (int)(flags >> 14) & 0xFFF; + MacKeySize = (int)(flags >> 26) & 0xFFF; + EncryptionKeySize = (int)(flags >> 38) & 0xFFF; + RandomKeySize = (int)(flags >> 50) & 0xFFF; + } + #endregion + + #region Operators + public static explicit operator PacketEncoderSettings(ulong flags) => new PacketEncoderSettings(flags); + public static explicit operator ulong(PacketEncoderSettings packetEncoderSettings) + { + ulong flags = (uint)(packetEncoderSettings.EncryptionEnabled ? 1 : 0); + flags |= (uint)(packetEncoderSettings.MacEnabled ? (1 << 1) : 0); + flags |= (ulong)packetEncoderSettings.MacDigestSize << 2; + flags |= (ulong)packetEncoderSettings.MacPBKDF2IterationCount << 14; + flags |= (ulong)packetEncoderSettings.MacKeySize << 26; + flags |= (ulong)packetEncoderSettings.EncryptionKeySize << 38; + flags |= (ulong)packetEncoderSettings.RandomKeySize << 50; + return flags; + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Services/ServerDB/RegisteredGameServer.cs b/EchoRelay.Core/Server/Services/ServerDB/RegisteredGameServer.cs new file mode 100644 index 0000000..bd64a42 --- /dev/null +++ b/EchoRelay.Core/Server/Services/ServerDB/RegisteredGameServer.cs @@ -0,0 +1,507 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Messages.Matching; +using EchoRelay.Core.Server.Messages.ServerDB; +using EchoRelay.Core.Server.Services.Matching; +using EchoRelay.Core.Utils; +using System.Net; +using System.Security.Cryptography; + +namespace EchoRelay.Core.Server.Services.ServerDB +{ + /// + /// A provider which tracks a game server which has registered with a service. + /// It fulfills matching requests between the service and the + /// service. + /// + public class RegisteredGameServer + { + #region Fields/Properties + /// + /// The parent which the is registered to. + /// + private GameServerRegistry Registry { get; } + + /// + /// The registration request provided by the game server initially. + /// + private ERGameServerRegistrationRequest _registrationRequest; + + /// + /// The actual peer connection used to communicate with the game server. + /// + public Peer Peer { get; } + + /// + /// The identifier of the game server. + /// + public ulong ServerId + { + get { return _registrationRequest.ServerId; } + } + /// + /// The internal/private IP address of the game server. + /// + public IPAddress InternalAddress + { + get { return _registrationRequest.InternalAddress; } + } + /// + /// The public/external IP address of the game server. + /// + public IPAddress ExternalAddress + { + get + { + // If the external address registered as a private address, NAT configurations may have redirected request, etc. + // So we match it with the public IP address we attempt to fetch on server start, and assume anything on the same + // internal network is accessible through this IP. + if (Peer.Address.IsPrivate()) + if(Peer.Server.PublicIPAddress != null) + return Peer.Server.PublicIPAddress; + + // The peer address is external, so we return it immediately. + return Peer.Address; + } + } + /// + /// The UDP port that the game server is broadcasting on. + /// + public ushort Port + { + get { return _registrationRequest.Port; } + } + /// + /// A symbol indicating the region of the server. + /// + public long RegionSymbol + { + get { return _registrationRequest.RegionSymbol; } + } + /// + /// The version of the server, prevents mismatches in matching. + /// + public long VersionLock + { + get { return _registrationRequest.VersionLock; } + } + + /// + /// The current session identifier (null if a session has not been started). + /// + public Guid? SessionId { get; private set; } + /// + /// The type of lobby (visibility) to clients matching. + /// e.g. Public, Private. + /// + public ERGameServerStartSession.LobbyType SessionLobbyType { get; set; } + /// + /// A symbol representing the gametype of the current session. + /// + public long? SessionGameTypeSymbol { get; private set; } + /// + /// A symbol representing the level of the current session. + /// + public long? SessionLevelSymbol { get; private set; } + /// + /// The channel used for filtering this game server from others serving other client channels. + /// e.g. PLAYGROUND, ECHO COMBAT PLAYERS, etc. + /// + public Guid? SessionChannel { get; private set; } + /// + /// The total amount of players allowed in the session. + /// Note: One slot may be reserved for the server itself, as the default is "16", + /// but the game usually only allows 15 players (3 teams x 5 players each). + /// + public byte SessionPlayerLimit { get; private set; } + /// + /// Indicates whether the server is currently in a locked state. + /// + public bool SessionLocked { get; private set; } + /// + /// Indicates whether a session has been started. + /// + public bool SessionStarted + { + get + { + return SessionId != null; + } + } + + /// + /// The current amount of players in the server. + /// + public byte SessionPlayerCount + { + get { return (byte)_playerSessions.Count; } + } + + /// + /// Represents the active player sessions in the server. + /// + private Dictionary _playerSessions; + + /// + /// A lock used for asynchronous/awaitable concurrent access to this object. + /// + public AsyncLock _accessLock; + #endregion + + #region Events + /// + /// Event for players being added to the current 's game session. + /// + /// The the players were added to. + /// The player sessions and connections for every player added. + public delegate void PlayersAddedEventHandler(RegisteredGameServer gameServer, (Guid playerSession, Peer? peer)[] players); + /// + /// Event for players being added to the current 's game session. + /// + public event PlayersAddedEventHandler? OnPlayersAdded; + + /// + /// Event for a player being removed from the current 's game session. + /// + /// The the players were removed from. + /// The player session and connection for the removed player. + public delegate void PlayerRemovedEventHandler(RegisteredGameServer gameServer, Guid playerSession, Peer? peer); + /// + /// Event for a player being removed from the current 's game session. + /// + public event PlayerRemovedEventHandler? OnPlayerRemoved; + + /// + /// Event for the 's session starting, ending, locking, or unlocking. + /// This does not fire when players are added or removed. + /// + /// The the session state changed for. + public delegate void SessionStateChanged(RegisteredGameServer gameServer); + /// + /// Event for the 's session starting, ending, locking, or unlocking. + /// This does not fire when players are added or removed. + /// + public event SessionStateChanged? OnSessionStateChanged; + #endregion + + #region Constructor + public RegisteredGameServer(GameServerRegistry registry, Peer peer, ERGameServerRegistrationRequest registrationRequest) + { + Registry = registry; + Peer = peer; + _registrationRequest = registrationRequest; + SessionLobbyType = ERGameServerStartSession.LobbyType.Unassigned; + SessionPlayerLimit = 16; + _playerSessions = new Dictionary(); + _accessLock = new AsyncLock(); + } + #endregion + + #region Functions + public async Task StartSession(ERGameServerStartSession.LobbyType lobbyType, Guid channel, long? gameTypeSymbol, long? levelSymbol, ERGameServerStartSession.SessionSettings? settings, byte playerLimit = 16) + { + // Lock throughout this method. + await _accessLock.ExecuteLocked(async () => + { + // Start the session. + await StartSessionInternal(lobbyType, channel, gameTypeSymbol, levelSymbol, settings, playerLimit); + }); + + // Invoke the session state change event. + OnSessionStateChanged?.Invoke(this); + } + private async Task StartSessionInternal(ERGameServerStartSession.LobbyType lobbyType, Guid channel, long? gameTypeSymbol, long? levelSymbol, ERGameServerStartSession.SessionSettings? settings, byte playerLimit = 16) + { + // Remove the previous session id from the parent registry's lookup. + if (SessionId != null) + Registry.RegisteredGameServersBySessionId.Remove(SessionId.Value, out _); + + // Set up our session variables + SessionId = SecureGuidGenerator.Generate(); + SessionLobbyType = lobbyType; + SessionChannel = channel; + SessionGameTypeSymbol = gameTypeSymbol; + SessionLevelSymbol = levelSymbol; + SessionPlayerLimit = playerLimit; + _playerSessions.Clear(); + SessionLocked = false; + + // Merge session settings information and send a "start session" message to the game server. + var mergedSessionSettings = new ERGameServerStartSession.SessionSettings( + appId: settings?.AppId ?? "1369078409873402", + gametype: SessionGameTypeSymbol, + level: SessionLevelSymbol, + additionalData: settings?.AdditionalData + ); + + // Send the start session message to the game server. + await Peer.Send(new ERGameServerStartSession(SessionId.Value, SessionChannel.Value, SessionPlayerLimit, SessionLobbyType, mergedSessionSettings)); + + // Add the new session id to the parent registry's lookup. + Registry.RegisteredGameServersBySessionId[SessionId.Value] = this; + } + public async Task ProcessLobbySessionRequest(Peer matchingPeer, byte playerLimit = 16) + { + // Obtain the peer's matching session data. + MatchingSession? matchingSession = matchingPeer.GetSessionData(); + if (matchingSession == null) + return; + + // Set the matched game server to this one. + matchingSession.MatchedGameServer = this; + + // Lock throughout this method. + bool newSessionStarted = false; + await _accessLock.ExecuteLocked(async () => + { + // If the server hasn't started a session, direct it to do so now. + if (!SessionStarted) + { + // Start a new session on the game server. + await StartSessionInternal( + matchingSession.NewSessionLobbyType, + matchingSession.Channel ?? new Guid(), + matchingSession.GameTypeSymbol ?? matchingSession.SessionSettings?.GameType, + matchingSession.LevelSymbol ?? matchingSession.SessionSettings?.Level, + matchingSession.SessionSettings, + playerLimit + ); + + newSessionStarted = true; + } + + // Set the session id from our game server in our matching session + matchingSession.MatchedSessionId = SessionId; + + // Create our packet encoding settings + PacketEncoderSettings serverEncoderSettings = new PacketEncoderSettings( + encryptionEnabled: true, + macEnabled: true, + macDigestSize: 32, + macPBKDF2IterationCounter: 0, + macKeySize: 32, + encryptionKeySize: 32, + randomKeySize: 32 + ); + PacketEncoderSettings clientEncoderSettings = new PacketEncoderSettings( + encryptionEnabled: true, + macEnabled: true, + macDigestSize: 64, + macPBKDF2IterationCounter: 0, + macKeySize: 32, + encryptionKeySize: 32, + randomKeySize: 32 + ); + + // Create our success messages with our game server and client packet encryption parameters. + // These are typical parameters you'd see configured in normal gameplay. + LobbySessionSuccessv5 sessionSuccessv5 = new LobbySessionSuccessv5( + gameTypeSymbol: SessionGameTypeSymbol ?? -1, + matchingSession: matchingSession.MatchedSessionId!.Value, + channelUUID: SessionChannel ?? new Guid(), + endpoint: new LobbyPingRequestv3.EndpointData(InternalAddress, ExternalAddress, Port), + teamIndex: (short)matchingSession.TeamIndex, + unk1: 0, + serverEncoderFlags: (ulong)serverEncoderSettings, + clientEncoderFlags: (ulong)clientEncoderSettings, + serverSequenceId: BitConverter.ToUInt64(RandomNumberGenerator.GetBytes(8)), + serverMacKey: RandomNumberGenerator.GetBytes(serverEncoderSettings.MacKeySize), + serverEncKey: RandomNumberGenerator.GetBytes(serverEncoderSettings.EncryptionKeySize), + serverRandomKey: RandomNumberGenerator.GetBytes(serverEncoderSettings.RandomKeySize), + clientSequenceId: BitConverter.ToUInt64(RandomNumberGenerator.GetBytes(8)), + clientMacKey: RandomNumberGenerator.GetBytes(clientEncoderSettings.MacKeySize), + clientEncKey: RandomNumberGenerator.GetBytes(clientEncoderSettings.EncryptionKeySize), + clientRandomKey: RandomNumberGenerator.GetBytes(clientEncoderSettings.RandomKeySize) + ); + LobbySessionSuccessv4 sessionSuccessv4 = new LobbySessionSuccessv4( + sessionSuccessv5.GameTypeSymbol, + sessionSuccessv5.MatchingSession, + sessionSuccessv5.Endpoint, + sessionSuccessv5.TeamIndex, + sessionSuccessv5.Unk1, + sessionSuccessv5.ServerEncoderFlags, + sessionSuccessv5.ClientEncoderFlags, + sessionSuccessv5.ServerSequenceId, + sessionSuccessv5.ServerMacKey, + sessionSuccessv5.ServerEncKey, + sessionSuccessv5.ServerRandomKey, + sessionSuccessv5.ClientSequenceId, + sessionSuccessv5.ClientMacKey, + sessionSuccessv5.ClientEncKey, + sessionSuccessv5.ClientRandomKey + ); + + // Send the success messages to the server (so it knows to expect a new connection with these packet encoder settings). + await Peer.Send(sessionSuccessv4); + await Peer.Send(sessionSuccessv5); + + // Send the success messages to the peer (so it can connect). + await matchingPeer.Send(sessionSuccessv4); + await matchingPeer.Send(sessionSuccessv5); + }); + + // If a new session was started, fire the relevant event handlers. + if(newSessionStarted) + OnSessionStateChanged?.Invoke(this); + } + + public async Task ProcessPlayerSessionRequest(Peer matchingPeer, XPlatformId userId, Guid channel) + { + // Lock throughout this method. + await _accessLock.ExecuteLocked(async () => + { + // Obtain the peer's matching session data and ensure they have a session id set. + MatchingSession? matchingSession = matchingPeer.GetSessionData(); + if (matchingSession == null || matchingSession.MatchedSessionId == null) + return; + + // Use a cryptographically secure RNG to generate the player sessions. + Guid[] playerSessions = new Guid[] { SecureGuidGenerator.Generate() }; + + // Try to send the player sessions to the server and add them to our pending lookup if it succeeds without exception. + try + { + // Check the player count and decide whether to accept or deny the player session. + if ((ulong)playerSessions.Length + SessionPlayerCount > SessionPlayerLimit) + { + // Inform the game server we rejected player sessions. + await Peer.Send(new ERGameServerPlayersRejected(ERGameServerPlayersRejected.PlayerSessionError.LobbyFull, playerSessions)); + + // TODO: Send player sessions failure to the client. + } + else + { + // Send the player sessions to the player. + await matchingPeer.Send(new LobbyPlayerSessionsSuccessUnk1(matchingSession.MatchedSessionId.Value, playerSessions)); + await matchingPeer.Send(new LobbyPlayerSessionsSuccessv2(0xFF, matchingSession.UserId, playerSessions[0])); + await matchingPeer.Send(new LobbyPlayerSessionsSuccessv3(0xFF, matchingSession.UserId, playerSessions[0], (short)matchingSession.TeamIndex, 0, 0)); + + // Add the pending player session associated to this peer. + _playerSessions[playerSessions[0]] = matchingPeer; + } + + } + catch { } + }); + } + + /// + /// Sets the locked status on the game server, controlling whether new players can join or not. + /// + /// The locked status to set for the lobby/session. + public void SetLockedStatus(bool locked) + { + // Determine if the locked status will change + bool changed = SessionLocked != locked; + + // Set the locked status + SessionLocked = locked; + + // Fire the relevant event handler. + if (changed) + OnSessionStateChanged?.Invoke(this); + } + + public async Task GetPeer(Guid playerSession) + { + // Lock throughout this method and grab the peer for this session. + Peer? peer = null; + await _accessLock.ExecuteLocked(() => + { + _playerSessions.TryGetValue(playerSession, out peer); + return Task.CompletedTask; + }); + return peer; + } + + public async Task<(Guid PlayerSession, Peer? Peer)[]> GetPlayers() + { + // Lock throughout this method and grab the player sessions. + var playersInfo = Array.Empty<(Guid playerSession, Peer? peer)>(); + await _accessLock.ExecuteLocked(() => + { + playersInfo = _playerSessions.AsEnumerable().Select(x => (x.Key, (Peer?)x.Value)).ToArray(); + return Task.CompletedTask; + }); + return playersInfo; + } + + public async Task AddPlayers(Guid[] playerSessions) + { + // Lock throughout this method. + (Guid playerSession, Peer? peer)[] addedPlayersInfo = Array.Empty<(Guid, Peer?)>(); + await _accessLock.ExecuteLocked(async () => + { + // Obtain every added player session and the associated peer. + addedPlayersInfo = new (Guid playerSession, Peer? peer)[playerSessions.Length]; + + // Signal for the game server to accept the players. + await Peer.Send(new ERGameServerPlayersAccepted(playerSessions)); + + // Build the event data for all players added. + for (int i = 0; i < addedPlayersInfo.Length; i++) + { + var playerSession = playerSessions[i]; + _playerSessions.TryGetValue(playerSession, out Peer? peer); + addedPlayersInfo[i] = (playerSessions[i], peer); + } + }); + + // Fire the event for players being added + if(addedPlayersInfo.Length > 0) + OnPlayersAdded?.Invoke(this, addedPlayersInfo); + } + + public async Task KickPlayer(Guid playerSession) + { + // Inform the game server we rejected player sessions. + await Peer.Send(new ERGameServerPlayersRejected(ERGameServerPlayersRejected.PlayerSessionError.KickedFromServer, new Guid[] { playerSession })); + } + + public async Task RemovePlayer(Guid playerSession) + { + // Define the peer associated with this player session, which we will attempt to obtain to fire the removed event later. + Peer? peer = null; + + // Lock throughout this method. + await _accessLock.ExecuteLocked(() => + { + // Try to get the existing peer for this player session. + _playerSessions.TryGetValue(playerSession, out peer); + + // Remove this player session from our lookup if it exists. + _playerSessions.Remove(playerSession); + + // If we hit 0 players, expect end of session, set server as not ready to match. + if (SessionPlayerCount == 0) + SessionLocked = true; + + return Task.CompletedTask; + }); + + // Fire the event for a player being removed + OnPlayerRemoved?.Invoke(this, playerSession, peer); + } + + public async Task EndSession() + { + // Lock throughout this method. + await _accessLock.ExecuteLocked(() => { + // Reset all variables + SessionId = null; + SessionLobbyType = ERGameServerStartSession.LobbyType.Unassigned; + SessionChannel = null; + SessionGameTypeSymbol = null; + SessionLevelSymbol = null; + SessionLocked = false; + SessionPlayerLimit = 16; + _playerSessions.Clear(); + + return Task.CompletedTask; + }); + + // Fire the event for the session ending. + OnSessionStateChanged?.Invoke(this); + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Services/ServerDB/ServerDBService.cs b/EchoRelay.Core/Server/Services/ServerDB/ServerDBService.cs new file mode 100644 index 0000000..c8ba0a1 --- /dev/null +++ b/EchoRelay.Core/Server/Services/ServerDB/ServerDBService.cs @@ -0,0 +1,196 @@ +using EchoRelay.Core.Server.Messages; +using EchoRelay.Core.Server.Messages.Common; +using EchoRelay.Core.Server.Messages.ServerDB; +using System.Collections.Specialized; +using System.Web; + +namespace EchoRelay.Core.Server.Services.ServerDB +{ + public class ServerDBService : Service + { + public GameServerRegistry Registry { get; } + public ServerDBService(Server server) : base(server, "SERVERDB") + { + Registry = new GameServerRegistry(); + OnPeerDisconnected += ServerDBService_OnPeerDisconnected; + } + + private void ServerDBService_OnPeerDisconnected(Service service, Peer peer) + { + ClearPeerRegistration(peer); + } + + private void ClearPeerRegistration(Peer peer) + { + // If this peer registered a game server, remove it from the registry. + RegisteredGameServer? registeredGameServer = peer.GetSessionData(); + if (registeredGameServer != null) + { + Registry.RemoveGameServer(registeredGameServer); + } + + // Clear any session data. + peer.ClearSessionData(); + } + + /// + /// Handles a packet being received by a peer. + /// This is called after all events have been fired for . + /// + /// The peer which sent the packet. + /// The packet sent by the peer. + protected override async Task HandlePacket(Peer sender, Packet packet) + { + // Loop for each message received in the packet + foreach (Message message in packet) + { + switch (message) + { + case ERGameServerRegistrationRequest registrationRequest: + await ProcessLobbyRegistrationRequest(sender, registrationRequest); + break; + case ERGameServerSessionStarted sessionStarted: + await ProcessSessionStarted(sender, sessionStarted); + break; + case ERGameServerEndSession endSession: + await ProcessEndSession(sender, endSession); + break; + case ERGameServerPlayerSessionsLocked sessionLocked: + await ProcessPlayerSessionsLocked(sender, sessionLocked); + break; + case ERGameServerPlayerSessionsUnlocked sessionUnlocked: + await ProcessPlayerSessionsUnlocked(sender, sessionUnlocked); + break; + case ERGameServerAcceptPlayers acceptPlayers: + await ProcessAcceptPlayers(sender, acceptPlayers); + break; + case ERGameServerRemovePlayer removePlayer: + await ProcessRemovePlayer(sender, removePlayer); + break; + + } + } + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessLobbyRegistrationRequest(Peer sender, ERGameServerRegistrationRequest request) + { + // Clear a previous registration if it exists. + ClearPeerRegistration(sender); + + // Obtain our service query parameters, so we can check the API key. + NameValueCollection queryStrings = HttpUtility.ParseQueryString(sender.RequestUri.Query); + string? apiKey = queryStrings.Get("api_key"); + + + // Validate the API key if we enforce one. + if (Server.Settings.ServerDBApiKey != null && apiKey != Server.Settings.ServerDBApiKey) + { + await sender.Send(new LobbyRegistrationFailure(LobbyRegistrationFailure.FailureCode.DatabaseError)); + return; + } + + // Register the game server and update our session data with it. + RegisteredGameServer registeredGameServer = Registry.RegisterGameServer(sender, request); + sender.SetSessionData(registeredGameServer); + + // Send our registration success message. + await sender.Send(new LobbyRegistrationSuccess(request.ServerId, sender.Address)); + await sender.Send(new TcpConnectionUnrequireEvent()); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessSessionStarted(Peer sender, ERGameServerSessionStarted request) + { + // This is here if we need it, but we assume the session started when a session start message is sent. + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessEndSession(Peer sender, ERGameServerEndSession request) + { + // Obtain the registered game server + RegisteredGameServer? registeredGameServer = sender.GetSessionData(); + if (registeredGameServer == null) + return; + + // Update the session started status. + await registeredGameServer.EndSession(); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessPlayerSessionsLocked(Peer sender, ERGameServerPlayerSessionsLocked request) + { + // Obtain the registered game server + RegisteredGameServer? registeredGameServer = sender.GetSessionData(); + if (registeredGameServer == null) + return; + + // Update the locked status. + registeredGameServer.SetLockedStatus(true); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessPlayerSessionsUnlocked(Peer sender, ERGameServerPlayerSessionsUnlocked request) + { + // Obtain the registered game server + RegisteredGameServer? registeredGameServer = sender.GetSessionData(); + if (registeredGameServer == null) + return; + + // Update the locked status. + registeredGameServer.SetLockedStatus(false); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessAcceptPlayers(Peer sender, ERGameServerAcceptPlayers request) + { + // Obtain the registered game server + RegisteredGameServer? registeredGameServer = sender.GetSessionData(); + if (registeredGameServer == null) + return; + + // Add the provided player sessions to the associated game server. + await registeredGameServer.AddPlayers(request.PlayerSessions); + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessRemovePlayer(Peer sender, ERGameServerRemovePlayer request) + { + // Obtain the registered game server + RegisteredGameServer? registeredGameServer = sender.GetSessionData(); + if (registeredGameServer == null) + return; + + // Remove the provided player session from the associated game server. + await registeredGameServer.RemovePlayer(request.PlayerSession); + } + } +} diff --git a/EchoRelay.Core/Server/Services/Service.cs b/EchoRelay.Core/Server/Services/Service.cs new file mode 100644 index 0000000..9d330c2 --- /dev/null +++ b/EchoRelay.Core/Server/Services/Service.cs @@ -0,0 +1,229 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Messages; +using EchoRelay.Core.Server.Storage; +using EchoRelay.Core.Server.Storage.Resources; +using System.Collections.Concurrent; +using System.Net; +using System.Net.WebSockets; + +namespace EchoRelay.Core.Server.Services +{ + /// + /// An Echo VR websocket service that handles client connections. + /// + public abstract class Service + { + #region Properties + /// + /// The name of the service. + /// + public string Name { get; } + + /// + /// The which the service is operating on. + /// + public Server Server { get; } + /// + /// The persistent storage layer for the parent . + /// + public ServerStorage Storage + { + get + { + return Server.Storage; + } + } + /// + /// The 's , used to resolve symbols for various objects. + /// + public SymbolCache SymbolCache + { + get + { + return Server.SymbolCache; + } + } + + /// + /// A list of connected peers in the service. + /// + public List Peers { get; } + /// + /// A lock used to access or update , to avoid concurrent access issues. + /// + public readonly object PeersLock = new object(); + /// + /// A lookup of peer endpoints (ip address, port) -> peer. + /// + public ConcurrentDictionary<(IPAddress,int), Peer> PeersByEndpoint { get; } + #endregion + + #region Events + /// + /// Event for a connecting to a . + /// + /// The the connected to. + /// The which connected. + public delegate void PeerConnectedEventHandler(Service service, Peer peer); + /// + /// Event for a connecting to a . + /// + public event PeerConnectedEventHandler? OnPeerConnected; + + /// + /// Event for a disconnecting from a . + /// + /// The the disconnected from. + /// The which disconnected. + public delegate void PeerDisconnectedEventHandler(Service service, Peer peer); + /// + /// Event for a disconnecting from a . + /// + public event PeerDisconnectedEventHandler? OnPeerDisconnected; + + /// + /// Event for a authenticating within a , with a given . + /// + public event Peer.AuthenticatedEventHandler? OnPeerAuthenticated; + + /// + /// Event for a packet being received from a connected to the . + /// + public event Peer.PacketReceivedEventHandler? OnPacketReceived; + + /// + /// Event for a packet being sent from a connected to the . + /// + public event Peer.PacketSentEventHandler? OnPacketSent; + #endregion + + #region Constructor + /// + /// Initializes the new with the provided arguments. + /// + /// The name of the service. + public Service(Server server, string name) + { + // Set the provided arguments. + Server = server; + Name = name; + Peers = new List(); + PeersByEndpoint = new ConcurrentDictionary<(IPAddress, int), Peer>(); + } + #endregion + + #region Functions + /// + /// Enters a communication loop with the accepted websocket connection. + /// + /// The websocket to process messages for. + internal async Task HandleConnection(HttpListenerContext context, WebSocket webSocket) + { + // Create a new peer from this connection and add it to our list of peers. + Peer peer = new Peer(Server, this, context, webSocket); + var endpoint = (peer.Address, peer.Port); + lock (PeersLock) + { + Peers.Add(peer); + PeersByEndpoint[endpoint] = peer; + } + + // Fire the peer connected event + OnPeerConnected?.Invoke(this, peer); + + // Subscribe to peer event handlers to forward events. + peer.OnPeerAuthenticated += Peer_OnPeerAuthenticated; + peer.OnPacketSent += Peer_OnPacketSent; + peer.OnPacketReceived += Peer_OnPacketReceived; + + // Create a buffer to receive our packet data in. + byte[] receiveBuffer = new byte[Packet.MAX_SIZE]; + + // While the connection is open, continuously try to receive messages. + try + { + while (webSocket.State == WebSocketState.Open) + { + // Receive data from the web socket until we hit our end of message. + Memory receiveBufferAtPosition = receiveBuffer; + int totalSize = 0; + WebSocketMessageType messageType = WebSocketMessageType.Close; + while(true) + { + var receiveResult = await webSocket.ReceiveAsync(receiveBufferAtPosition, CancellationToken.None); + messageType = receiveResult.MessageType; + receiveBufferAtPosition = receiveBufferAtPosition.Slice(receiveResult.Count); + totalSize += receiveResult.Count; + if (receiveResult.EndOfMessage) + break; + } + + // Obtain the packet buffer without the trailing unused space. + byte[] packetBuffer = receiveBuffer.Take(totalSize).ToArray(); + switch (messageType) + { + case WebSocketMessageType.Binary: + // Parse a packet out of this message. + Packet packet = Packet.Decode(packetBuffer); + + // Fire the packet received event for this service. + peer.InvokeReceiveEventHandler(packet);; + + // Handle the packet + await HandlePacket(peer, packet); + break; + case WebSocketMessageType.Close: + // Close the connection gracefully. + await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "", CancellationToken.None); + return; + default: + // Close the connection with an invalid message type status. + await webSocket.CloseAsync(WebSocketCloseStatus.InvalidMessageType, "", CancellationToken.None); + throw new WebSocketException("Received an unexpected websocket message type"); + } + } + } + finally + { + // Whether we exit gracefully or encounter an exception, remove the peer from our list of peers. + lock (PeersLock) + { + Peers.Remove(peer); + PeersByEndpoint.Remove(endpoint, out _); + } + + // Fire the peer disconnected event + OnPeerDisconnected?.Invoke(this, peer); + } + } + + /// + /// Handles a packet being received by a peer. + /// This is called after all event handlers have been fired for . + /// + /// The peer which sent the packet. + /// The packet sent by the peer. + protected abstract Task HandlePacket(Peer sender, Packet packet); + #endregion + + #region Event Handlers + private void Peer_OnPacketSent(Service service, Peer peer, Packet packet) + { + // Fire the sent event for the service. + OnPacketSent?.Invoke(service, peer, packet); + } + + private void Peer_OnPeerAuthenticated(Service service, Peer peer, XPlatformId userId) + { + // Fire the sent event for the service. + OnPeerAuthenticated?.Invoke(service, peer, userId); + } + + private void Peer_OnPacketReceived(Service service, Peer peer, Packet packet) + { + // Fire the sent event for the service. + OnPacketReceived?.Invoke(service, peer, packet); + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Services/Transaction/TransactionService.cs b/EchoRelay.Core/Server/Services/Transaction/TransactionService.cs new file mode 100644 index 0000000..db1438b --- /dev/null +++ b/EchoRelay.Core/Server/Services/Transaction/TransactionService.cs @@ -0,0 +1,47 @@ +using EchoRelay.Core.Server.Messages; +using EchoRelay.Core.Server.Messages.Transaction; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Services.Transaction +{ + public class TransactionService : Service + { + public TransactionService(Server server) : base(server, "TRANSACTION") + { + } + + /// + /// Handles a packet being received by a peer. + /// This is called after all event handlers have been fired for . + /// + /// The peer which sent the packet. + /// The packet sent by the peer. + protected override async Task HandlePacket(Peer sender, Packet packet) + { + // Loop for each message received in the packet + foreach (Message message in packet) + { + switch (message) + { + case ReconcileIAP reconcileIAP: + await ProcessReconcileIAPRequest(sender, reconcileIAP); + break; + default: + break; + } + } + } + + /// + /// Processes a . + /// + /// The sender of the request. + /// The request contents. + private async Task ProcessReconcileIAPRequest(Peer sender, ReconcileIAP request) + { + // Respond to every request with some kind of result response. + await sender.Send(new ReconcileIAPResult(request.UserId, JsonConvert.DeserializeObject("{'balance': {'currency': {'echopoints': {'val': 0}}}, 'transactionid': 1}")!)); + } + } +} diff --git a/EchoRelay.Core/Server/Storage/Filesystem/FilesystemResourceProviders.cs b/EchoRelay.Core/Server/Storage/Filesystem/FilesystemResourceProviders.cs new file mode 100644 index 0000000..d139ca1 --- /dev/null +++ b/EchoRelay.Core/Server/Storage/Filesystem/FilesystemResourceProviders.cs @@ -0,0 +1,208 @@ +using EchoRelay.Core.Server.Storage.Types; +using EchoRelay.Core.Utils; +using Newtonsoft.Json; +using System.Collections.Concurrent; + +namespace EchoRelay.Core.Server.Storage.Filesystem +{ + /// + /// A filesystem which storages a singular resource. + /// + /// The type of key which is used to index the resource. + /// The type of resources which should be managed by this provider. + internal class FilesystemResourceProvider : ResourceProvider + { + /// + /// The filesystem path for the . + /// + public string FilePath { get; set; } + + /// + /// A cached copy of the resource populated when the item is read from disk or written to disk. + /// + private V? _resource; + + public FilesystemResourceProvider(ServerStorage storage, string filePath) : base(storage) + { + FilePath = filePath; + } + + + protected override void OpenInternal() + { + } + + protected override void CloseInternal() + { + // Clear cache + _resource = default; + } + + public override bool Exists() + { + // Check if the resource is loaded or if the file exists. + return _resource != null || File.Exists(FilePath); + } + + protected override V? GetInternal() + { + // Cache the resource from file if it hasn't been already, then return it. + if (_resource == null) + { + string resourceJson = File.ReadAllText(FilePath); + _resource = JsonConvert.DeserializeObject(resourceJson); + } + return _resource; + } + protected override void SetInternal(V resource) + { + // Update the cached resource + _resource = resource; + + // Serialize the resource and write to disk. + string resourceJson = JsonConvert.SerializeObject(_resource, Formatting.Indented, StreamIO.JsonSerializerSettings); + File.WriteAllText(FilePath, resourceJson); + } + protected override V? DeleteInternal() + { + // Store a reference to our cached resource + V? resource = _resource; + + // Clear the cached resource. + _resource = default; + + // Return the removed resource, if any. + return resource; + } + } + + /// + /// A filesystem which storages a given type of keyed resource in a collection. + /// + /// The type of key which is used to index the resource. + /// The type of resources which should be managed by this provider. + internal class FilesystemResourceCollectionProvider : ResourceCollectionProvider + where K : notnull + where V : IKeyedResource + { + /// + /// The directory containing the resources. + /// + public string ResourceDirectory { get; set; } + + private string _listFilesPattern; + private Func _relativeFilePathSelectorFunc; + + + private ConcurrentDictionary _resources; + private LRUFileCache _cachedFilesystem; + + private object _lookupsChangeLock = new object(); + + public FilesystemResourceCollectionProvider(ServerStorage storage, string resourceDirectory, string listFilesPattern, Func relativeFilePathSelectorFunc, int cacheSize = 0) : base(storage) + { + ResourceDirectory = resourceDirectory; + _resources = new ConcurrentDictionary(); + _cachedFilesystem = new LRUFileCache(cacheSize); + _listFilesPattern = listFilesPattern; + _relativeFilePathSelectorFunc = relativeFilePathSelectorFunc; + } + + + protected override void OpenInternal() + { + // If the directory doesn't exist, exit + if (!Directory.Exists(ResourceDirectory)) + return; + + // Create a set of verified keys/paths, to ensure we don't have duplicate keys. + Dictionary verifiedPaths = new Dictionary(); + + // Enumerate all config files and read them in. + string[] filePaths = Directory.GetFiles(ResourceDirectory, _listFilesPattern); + foreach (string filePath in filePaths) + { + try + { + // Load the config resource. + V? resource = _cachedFilesystem.Read(filePath); + + // Verify the resource. + if (resource == null) + throw new FileNotFoundException(); + + // Obtain the key for the resource + K key = resource.Key(); + if (verifiedPaths.ContainsKey(key)) + throw new InvalidDataException($"Resource definition collides with file '{verifiedPaths[key]}'"); + + // Verify the filename of the file is as expected, they should all be deterministic. + // If they are not, we move them for the user. + string normalizedFilePath = PathUtils.NormalizedPath(filePath); + string normalizedExpectedPath = PathUtils.NormalizedPath(Path.Join(ResourceDirectory, _relativeFilePathSelectorFunc(key))); + if (normalizedFilePath != normalizedExpectedPath) + { + File.Move(normalizedFilePath, normalizedExpectedPath); + } + + // Add it to our lookups + _resources[key] = (normalizedExpectedPath, resource); + + // Add it to our verified keys/paths to ensure we don't have later collisions. + verifiedPaths[key] = normalizedExpectedPath; + } + catch (Exception ex) + { + Close(); + throw new Exception($"Could not load resource {typeof(V).Name}: '{filePath}'", ex); + } + } + } + + protected override void CloseInternal() + { + // Clear cache + _resources.Clear(); + _cachedFilesystem.Clear(); + } + + public override K[] Keys() + { + // Return all the keys as an array. + return _resources.Keys.ToArray(); + } + public override bool Exists(K key) + { + // Check if we indexed the provided key. + return _resources.ContainsKey(key); + } + + protected override V? GetInternal(K key) + { + // Try to obtain the file path from the key. If we can't, return null/default. + if (!_resources.TryGetValue(key, out var resourceInfo)) + { + return default; + } + + // The item from cached filesystem. + return _cachedFilesystem.Read(resourceInfo.Path); + } + protected override void SetInternal(K key, V resource) + { + // Obtain the file path for this key. + string filePath = PathUtils.NormalizedPath(Path.Join(ResourceDirectory, _relativeFilePathSelectorFunc(key))); + + // Set it in our resource lookup and cache the contents. + _resources[key] = (filePath, resource); + _cachedFilesystem.Write(filePath, resource); + } + protected override V? DeleteInternal(K key) + { + // Remove the item. + _cachedFilesystem.Delete(_resources[key].Path); + _resources.Remove(key, out var removed); + return removed.Resource; + } + } +} diff --git a/EchoRelay.Core/Server/Storage/Filesystem/FilesystemServerStorage.cs b/EchoRelay.Core/Server/Storage/Filesystem/FilesystemServerStorage.cs new file mode 100644 index 0000000..4924e30 --- /dev/null +++ b/EchoRelay.Core/Server/Storage/Filesystem/FilesystemServerStorage.cs @@ -0,0 +1,63 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Storage.Resources; +using EchoRelay.Core.Server.Storage.Types; + +namespace EchoRelay.Core.Server.Storage.Filesystem +{ + public class FilesystemServerStorage : ServerStorage + { + /// + /// The root directory to be used for file system server storage. + /// + public string RootDirectory { get; } + + public override ResourceProvider AccessControlList => _accessControlList; + private FilesystemResourceProvider _accessControlList; + + public override ResourceCollectionProvider Accounts => _accounts; + private FilesystemResourceCollectionProvider _accounts; + + public override ResourceProvider ChannelInfo => _channelInfo; + private FilesystemResourceProvider _channelInfo; + + public override ResourceCollectionProvider<(string type, string identifier), ConfigResource> Configs => _configs; + private FilesystemResourceCollectionProvider<(string type, string identifier), ConfigResource> _configs; + + public override ResourceCollectionProvider<(string type, string language), DocumentResource> Documents => _documents; + private FilesystemResourceCollectionProvider<(string type, string language), DocumentResource> _documents; + + public override ResourceProvider LoginSettings => _loginSettings; + private FilesystemResourceProvider _loginSettings; + + public override ResourceProvider SymbolCache => _symbolCache; + private FilesystemResourceProvider _symbolCache; + + + private readonly object _symbolCacheLock = new object(); + + public FilesystemServerStorage(string rootDirectory) + { + // Verify our root directory exists + Directory.CreateDirectory(rootDirectory); + + // Set our properties + RootDirectory = rootDirectory; + var accountsDirectory = Path.Join(RootDirectory, "accounts"); + var configResourceDirectory = Path.Join(RootDirectory, "configs"); + var documentResourceDirectory = Path.Join(RootDirectory, "documents"); + var accessControlListFilePath = Path.Join(RootDirectory, "access_control_list.json"); + var channelInfoFilePath = Path.Join(RootDirectory, "channel_info.json"); + var loginSettingsFilePath = Path.Join(RootDirectory, "login_settings.json"); + var symbolCacheFilePath = Path.Join(RootDirectory, "symbols.json"); + + // Create our resource containers + _accessControlList = new FilesystemResourceProvider(this, accessControlListFilePath); + _accounts = new FilesystemResourceCollectionProvider(this, accountsDirectory, "*.json", x => $"{x}.json", 0x100); + _channelInfo = new FilesystemResourceProvider(this, channelInfoFilePath); + _configs = new FilesystemResourceCollectionProvider<(string Type, string Identifier), ConfigResource>(this, configResourceDirectory, "*.json", x => $"{x.Identifier}.json", 0x100); + _documents = new FilesystemResourceCollectionProvider<(string Type, string Language), DocumentResource>(this, documentResourceDirectory, "*.json", x => $"{x.Type}_{x.Language}.json", 0x100); + _loginSettings = new FilesystemResourceProvider(this, loginSettingsFilePath); + _symbolCache = new FilesystemResourceProvider(this, symbolCacheFilePath); + } + } +} diff --git a/EchoRelay.Core/Server/Storage/Filesystem/LRUFileCache.cs b/EchoRelay.Core/Server/Storage/Filesystem/LRUFileCache.cs new file mode 100644 index 0000000..cbbb44e --- /dev/null +++ b/EchoRelay.Core/Server/Storage/Filesystem/LRUFileCache.cs @@ -0,0 +1,297 @@ +using EchoRelay.Core.Utils; +using Newtonsoft.Json; + +namespace EchoRelay.Core.Server.Storage.Filesystem +{ + /// + /// A filesystem operation read/write/delete cache for JSON objects compatible with . + /// It caches items to minimize reads from disk, and caches items for some time to minimize the amount of subsequent writes to the same file. + /// + /// The type of JSON objects the file cache will when reading/writing. + internal class LRUFileCache + { + #region Fields + private int _capacity; + private Dictionary _cache; + private LinkedList _accessLRU; + private LinkedList _writeLRU; + private TimeSpan _flushDelay; + private object _cacheLock = new object(); + #endregion + + #region Constructor + public LRUFileCache(int capacity, TimeSpan? flushDelay = null) + { + _capacity = capacity; + _cache = new Dictionary(); + _accessLRU = new LinkedList(); + _writeLRU = new LinkedList(); + _flushDelay = flushDelay ?? TimeSpan.Zero; + } + #endregion + + #region Functions + /// + /// Checks if a given file exists at the path, factoring in changes made in the cache. + /// + /// The file path to check for existence. + /// Returns a boolean indicating if the file currently exists (considering in changes in cache). + public bool Exists(string path) + { + // Normalize the path + path = PathUtils.NormalizedPath(path); + + lock (_cacheLock) + { + // Determine if the path is in our cache or we can find the file. + return _cache.ContainsKey(path) || File.Exists(path); + } + } + /// + /// Obtains the contents of a file at a given path, factoring in changes from the cache. + /// + /// The file path to obtain contents for. + /// Returns the file contents, factoring in changes from the cache. + public T? Read(string path) + { + // Normalize the path + path = PathUtils.NormalizedPath(path); + + lock (_cacheLock) + { + // If the file is in our cache, move it to the end of LRU (most recently used now) and return it. + if (_cache.TryGetValue(path, out var cachedFile) && cachedFile != null) + { + _accessLRU.Remove(cachedFile.AccessLRUNode!); + _accessLRU.AddLast(cachedFile.AccessLRUNode!); + return cachedFile.Content; + } + + // Read the content and deserialize it + T? content = JsonConvert.DeserializeObject(File.ReadAllText(path)); + + // If we got a result, cache it. + if (content != null && _capacity > 0) + { + // If we are at capacity for our cache, remove an item. + if (_cache.Count >= _capacity) + { + RemoveItem(_accessLRU.First!.Value); + } + + // Add a new cache item to our list. + cachedFile = new LRUFileCacheItem(path, content); + cachedFile.AccessLRUNode = new LinkedListNode(cachedFile); + _accessLRU.AddLast(cachedFile.AccessLRUNode); + _cache[path] = cachedFile; + } + return content; + } + } + /// + /// Updates the contents of a file at a given path. If the cache capacity is 0, the file is flushed immediately. + /// Otherwise, the write changes are cached, and will later be flushed by or . + /// + /// The path of the file to update contents for. + /// The file contents to update with. + public void Write(string path, T? content) + { + // Normalize the path + path = PathUtils.NormalizedPath(path); + + lock (_cacheLock) + { + // If we are using a cache, add the item to it to be written at a later time. + if (_capacity > 0) + { + // If the item is in our cache, we update it, otherwise, we create a new cache item. + if (_cache.TryGetValue(path, out var cachedFile)) + { + // Update the existing item + cachedFile.Content = content; + _accessLRU.Remove(cachedFile.AccessLRUNode!); + _accessLRU.AddLast(cachedFile.AccessLRUNode!); + if (cachedFile.ChangedTime == null) + cachedFile.ChangedTime = DateTime.UtcNow; + } + else + { + // If we are at capacity for our cache, remove an item. + if (_cache.Count >= _capacity) + { + RemoveItem(_accessLRU.First!.Value); + } + + // Add a new cache item to our list. + cachedFile = new LRUFileCacheItem(path, content, DateTime.UtcNow); + cachedFile.AccessLRUNode = new LinkedListNode(cachedFile); + _accessLRU.AddLast(cachedFile.AccessLRUNode); + _cache[path] = cachedFile; + } + + // If we didn't attach this to our write LRU yet, do so now. + if (cachedFile.WriteLRUNode == null) + { + cachedFile.WriteLRUNode = new LinkedListNode(cachedFile); + _writeLRU.AddLast(cachedFile.WriteLRUNode); + } + + // If the flush delay is zero, flush immediately. Otherwise, ensure we have a flush request already for this node. + if (_flushDelay == TimeSpan.Zero) + FlushItem(cachedFile); + } + else + { + // If we have file contents, write them. Otherwise, we perform a deletion operation. + if (content != null) + { + string serializedData = JsonConvert.SerializeObject(content, Formatting.Indented, StreamIO.JsonSerializerSettings); + File.WriteAllText(path, serializedData); + } else + { + File.Delete(path); + } + } + } + } + /// + /// Deletes a file at a given path. If the cache capacity is 0, the file is deleted immediately. + /// Otherwise, the delete changes are cached, and will later be flushed by or . + /// + /// + public void Delete(string path) + { + // Write default value (null), which signals to delete a file. + Write(path, default); + } + /// + /// Updates the cache, checking for any file flushes which should be made. + /// + public void Update() + { + // Loop through the write/flush LRU to determine if any write flushes are overdue. + while (_writeLRU.Count > 0) + { + // Obtain the first item. + var writeLRUNode = _writeLRU.First!; + var cacheItem = _writeLRU.First!.Value; + + // If it is somehow unchanged (not needing flushing, simply remove it from the write LRU cache). + if (cacheItem.ChangedTime == null) + { + cacheItem.WriteLRUNode = null; + _writeLRU.Remove(writeLRUNode); + continue; + } + + // If our oldest item to flush isn't old enough, we can stop now. + TimeSpan timeDiff = DateTime.UtcNow - (DateTime)cacheItem.ChangedTime; + if (timeDiff < _flushDelay) + return; + + // Otherwise we flush the item and move onto the next. + FlushItem(cacheItem); + } + } + /// + /// Flushes all pending write/delete operations to disk. + /// This retains the items within the cache for reads. + /// + public void Flush() + { + lock (_cacheLock) + { + while (_writeLRU.Count > 0) + { + FlushItem(_writeLRU.First()); + } + } + } + /// + /// Clears the entire cache after flushing all pending/write delete operations to disk. + /// + public void Clear() + { + lock (_cacheLock) + { + while (_accessLRU.Count > 0) + { + RemoveItem(_accessLRU.First()); + } + } + } + + /// + /// Removes an individual cache item from the . + /// + /// The cached item to remove. + private void RemoveItem(LRUFileCacheItem cachedFile) + { + // Ensure any changes are flushed to disk. + FlushItem(cachedFile); + + // Remove it from the cache + _accessLRU.Remove(cachedFile.AccessLRUNode!); + _cache.Remove(cachedFile.Path, out _); + } + + /// + /// Flushes any pending data to write to disk for an individual cache item. + /// + /// The cached item to flush changes to disk for. + private void FlushItem(LRUFileCacheItem cachedFile) + { + // If the file is unchanged from the filesystem, do nothing. + if (cachedFile.ChangedTime == null) + return; + + // If we have file contents, write them. Otherwise, we perform a deletion operation. + if (cachedFile.Content != null) + { + // Serialize the data + string serializedData = JsonConvert.SerializeObject(cachedFile.Content, Formatting.Indented, StreamIO.JsonSerializerSettings); + + // Create the folder if it doesn't exist, and write it. + string? parentDirectory = Path.GetDirectoryName(cachedFile.Path); + if (parentDirectory != null) + Directory.CreateDirectory(parentDirectory); + File.WriteAllText(cachedFile.Path, serializedData); + } else + { + File.Delete(cachedFile.Path); + } + + // Remove it from the LRU used to track write flushes. + if (cachedFile.WriteLRUNode != null) + _writeLRU.Remove(cachedFile.WriteLRUNode); + cachedFile.WriteLRUNode = null; + cachedFile.ChangedTime = null; + } + #endregion + + #region Classes + /// + /// An individual cached file item. + /// + private class LRUFileCacheItem + { + #region Fields + public string Path; + public T? Content; + public DateTime? ChangedTime; + public LinkedListNode? AccessLRUNode; + public LinkedListNode? WriteLRUNode; + #endregion + + #region Constructor + public LRUFileCacheItem(string path, T? content, DateTime? changedTime = null) + { + Path = path; + Content = content; + ChangedTime = changedTime; + } + #endregion + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Storage/InitialDeployment.cs b/EchoRelay.Core/Server/Storage/InitialDeployment.cs new file mode 100644 index 0000000..e6b717b --- /dev/null +++ b/EchoRelay.Core/Server/Storage/InitialDeployment.cs @@ -0,0 +1,211 @@ +using EchoRelay.Core.Server.Storage.Resources; +using EchoRelay.Core.Server.Storage.Types; +using EchoRelay.Core.Server.Storage.Types.DocumentTypes; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Storage +{ + public abstract class InitialDeployment + { + private static void DeployAccessControlList(ServerStorage storage) + { + // Update the access control list. + storage.AccessControlList.Set(new AccessControlListResource(allowRules: new string[] {"*"}, disallowRules: Array.Empty())); + } + private static void DeploySymbolCache(ServerStorage storage, string? gameDirectory = null) + { + // Obtain our symbol cache + SymbolCache symbolCache = new SymbolCache(); + + // Add standard config symbols + symbolCache.Add("main_menu", 1516004601999793531); + symbolCache.Add("active_battle_pass_season", 8740945458790516606); + symbolCache.Add("active_store_entry", 6474864185678376393); + symbolCache.Add("active_store_featured_entry", 6145481310444124465); + + // Add document symbols + symbolCache.Add("eula", -3980269165643165007); + + // Add level symbols + symbolCache.Add("mpl_lobby_b2", -3415139097788326908); + symbolCache.Add("mpl_tutorial_lobby", 4363271643694206015); + + symbolCache.Add("mpl_arena_a", 6300205991959903307); + symbolCache.Add("mpl_tutorial_arena", 4363271690485661735); + + symbolCache.Add("mpl_combat_combustion", 4784809810443202620); + symbolCache.Add("mpl_combat_dyson", 4891712358845785604); + symbolCache.Add("mpl_combat_fission", -2351820497221352492); + symbolCache.Add("mpl_combat_gauss", 4891712363006409241); + + // Add gametype symbols + symbolCache.Add("social_2.0", 301069346851901302); + symbolCache.Add("social_2.0_private", 3485062872400698437); + symbolCache.Add("social_2.0_npe", 1601406692177864215); + + symbolCache.Add("echo_arena", -3791849610740453517); + symbolCache.Add("echo_arena_private", 691594351282457603); + symbolCache.Add("echo_arena_tournament", -3081978974147786912); + symbolCache.Add("echo_arena_public_ai", -3076694376331427079); + symbolCache.Add("echo_arena_practice_ai", -8607855738967935905); + symbolCache.Add("echo_arena_private_ai", -2341211041644966243); + symbolCache.Add("echo_arena_first_match", -1545408622389224342); + symbolCache.Add("echo_arena_npe", -2840452043221058453); + + symbolCache.Add("echo_combat", 4421472114608583194); + symbolCache.Add("echo_combat_private", 3727844164146657855); + symbolCache.Add("echo_combat_tournament", 7729563559975407548); + symbolCache.Add("echo_combat_public_ai", 4832867265306071705); + symbolCache.Add("echo_combat_practice_ai", 2720675696233281171); + symbolCache.Add("echo_combat_private_ai", 7060564080080586305); + symbolCache.Add("echo_combat_first_match", 5171983837792427686); + + symbolCache.Add("echo_demo", 5603003217554343217); + symbolCache.Add("echo_demo_public", 3718950499098277919); + + // If a game directory was provided, extract symbols from it. + if (gameDirectory != null) + symbolCache.Add(gameDirectory, SearchOption.TopDirectoryOnly, false); + + // Update the symbol cache. + storage.SymbolCache.Set(symbolCache); + } + private static void DeployLoginSettings(ServerStorage storage) + { + // Create new login settings. The default values will be sufficient for most of this. + // We create a config data entry pointing to each of our config resources. + // The default values for an entry have a start/end range that will be sufficient for years. + LoginSettingsResource loginSettings = new LoginSettingsResource() + { + ConfigData = new LoginSettingsResource.LoginConfigSettings() + }; + + // Update our login settings in storage + storage.LoginSettings.Set(loginSettings); + } + private static void DeployChannelInfo(ServerStorage storage) + { + // Create a new channel info + ChannelInfoResource channelInfo = new ChannelInfoResource( + new ChannelInfoResource.Channel() + { + ChannelUUID = "90DD4DB5-B5DD-4655-839E-FDBE5F4BC0BF", + Name = "THE PLAYGROUND", + Description = "Classic Echo VR social lobbies.", + Rules = "1. Only use this channel for testing.\n2. Act responsibly.\n3. Act legally.", + RulesVersion = 1, + Link = "https://en.wikipedia.org/wiki/Lone_Echo", + Priority = 0, + Rad = true, + }, + new ChannelInfoResource.Channel() + { + ChannelUUID = "DD9C48DF-C495-4EF3-B317-4FD6364F329D", + Name = "CASUAL MATURE GAMERS", + Description = "Casual lobbies for less competitive players.", + Rules = "1. Only use this channel for testing.\n2. Act responsibly.\n3. Act legally.", + RulesVersion = 1, + Link = "https://en.wikipedia.org/wiki/Lone_Echo", + Priority = 1, + Rad = true, + }, + new ChannelInfoResource.Channel() + { + ChannelUUID = "937CE604-5DC7-431F-812B-C7C25B4B37B6", + Name = "COMPETITIVE GAMERS", + Description = "Competitive lobbies for competitive players.", + Rules = "1. Only use this channel for testing.\n2. Act responsibly.\n3. Act legally.", + RulesVersion = 1, + Link = "https://en.wikipedia.org/wiki/Lone_Echo", + Priority = 2, + Rad = true, + }, + new ChannelInfoResource.Channel() + { + ChannelUUID = "EF663D3F-D947-484A-BA7E-8C5ED7FED1A6", + Name = "COMBAT PLAYERS", + Description = "Casual and competitive lobbies for Echo Combat players.", + Rules = "1. Only use this channel for testing.\n2. Act responsibly.\n3. Act legally.", + RulesVersion = 1, + Link = "https://en.wikipedia.org/wiki/Lone_Echo", + Priority = 3, + Rad = true, + } + ); + + // Update our channel info in storage. + storage.ChannelInfo.Set(channelInfo); + } + private static void DeployConfigs(ServerStorage storage) + { + // Add all relevant resources. + + #region Main Menu + storage.Configs.Set(JsonConvert.DeserializeObject(@" +{ + ""type"":""main_menu"", + ""id"":""main_menu"", + ""_ts"":0, + ""news"":{ + ""offseason"":{ + ""texture"":""None"", + ""link"":""https://en.wikipedia.org/wiki/Lone_Echo"" + }, + ""sentiment"":{ + ""texture"":""ui_mnu_news_latest"", + ""link"":""https://en.wikipedia.org/wiki/Lone_Echo"" + } + }, + ""splash"":{ + ""offseason"":{ + ""texture"":""ui_menu_splash_screen_poster_a_shutdown_clr"", // can replace with ui_menu_splash_screen_poster_a_s7_clr for season 7 ending message, etc. + ""link"":""https://en.wikipedia.org/wiki/Lone_Echo"" + } + }, + ""splash_version"":1, // the version of the splash seen, so it doesn't show again. should monotonically increase. + ""help_link"":""https://en.wikipedia.org/wiki/Lone_Echo"", + ""news_link"":""https://en.wikipedia.org/wiki/Lone_Echo"", + ""discord_link"":""https://en.wikipedia.org/wiki/Lone_Echo"" +} +")!); + #endregion + } + private static void DeployDocuments(ServerStorage storage) + { + // Create our document resources. + EulaDocumentResource eula = new EulaDocumentResource + { + Type = "eula", + Language = "en", + Version = 1, + VersionGameAdmin = 1, + Text = "Warning: This is an unofficial server. By continuing, you indicate your connection is intentional, for legal personal research purposes.", + TextGameAdmin = "Unofficial server operators have visibility into game traffic, while players with authorized Game Admin roles may act as spectators or moderators to observe player interactions.", + MarkAsReadProfileKey = "legal|eula_version", + MarkAsReadProfileKeyGameAdmin = "legal|game_admin_version", + PrivacyPolicyLink = "https://en.wikipedia.org/wiki/Lone_Echo", + CodeOfConductLink = "https://en.wikipedia.org/wiki/Lone_Echo", + CodeOfConductVRLink = "https://en.wikipedia.org/wiki/Lone_Echo", + EchoCombatLink = "https://en.wikipedia.org/wiki/Lone_Echo", + EchoArenaLink = "https://en.wikipedia.org/wiki/Lone_Echo", + EchoVREulaLink = "https://en.wikipedia.org/wiki/Lone_Echo", + GameAdminsLink = "https://en.wikipedia.org/wiki/Lone_Echo", + TermsAndConditionsLink = "https://en.wikipedia.org/wiki/Lone_Echo", + }; + + // Convert the object to a generic document resource and store it. + DocumentResource documentResource = JObject.FromObject(eula)?.ToObject()!; + storage.Documents.Set(documentResource); + } + public static void PerformInitialDeployment(ServerStorage storage, string? gameDirectory = null, bool clearExistingAccounts = false) + { + DeployAccessControlList(storage); + DeploySymbolCache(storage, gameDirectory); + DeployLoginSettings(storage); + DeployChannelInfo(storage); + DeployConfigs(storage); + DeployDocuments(storage); + } + } +} diff --git a/EchoRelay.Core/Server/Storage/ResourceProviders.cs b/EchoRelay.Core/Server/Storage/ResourceProviders.cs new file mode 100644 index 0000000..2560dad --- /dev/null +++ b/EchoRelay.Core/Server/Storage/ResourceProviders.cs @@ -0,0 +1,169 @@ +using EchoRelay.Core.Server.Storage.Types; + +namespace EchoRelay.Core.Server.Storage +{ + /// + /// Event for a storage resource being loaded. + /// + /// The storage that the resource was loaded from. + /// The resource that was loaded. + public delegate void ResourceLoadedEventHandler(ServerStorage storage, T resource); + + /// + /// Event for a storage resource being changed. + /// + /// The storage that the resource was updated in. + /// The resource that was changed. + /// The type of change made to the resource. + public delegate void ResourceChangedEventHandler(ServerStorage storage, T resource, StorageChangeType changeType); + + #region Classes + /// + /// A storage provider for a given type of resource, . + /// + /// The type of resource which should be managed by this provider. + public abstract class ResourceProvider + { + /// + /// The parent which this provider was initialized for. + /// + public ServerStorage Storage { get; } + + /// + /// Event for a -type resource being loaded. + /// + public event ResourceLoadedEventHandler? OnLoaded; + + /// + /// Event for a -type resource being changed. + /// + public event ResourceChangedEventHandler? OnChanged; + + public ResourceProvider(ServerStorage storage) + { + Storage = storage; + } + + public void Open() + { + OpenInternal(); + } + protected abstract void OpenInternal(); + public void Close() + { + CloseInternal(); + } + protected abstract void CloseInternal(); + public abstract bool Exists(); + public V? Get() + { + V? resource = GetInternal(); + if (resource != null) + OnLoaded?.Invoke(Storage, resource); + return resource; + } + protected abstract V? GetInternal(); + public void Set(V resource) + { + SetInternal(resource); + OnChanged?.Invoke(Storage, resource, StorageChangeType.Set); + } + protected abstract void SetInternal(V resource); + public void Delete() + { + V? removedResource = DeleteInternal(); + if (removedResource != null) + OnChanged?.Invoke(Storage, removedResource, StorageChangeType.Deleted); + } + protected abstract V? DeleteInternal(); + } + + /// + /// A storage provider for a given type of resource that exists in a collection of the same type, . + /// + /// The type of key which is used to index the resource. + /// The type of resources which should be managed by this provider. + public abstract class ResourceCollectionProvider + where K : notnull + where V : IKeyedResource + { + /// + /// The parent which this provider was initialized for. + /// + public ServerStorage Storage { get; } + + /// + /// Indicates whether the resource provider is currently opened on storage. + /// + public bool Opened { get; private set; } + + /// + /// Event for a -type resource being loaded. + /// + public event ResourceLoadedEventHandler? OnLoaded; + + /// + /// Event for a -type resource being changed. + /// + public event ResourceChangedEventHandler? OnChanged; + + public ResourceCollectionProvider(ServerStorage storage) + { + Storage = storage; + } + + public void Open() + { + OpenInternal(); + Opened = true; + } + protected abstract void OpenInternal(); + public void Close() + { + CloseInternal(); + Opened = false; + } + protected abstract void CloseInternal(); + public abstract K[] Keys(); + public abstract bool Exists(K key); + public V? Get(K key) + { + V? resource = GetInternal(key); + if (resource != null) + OnLoaded?.Invoke(Storage, resource); + return resource; + } + protected abstract V? GetInternal(K key); + public void Set(V resource) + { + SetInternal(resource.Key(), resource); + OnChanged?.Invoke(Storage, resource, StorageChangeType.Set); + } + protected abstract void SetInternal(K key, V resource); + public void Delete(K key) + { + V? removedResource = DeleteInternal(key); + if (removedResource != null) + OnChanged?.Invoke(Storage, removedResource, StorageChangeType.Deleted); + } + protected abstract V? DeleteInternal(K key); + } + #endregion + + #region Enums + /// + /// Indicates the type of change that was made to a resource in storage. + /// + public enum StorageChangeType + { + /// + /// An item was set in storage. + /// + Set, + /// + /// An item was deleted from storage. + /// + Deleted + } + #endregion +} diff --git a/EchoRelay.Core/Server/Storage/Resources/AccessControlListResource.cs b/EchoRelay.Core/Server/Storage/Resources/AccessControlListResource.cs new file mode 100644 index 0000000..57cbbe9 --- /dev/null +++ b/EchoRelay.Core/Server/Storage/Resources/AccessControlListResource.cs @@ -0,0 +1,106 @@ +using EchoRelay.Core.Utils; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Net; +using System.Text.RegularExpressions; + +namespace EchoRelay.Core.Server.Storage.Types +{ + /// + /// Access control lists dictating allow and disallow rules for IPs, supporting wildcard ("*") matching. + /// The allow rules are checked prior to the disallow rules. + /// + public class AccessControlListResource + { + #region Properties + /// + /// IPv4 string filters that dictate which connections can be established for a . + /// This is applied before the . + /// + [JsonProperty("allow_rules")] + [JsonConverter(typeof(JsonUtils.HashSetConverter))] + public HashSet AllowRules { get; set; } + + /// + /// IPv4 string filters that dictate which connections can not be established for a . + /// This is applied after the . + /// + [JsonProperty("disallow_rules")] + [JsonConverter(typeof(JsonUtils.HashSetConverter))] + public HashSet DisallowRules { get; set; } + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + #endregion + + #region Constructor + /// + /// Initializes a new . + /// + public AccessControlListResource() + { + AllowRules = new HashSet(); + DisallowRules = new HashSet(); + } + /// + /// Initializes a new with the provided arguments. + /// + public AccessControlListResource(IEnumerable allowRules, IEnumerable disallowRules) + { + AllowRules = new HashSet(allowRules); + DisallowRules = new HashSet(disallowRules); + } + #endregion + + #region Functions + /// + /// Checks if an address string matches an array of rules. + /// + /// The IP address string to match. + /// The rules to match against. + /// Returns true if any rule matched, false otherwise. + private bool MatchAddressToRules(string address, string[] rules) + { + // Try to match any rule to this address string. + foreach (string rule in rules) + { + // Create a regex by converting wildcard expressions. + // Note: Other regex expressions would be retained here. It is the caller's responsibility to provide a string only containing numerics, '.' and '*' characters. + string pattern = rule.ToLower().Replace(".", "\0").Replace("*", ".*").Replace("\0", "\\."); + Regex regex = new Regex(pattern); + + // If we have a match, report it immediately + if (regex.IsMatch(address.ToLower())) + return true; + } + + // No rule matched. + return false; + } + + /// + /// Checks whether a given IP address passes the allow list/disallow list. + /// + /// The address to check is allowed through the access controls list. + /// Returns true if the IP address should be allowed, false otherwise. + public bool CheckAuthorized(IPAddress address) + { + // Obtain the IP address as a string + string ipAddress = address.ToString(); + + // Verify at least one allow rule matches the address. + if (!MatchAddressToRules(ipAddress, AllowRules.ToArray())) + return false; + + // Verify no disallow rule matches the address. + return !MatchAddressToRules(ipAddress, DisallowRules.ToArray()); + } + #endregion + } +} + + + diff --git a/EchoRelay.Core/Server/Storage/Resources/AccountResource.cs b/EchoRelay.Core/Server/Storage/Resources/AccountResource.cs new file mode 100644 index 0000000..e8dafb2 --- /dev/null +++ b/EchoRelay.Core/Server/Storage/Resources/AccountResource.cs @@ -0,0 +1,869 @@ +using EchoRelay.Core.Game; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using System.Security.Cryptography; +using System.Text; + +namespace EchoRelay.Core.Server.Storage.Types +{ + /// + /// An account registered with the server. + /// + public class AccountResource : IKeyedResource + { + #region Properties + /// + /// The client and server side profile for the account. + /// + [JsonProperty("profile")] + public AccountProfile Profile { get; set; } + + /// + /// Indicates whether the account can enter game servers as a moderator (using the `-moderator` CLI command). + /// This enables a free flycam mode where you are invisible to players. + /// + [JsonProperty("is_moderator")] + public bool IsModerator { get; set; } + + /// + /// Indicates when the account is banned until. If the user is not banned, this is null. + /// + [JsonProperty("banned_until")] + public DateTime? BannedUntil { get; set; } + + /// + /// A password-based account lock hash. This is computed as hash(UTF-8(account_lock) . ). + /// + [JsonProperty("account_lock_hash")] + public byte[]? AccountLockHash { get; private set; } + + /// + /// The salt to be appended to the password-based account lock before being hashed + /// and stored in the property. + /// + [JsonProperty("account_lock_salt")] + public byte[]? AccountLockSalt { get; private set; } + + /// + /// Indicates whether the account is currently banned. + /// + [JsonIgnore] + public bool Banned => BannedUntil != null && BannedUntil > DateTime.UtcNow; + + /// + /// The platform account identifier (unique user identifier) for the account. + /// This is provided from the . It may be invalid if it + /// has not yet been set. + /// + [JsonIgnore] + public XPlatformId AccountIdentifier + { + get + { + return XPlatformId.Parse(Profile.Server.XPlatformId) ?? new XPlatformId(0, 0); + } + } + #endregion + + #region Constructors + public AccountResource() + { + Profile = new AccountProfile(); + } + public AccountResource(XPlatformId userId, string? displayName = null, bool completedNPE = false, bool completedCommunityGuidelines = false, bool disableAfkTimeout = false) : this() + { + // Verify the user id provided. + if (!userId.Valid()) + throw new ArgumentException("Could not create user with invalid platform id supplied"); + + // Set the display name and platform id for client and server profiles. + Profile.Server.XPlatformId = userId.ToString(); + Profile.Client.XPlatformId = Profile.Server.XPlatformId; + Profile.SetDisplayName(displayName ?? userId.ToString()); + + // Set onboarding tutorials as completed, if requested. + if (completedNPE) + { + Profile.Client.NPE = new AccountClientProfile.NPESettings(); + Profile.Client.NPE.Lobby.Completed = true; + Profile.Client.NPE.Movement.Completed = true; + Profile.Client.NPE.FirstMatch.Completed = true; + Profile.Client.NPE.ArenaBasics.Completed = true; + } + + // Set acceptance of community guidelines, if requested. + if (completedCommunityGuidelines) + { + Profile.Client.Social = new AccountClientProfile.SocialSettings(); + Profile.Client.Social.SetupVersion = 1; + Profile.Client.Social.CommunityValuesVersion = 1; + } + + // Set developer settings + if (disableAfkTimeout) + { + Profile.Server.Developer = new AccountServerProfile.DeveloperSettings(); + Profile.Server.Developer.DisableAfkTimeout = true; // prevent kicking of "no ovr" (demo) users. + Profile.Server.Developer.XPlatformId = userId.ToString(); // enables developer mode to allow other options + } + } + #endregion + + #region Functions + /// + /// Obtains the key which the storage resource is indexed by. + /// + /// Returns the key which the resource is indexed by. + public XPlatformId Key() + { + return AccountIdentifier; + } + + /// + /// Authenticates the user with the provided account lock. + /// If no account lock was set, authentication always passes. + /// If a account lock is provided when no account lock is set, it is then set to the provided account lock. + /// + /// The account lock to authenticate to the account with. If the account has no account lock, it is set to this for the future. + /// Returns true if the user was authenticated, false otherwise. + public bool Authenticate(string? accountLock) + { + // If our account hash or salt is null, set the password and return true. + if (AccountLockSalt == null || AccountLockHash == null) + { + SetAccountLock(accountLock); + return true; + } + else if (accountLock == null) + { + // If the provided password is null, but the account has one, authentication fails. + return false; + } + else + { + // Otherwise, a password exists, so we calculate the hash for this password and compare. + byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(accountLock).Concat(AccountLockSalt).ToArray()); + return hash.SequenceEqual(AccountLockHash); + } + } + + /// + /// Sets the account lock to the provided account lock. + /// + /// The account lock to use. If null/empty, this clears the account lock. + public void SetAccountLock(string? accountLock) + { + if (string.IsNullOrEmpty(accountLock)) + { + ClearAccountLock(); + } + else + { + // Generate a new salt + AccountLockSalt = RandomNumberGenerator.GetBytes(0x16); + + // Hash the password with the salt. + // NOTE: Ideally, we'd use argon2id, bcrypt, or something more intensive here. + // For simplicity's sake and lack of control over client code (supporting unmodified clients), + // we'll just use a less secure scheme here with SHA256. + AccountLockHash = SHA256.HashData(Encoding.UTF8.GetBytes(accountLock).Concat(AccountLockSalt).ToArray()); + } + } + + /// + /// Clears the account lock on the account, unlocking it for any to use. + /// The account lock may be set by the client again with one provided. + /// + public void ClearAccountLock() + { + AccountLockHash = null; + AccountLockSalt = null; + } + #endregion + + /// + /// The client and server-side profiles for a given account. + /// + public class AccountProfile + { + /// + /// The client-side profile for the account. + /// + [JsonProperty("client")] + public AccountClientProfile Client { get; set; } = new AccountClientProfile(); + + /// + /// The server-side profile for the account. + /// + [JsonProperty("server")] + public AccountServerProfile Server { get; set; } = new AccountServerProfile(); + + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + /// + /// Sets the display name for both client and server profiles. + /// + /// The display name to set. + public void SetDisplayName(string displayName) + { + Client.DisplayName = displayName; + Server.DisplayName = displayName; + } + } + + /// + /// The client-side profile for a given account. + /// It does not contain any custom fields and reflects the structure used by the game. + /// + public class AccountClientProfile + { + /// + /// The display name of the account. + /// + [JsonProperty("displayname")] + public string DisplayName { get; set; } = ""; + + /// + /// The platform account identifier (unique user identifier) for the account. + /// + [JsonProperty("xplatformid")] + public string XPlatformId { get; set; } = ""; + + /// + /// The team name for the user. + /// + [JsonProperty("teamname")] + public string? TeamName { get; set; } = null; + + /// + /// The default weapon type used by this player. + /// + [JsonProperty("weapon")] + public string? Weapon { get; set; } = "scout"; // "scout", etc + + /// + /// The default grenade type used by this player. + /// + [JsonProperty("grenade")] + public string? Grenade { get; set; } = "det"; // "det", etc + + /// + /// The default arm used by this player. + /// + [JsonProperty("weaponarm")] + public int? WeaponArm { get; set; } = 1; + + /// + /// The last time this profile was modified. + /// + [JsonProperty("modifytime")] + public ulong? ModifyTime { get; set; } + + /// + /// The default ability for this player. + /// + [JsonProperty("ability")] + public string? Ability { get; set; } = null; // "heal", "sensor", etc + + /// + /// Acceptance of terms of service, EULA, etc. + /// + [JsonProperty("legal")] + public JObject? Legal { get; set; } = null; + + /// + /// TODO: + /// + [JsonProperty("temp")] + public JObject? Temp { get; set; } = null; + + /// + /// A structure containing player ids which were muted by this player. + /// + [JsonProperty("mute")] + public JObject? Mute { get; set; } = null; + + /// + /// A structure indicating progress in the new-player-experience/tutorials completion status. + /// + [JsonProperty("npe")] + public NPESettings? NPE { get; set; } = null; + + /// + /// A structure indicating versions of customizations/configs the user is on. + /// + [JsonProperty("customization")] + public CustomizationSettings? Customization { get; set; } = new CustomizationSettings(); + + /// + /// A structure specifying various information such as the user's default channel, setup version, community values acceptance, etc. + /// + [JsonProperty("social")] + public SocialSettings? Social { get; set; } = new SocialSettings(); + + /// + /// A structure containing symbol ids for unlockables. + /// + [JsonProperty("newunlocks")] + public long[] NewUnlocks { get; set; } = Array.Empty(); + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + #region Classes + /// + /// The social settings for a . + /// Specifies various settubgs such as the user's default channel, setup version, + /// community values acceptance, etc. + /// + public class SocialSettings + { + /// + /// The latest version of community values guidelines which has been accepted. + /// + [JsonProperty("community_values_version")] + public long? CommunityValuesVersion { get; set; } + + /// + /// The latest setup version which has been accepted. + /// + [JsonProperty("setup_version")] + public long? SetupVersion { get; set; } + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + } + /// + /// The customizations settings for a . + /// Specifies the versions of unlocks the client profile has most recently encountered. + /// + public class CustomizationSettings + { + /// + /// The latest version of battle pass season the client profile encountered. + /// + [JsonProperty("battlepass_season_poi_version")] + public long? BattlePassSeasonPoiVersion { get; set; } = 0; + + /// + /// The latest version of battle pass season the client profile encountered. + /// + [JsonProperty("new_unlocks_poi_version")] + public long? NewUnlocksPoiVersion { get; set; } = 0; + + /// + /// The latest version of battle pass season the client profile encountered. + /// + [JsonProperty("store_entry_poi_version")] + public long? StoreEntryPoiVersion { get; set; } = 0; + + /// + /// The latest version of battle pass season the client profile encountered. + /// + [JsonProperty("clear_new_unlocks_version")] + public long? ClearNewUnlocksVersion { get; set; } = 0; + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + } + /// + /// The "New Player Experience" settings for a . + /// Specifies which onboarding/tutorial sequences the client has completed. + /// + public class NPESettings + { + /// + /// Indicates whether the onboarding tutorial for the lobby has been completed. + /// + [JsonProperty("lobby")] + public Status Lobby { get; set; } = new Status(); + + /// + /// Indicates whether the onboarding tutorial for the first match has been completed. + /// + [JsonProperty("firstmatch")] + public Status FirstMatch { get; set; } = new Status(); + + /// + /// Indicates whether the onboarding tutorial for player movement has been completed. + /// + [JsonProperty("movement")] + public Status Movement { get; set; } = new Status(); + + /// + /// Indicates whether the onboarding tutorial for arena has been completed. + /// + [JsonProperty("arenabasics")] + public Status ArenaBasics { get; set; } = new Status(); + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + /// + /// Indicates the completion status of an NPE entry. + /// + public class Status + { + /// + /// Indicates whether the NPE entry has been marked completed by the user, or whether they must still undergo it. + /// + [JsonProperty("completed")] + public bool Completed { get; set; } + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + public Status(bool completed = false) + { + Completed = completed; + } + } + } + #endregion + + } + + /// + /// The server-side profile for a given account. + /// It does not contain any custom fields and reflects the structure used by the game. + /// + public class AccountServerProfile + { + /// + /// The display name of the account. + /// + [JsonProperty("displayname")] + public string DisplayName { get; set; } = ""; + + /// + /// The platform account identifier (unique user identifier) for the account. + /// + [JsonProperty("xplatformid")] + public string XPlatformId { get; set; } = ""; + + /// + /// TODO: Some client version + /// + [JsonProperty("_version")] + public long Version { get; set; } = 5; + + /// + /// An environment lock for different sandboxes. + /// + [JsonProperty("publisher_lock")] + public string? PublisherLock { get; set; } = "rad15_live"; + + /// + /// The date echo combat was purchased. + /// + [JsonProperty("purchasedcombat")] + public ulong? PurchasedCombatDate { get; set; } = 0; + + /// + /// The lobby build timestamp. + /// + [JsonProperty("lobbyversion")] + public ulong? LobbyVersion { get; set; } + + /// + /// The last time this profile was modified. + /// + [JsonProperty("modifytime")] + public ulong? ModifyTime { get; set; } + + /// + /// The last time this profile was logged into. + /// + [JsonProperty("logintime")] + public ulong? LoginTime { get; set; } + + /// + /// The last time this profile was updated. + /// + [JsonProperty("updatetime")] + public ulong? UpdateTime { get; set; } + + /// + /// The time this profile was created. + /// + [JsonProperty("createtime")] + public ulong? CreateTime { get; set; } + + /// + /// Indicates this profile data may have gone stale. + /// + [JsonProperty("maybestale")] + public bool? MaybeStale { get; set; } + + /// + /// A structure detailing account statistics. + /// + [JsonProperty("stats")] + public StatsSettings? Stats { get; set; } = new StatsSettings(); + + /// + /// In-game unlockables, denoted by symbols. + /// + [JsonProperty("unlocks")] + public UnlocksSettings? Unlocks { get; set; } = new UnlocksSettings(); + + /// + /// A structure detailing loadout information for the player. + /// + [JsonProperty("loadout")] + public LoadoutSettings? Loadout { get; set; } = new LoadoutSettings(); + + /// + /// Social information for the player, akin to 's. + /// + [JsonProperty("social")] + public JObject? Social { get; set; } + + /// + /// A structure tracking achievement data. + /// + [JsonProperty("achievements")] + public JObject? Achievements { get; set; } + + /// + /// TODO: + /// + [JsonProperty("reward_state")] + public JObject? RewardState { get; set; } + + /// + /// A structure specifying various information such as the user's default channel, setup version, community values acceptance, etc. + /// + [JsonProperty("dev")] + public DeveloperSettings? Developer { get; set; } + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + #region Classes + /// + /// The game stats settings for a . + /// + public class StatsSettings + { + /// + /// Game stats for Echo Arena. + /// + [JsonProperty("arena")] + public GameStats? Arena { get; set; } = new GameStats(); + + /// + /// Game stats for Echo Combat. + /// + [JsonProperty("combat")] + public GameStats? Combat { get; set; } = new GameStats(); + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + /// + /// A structure containing gameplay stats for a given game type. + /// + public class GameStats + { + /// + /// The player's level in the given game. + /// + [JsonProperty("Level")] + public Stat? Level { get; set; } = new Stat(1, "add", 1); + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + public class Stat + { + [JsonProperty("cnt")] + public long? Count { get; set; } + + [JsonProperty("op")] + public string? Operation { get; set; } + + [JsonProperty("val")] + public long? Value { get; set; } + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + public Stat(long? count = null, string? operation = null, long? value = null) + { + Count = count; + Operation = operation; + Value = value; + } + } + } + } + + /// + /// The current profile loadout settings for a . + /// e.g. armor types, color, profile icon, etc. + /// + public class LoadoutSettings + { + /// + /// The underlying loadout sets. + /// + [JsonProperty("instances")] + public LoadoutInstances? Instances { get; set; } = new LoadoutInstances(); + + /// + /// The number displayed alongside the user display. + /// + [JsonProperty("number")] + public long? Number { get; set; } = 1; + + /// + /// The number displayed on the body of the user. + /// + [JsonProperty("number_body")] + public long? NumberBody { get; set; } = null; + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + /// + /// A structure containing the sets of loadouts for the game. + /// + public class LoadoutInstances + { + /// + /// A structure containing a unified loadout. + /// + [JsonProperty("unified")] + public Loadout? Unified { get; set; } = new Loadout(); + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + /// + /// A structure containing a unified loadout. + /// Note: Presumably this means unified across gametypes. + /// + public class Loadout + { + /// + /// A structure containing the actual armor/decal/style assignments for the loadout. + /// + [JsonProperty("slots")] + public LoadoutSlots? Slots { get; set; } = new LoadoutSlots(); + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + /// + /// A structure containing the actual armor/decal/style assignments for the loadout. + /// + public class LoadoutSlots + { + // Set the default loadout the game normally sets for any players. + // For reference, see `sourcedb\rad15\json\r14\defaultserverprofile.json`. + + [JsonProperty("decal")] + public string? Decal { get; set; } = "decal_default"; + + [JsonProperty("decal_body")] + public string? DecalBody { get; set; } = "decal_default"; + + [JsonProperty("emote")] + public string? Emote { get; set; } = "emote_blink_smiley_a"; + + [JsonProperty("secondemote")] + public string? SecondEmote { get; set; } = "emote_blink_smiley_a"; + + [JsonProperty("tint")] + public string? Tint { get; set; } = "tint_neutral_a_default"; + + [JsonProperty("tint_body")] + public string? TintBody { get; set; } = "tint_neutral_a_default"; + + [JsonProperty("tint_alignment_a")] + public string? TintAlignmentA { get; set; } = "tint_blue_a_default"; + + [JsonProperty("tint_alignment_b")] + public string? TintAlignmentB { get; set; } = "tint_orange_a_default"; + + [JsonProperty("pattern")] + public string? Pattern { get; set; } = "pattern_default"; + + [JsonProperty("pattern_body")] + public string? PatternBody { get; set; } = "pattern_default"; + + [JsonProperty("pip")] + public string? Pip { get; set; } = "rwd_decalback_default"; + + [JsonProperty("chassis")] + public string? Chassis { get; set; } = "rwd_chassis_body_s11_a"; + + [JsonProperty("bracer")] + public string? Bracer { get; set; } = "rwd_bracer_default"; + + [JsonProperty("booster")] + public string? Booster { get; set; } = "rwd_booster_default"; + + [JsonProperty("title")] + public string? Title { get; set; } = "rwd_title_title_default"; + + [JsonProperty("tag")] + public string? Tag { get; set; } = "rwd_tag_s1_a_secondary"; + + [JsonProperty("banner")] + public string? Banner { get; set; } = "rwd_banner_s1_default"; + + [JsonProperty("medal")] + public string? Medal { get; set; } = "rwd_medal_default"; + + [JsonProperty("goal_fx")] + public string? GoalFX { get; set; } = "rwd_goal_fx_default"; + + [JsonProperty("emissive")] + public string? Emissive { get; set; } = "emissive_default"; + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + } + } + } + } + + /// + /// The unlockable player rewards for a . + /// + public class UnlocksSettings + { + /// + /// Game stats for Echo Arena. + /// + [JsonProperty("arena")] + public Dictionary Arena { get; set; } = new Dictionary(); + + /// + /// Game stats for Echo Combat. + /// + [JsonProperty("combat")] + public Dictionary Combat { get; set; } = new Dictionary(); + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + public UnlocksSettings() + { + // Add the default unlockables the game normally adds for any players. + // For reference, see `sourcedb\rad15\json\r14\defaultserverprofile.json`. + Arena["decal_combat_flamingo_a"] = true; + Arena["decal_combat_logo_a"] = true; + Arena["decal_default"] = true; + Arena["decal_sheldon_a"] = true; + Arena["emote_blink_smiley_a"] = true; + Arena["emote_default"] = true; + Arena["emote_dizzy_eyes_a"] = true; + Arena["loadout_number"] = true; + Arena["pattern_default"] = true; + Arena["pattern_lightning_a"] = true; + Arena["rwd_banner_s1_default"] = true; + Arena["rwd_booster_default"] = true; + Arena["rwd_bracer_default"] = true; + Arena["rwd_chassis_body_s11_a"] = true; + Arena["rwd_decalback_default"] = true; + Arena["rwd_decalborder_default"] = true; + Arena["rwd_medal_default"] = true; + Arena["rwd_tag_default"] = true; + Arena["rwd_tag_s1_a_secondary"] = true; + Arena["rwd_title_title_default"] = true; + Arena["tint_blue_a_default"] = true; + Arena["tint_neutral_a_default"] = true; + Arena["tint_neutral_a_s10_default"] = true; + Arena["tint_orange_a_default"] = true; + Arena["rwd_goal_fx_default"] = true; + Arena["emissive_default"] = true; + + Combat["rwd_booster_s10"] = true; + Combat["rwd_chassis_body_s10_a"] = true; + } + } + + /// + /// The development settings for a . + /// + public class DeveloperSettings + { + /// + /// Disables AFK timeout. + /// + [JsonProperty("disable_afk_timeout")] + public bool? DisableAfkTimeout { get; set; } + + /// + /// The platform identifier for the account. If set, this shows the account as a developer. + /// + [JsonProperty("xplatformid")] + public string? XPlatformId { get; set; } + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + public DeveloperSettings(XPlatformId? accountId = null, bool disableAfkTimeout=false) + { + XPlatformId = accountId?.ToString(); + DisableAfkTimeout = disableAfkTimeout; + } + } + #endregion + + } + } +} diff --git a/EchoRelay.Core/Server/Storage/Resources/ChannelInfoResource.cs b/EchoRelay.Core/Server/Storage/Resources/ChannelInfoResource.cs new file mode 100644 index 0000000..55e4611 --- /dev/null +++ b/EchoRelay.Core/Server/Storage/Resources/ChannelInfoResource.cs @@ -0,0 +1,107 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Storage.Types +{ + /// + /// Provides information to clients regarding in-game channels (e.g. "COMPETITIVE GAMERS", "ECHO COMBAT PLAYERS", "PLAYGROUND", etc). + /// + public class ChannelInfoResource + { + #region Properties + /// + /// The list of channels and their underlying information. + /// + [JsonProperty("group")] + public Channel[] Group { get; set; } = Array.Empty(); + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + #endregion + + #region Constructor + /// + /// Initializes a new with the optionally provided channels. + /// + /// The optionally provided channels to set. + public ChannelInfoResource(params Channel[] group) + { + Group = group; + } + + #endregion + + #region Classes + /// + /// Provides information on a single channel in a . + /// + public class Channel + { + /// + /// The UUID representing the channel. + /// + [JsonProperty("channeluuid")] + public string ChannelUUID { get; set; } = "77777777-7777-7777-7777-777777777777"; + + /// + /// The name of the channel, e.g. "COMPETITIVE GAMERS", "ECHO COMBAT PLAYERS", "PLAYGROUND". + /// + [JsonProperty("name")] + public string Name { get; set; } = ""; + + /// + /// The description text shown to players. It may have new line characters in it. + /// + [JsonProperty("description")] + public string Description { get; set; } = ""; + + /// + /// The rules text shown to players. It may have new line characters in it. + /// See 's documentation for information regarding user rule acceptance. + /// + [JsonProperty("rules")] + public string Rules { get; set; } = ""; + + /// + /// The version number of the rules. Each client accepts the rules and updates their profile with this rules version + /// to know if they have acknowledged the most recent rules. Thus, increasing this number forces users to re-accept rules. + /// This should be increased monotonically. + /// + [JsonProperty("rules_version")] + public ulong RulesVersion { get; set; } = 1; + + /// + /// The description text shown to players. It may have new line characters in it. + /// + [JsonProperty("link")] + public string Link { get; set; } = "https://discord.com/"; + + /// + /// The priority (index from 0) of the channel in its parent list. + /// The order of a in the list does not have to match + /// priority, as long as there are no gaps or duplicates in priority numbers. + /// + [JsonProperty("priority")] + public ulong Priority { get; set; } + + /// + /// TODO: Unknown. + /// + [JsonProperty("_rad")] + public bool Rad { get; set; } + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + } + #endregion + } +} + + + diff --git a/EchoRelay.Core/Server/Storage/Resources/ConfigResource.cs b/EchoRelay.Core/Server/Storage/Resources/ConfigResource.cs new file mode 100644 index 0000000..319f9bb --- /dev/null +++ b/EchoRelay.Core/Server/Storage/Resources/ConfigResource.cs @@ -0,0 +1,64 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Storage.Types +{ + /// + /// A JSON resource used to represent a game/server configuration, served through the to clients. + /// + public class ConfigResource : IKeyedResource<(string Type, string Identifier)> + { + #region Properties + /// + /// The type of the config resource. This must correspond to a symbol in the . + /// + [JsonProperty("type")] + public string Type { get; set; } = ""; + + /// + /// The name of the config resource. This must correspond to a symbol in the . + /// + [JsonProperty("id")] + public string Identifier { get; set; } = ""; + + /// + /// Additional properties of the resource. Each resource defines its own set of fields. + /// + [JsonExtensionData] + public IDictionary AdditionalData; + #endregion + + #region Constructor + /// + /// Initializes a new . + /// + public ConfigResource() + { + // Initialize our additional tokens. + AdditionalData = new Dictionary(); + } + #endregion + + #region Functions + /// + /// Validates the JSON fields and throws an exception if they are invalid. + /// + /// The exception to throw. + public virtual void ValidateJSON() + { + // If our type or identifier are are not set, throw an exception. + if (string.IsNullOrEmpty(Type) || string.IsNullOrEmpty(Identifier)) + throw new JsonException("Cannot have a blank 'type' or 'id' key"); + } + + /// + /// Obtains the key which the storage resource is indexed by. + /// + /// Returns the key which the resource is indexed by. + public (string Type, string Identifier) Key() + { + return (Type, Identifier); + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Storage/Resources/DocumentResource.cs b/EchoRelay.Core/Server/Storage/Resources/DocumentResource.cs new file mode 100644 index 0000000..d28a2c2 --- /dev/null +++ b/EchoRelay.Core/Server/Storage/Resources/DocumentResource.cs @@ -0,0 +1,53 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Storage.Types +{ + /// + /// A JSON resource used to represent a document, served through the to clients. + /// + public class DocumentResource : IKeyedResource<(string Type, string Language)> + { + #region Properties + /// + /// The type of the resource identifier. This must correspond to a symbol in the . + /// + [JsonProperty("type")] + public string Type { get; set; } = ""; + + /// + /// The language of the resource identifier. This must correspond to a . + /// + [JsonProperty("lang")] + public string Language { get; set; } = ""; + + /// + /// Additional properties of the resource. Each resource defines its own set of fields. + /// + [JsonExtensionData] + public IDictionary AdditionalData; + #endregion + + #region Constructor + /// + /// Initializes a new . + /// + public DocumentResource() + { + // Initialize our additional tokens. + AdditionalData = new Dictionary(); + } + #endregion + + #region Functions + /// + /// Obtains the key which the storage resource is indexed by. + /// + /// Returns the key which the resource is indexed by. + public (string Type, string Language) Key() + { + return (Type, Language); + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Storage/Resources/DocumentTypes/EulaDocumentResource.cs b/EchoRelay.Core/Server/Storage/Resources/DocumentTypes/EulaDocumentResource.cs new file mode 100644 index 0000000..6240729 --- /dev/null +++ b/EchoRelay.Core/Server/Storage/Resources/DocumentTypes/EulaDocumentResource.cs @@ -0,0 +1,114 @@ +using Newtonsoft.Json; + +namespace EchoRelay.Core.Server.Storage.Types.DocumentTypes +{ + /// + /// A JSON resource used to represent the End-User License Agreement (EULA) . + /// It is served through the to clients, which must accept it and advance + /// their profile's "accepted version" to avoid seeing it again. + /// + /// NOTE: This type is dynamic and accepts extra fields. As such, despite it inheriting from , it should + /// not be passed into functions that deserialize a in case the difference in included fields makes some fields be lost. + /// + public class EulaDocumentResource : DocumentResource + { + #region Properties + /// + /// The version of the EULA. Clients track the version they've accepted in their profiles. + /// Increasing the version forces users to accept the new version. + /// + [JsonProperty("version")] + public long? Version { get; set; } + + /// + /// The version of the EULA for game admins. See 's documentation for more information + /// about versioning. + /// + [JsonProperty("version_ga")] + public long? VersionGameAdmin { get; set; } + + /// + /// The EULA text for normal users. + /// + [JsonProperty("text")] + public string? Text { get; set; } + + /// + /// The EULA text for game admins. + /// + [JsonProperty("text_ga")] + public string? TextGameAdmin { get; set; } + + /// + /// The JSON key to use to mark the document as being read in the user's profile. + /// + [JsonProperty("mark_as_read_profile_key")] + public string? MarkAsReadProfileKey { get; set; } + + /// + /// The JSON key to use to mark the document as being read in the user's profile. + /// + [JsonProperty("mark_as_read_profile_key_ga")] + public string? MarkAsReadProfileKeyGameAdmin { get; set; } + + + /// + /// The URI to the code of conduct and reporting page. + /// + [JsonProperty("link_cc")] + public string? CodeOfConductLink { get; set; } + + /// + /// The URI to the privacy policy page. + /// + [JsonProperty("link_pp")] + public string? PrivacyPolicyLink { get; set; } + + /// + /// The URI to the full Echo VR EULA page. + /// + [JsonProperty("link_vr")] + public string? EchoVREulaLink { get; set; } + + /// + /// The URI to the code of conduct for virtual experiences. + /// + [JsonProperty("link_cp")] + public string? CodeOfConductVRLink { get; set; } + + /// + /// The URI to the Echo Combat page. + /// + [JsonProperty("link_ec")] + public string? EchoCombatLink { get; set; } + + /// + /// The URI to the Echo Arena page. + /// + [JsonProperty("link_ea")] + public string? EchoArenaLink { get; set; } + + /// + /// The URI to the Game Admins page. + /// + [JsonProperty("link_ga")] + public string? GameAdminsLink { get; set; } + + /// + /// The URI to the Terms and Conditions page. + /// + [JsonProperty("link_tc")] + public string? TermsAndConditionsLink { get; set; } + + #endregion + + #region Constructor + /// + /// Initializes a new . + /// + public EulaDocumentResource() : base() + { + } + #endregion + } +} diff --git a/EchoRelay.Core/Server/Storage/Resources/IKeyedResource.cs b/EchoRelay.Core/Server/Storage/Resources/IKeyedResource.cs new file mode 100644 index 0000000..4d4787c --- /dev/null +++ b/EchoRelay.Core/Server/Storage/Resources/IKeyedResource.cs @@ -0,0 +1,15 @@ +namespace EchoRelay.Core.Server.Storage.Types +{ + /// + /// A storage resource which is indexed by a particular key. + /// + /// The type of key the resource is indexed by. + public interface IKeyedResource + { + /// + /// Obtains the key which the storage resource is indexed by. + /// + /// Returns the key which the resource is indexed by. + public K Key(); + } +} diff --git a/EchoRelay.Core/Server/Storage/Resources/LoginSettingsResource.cs b/EchoRelay.Core/Server/Storage/Resources/LoginSettingsResource.cs new file mode 100644 index 0000000..cd339dc --- /dev/null +++ b/EchoRelay.Core/Server/Storage/Resources/LoginSettingsResource.cs @@ -0,0 +1,162 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Server.Storage.Types +{ + /// + /// The environment settings provided to users when they sign in, detailing metrics/reporting settings for client-to-server, in-app purchase/matchmaker settings, and active config data. + /// + public class LoginSettingsResource + { + #region Properties + /// + /// Indicates whether the client has in-app purchases unlocked. + /// + [JsonProperty("iap_unlocked")] + public bool IAPUnlocked { get; set; } = false; + + /// + /// Indicates whether the client should log social events. + /// + [JsonProperty("remote_log_social")] + public bool RemoteLogSocial { get; set; } + + /// + /// Indicates whether the client should log warning events. + /// + [JsonProperty("remote_log_warnings")] + public bool RemoteLogWarnings { get; set; } + + /// + /// Indicates whether the client should log error events. + /// + [JsonProperty("remote_log_errors")] + public bool RemoteLogErrors { get; set; } + + /// + /// Indicates whether the client should log rich presence events. + /// + [JsonProperty("remote_log_rich_presence")] + public bool RemoteLogRichPresence { get; set; } + + /// + /// Indicates whether the client should log metrics events. + /// + [JsonProperty("remote_log_metrics")] + public bool RemoteLogMetrics { get; set; } = true; + + /// + /// The current environment of the network (similar to publisher_lock). + /// + [JsonProperty("env")] + public string Environment { get; set; } = "live"; + + /// + /// Authentication-related nonce. + /// + [JsonProperty("matchmaker_queue_mode")] + public string MatchmakerQueueMode { get; set; } = "disabled"; + + + /// + /// Configuration related data. + /// + [JsonProperty("config_data")] + public LoginConfigSettings? ConfigData { get; set; } + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + #endregion + + #region Classes + /// + /// References which are/will become available. + /// + public class LoginConfigSettings + { + /// + /// Configuration related data. + /// + [JsonProperty("active_battle_pass_season")] + public Entry? ActiveBattlePassSeason { get; set; } + + /// + /// Configuration related data. + /// + [JsonProperty("active_store_entry")] + public Entry? ActiveStoreEntry { get; set; } + + /// + /// Configuration related data. + /// + [JsonProperty("active_store_featured_entry")] + public Entry? ActiveStoreFeatureEntry { get; set; } + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + /// + /// A single entry in . + /// + public class Entry + { + /// + /// A , indicating which config resource to use. + /// + [JsonProperty("id")] + public string Identifier { get; set; } = ""; + + /// + /// The time at which the referenced will become available. + /// + [JsonProperty("starttime")] + public ulong StartTime { get; set; } + + /// + /// The time at which the referenced will expire. + /// + [JsonProperty("endtime")] + public ulong EndTime { get; set; } + + /// + /// Additional fields which are not caught explicitly are retained here. + /// + [JsonExtensionData] + public IDictionary AdditionalData = new Dictionary(); + + /// + /// Initializes a new . + /// + public Entry() + { + StartTime = 0; // default = a date that started already + EndTime = 3392363227; // default = a date that won't expire soon + } + /// + /// Initializes a new with the provided arguments. + /// + /// The , indicating which config resource to use. + /// The time at which the referenced will become available. + /// The time at which the referenced will expire. + public Entry(string identifier, ulong? startTime = null, ulong? endTime = null) : this() + { + Identifier = identifier; + if (startTime != null) + StartTime = startTime.Value; + if (endTime != null) + EndTime = endTime.Value; + } + } + } + #endregion + } +} + + + diff --git a/EchoRelay.Core/Server/Storage/Resources/SymbolCache.cs b/EchoRelay.Core/Server/Storage/Resources/SymbolCache.cs new file mode 100644 index 0000000..3bd0810 --- /dev/null +++ b/EchoRelay.Core/Server/Storage/Resources/SymbolCache.cs @@ -0,0 +1,263 @@ +using Newtonsoft.Json; +using System.Collections.Concurrent; +using System.Text; + +namespace EchoRelay.Core.Server.Storage.Resources +{ + [JsonConverter(typeof(SymbolCacheSerializer))] + /// + /// A two-way lookup between symbol identifiers amd symbol names. + /// Symbols are used to reference various objects such as message identifiers, config resources, documents, etc. + /// + public class SymbolCache + { + #region Fields + /// + /// A lookup of symbol to names. This mirrors . + /// + private ConcurrentDictionary _symbolsToName; + /// + /// A lookup of names to symbols. This mirrors . + /// + private ConcurrentDictionary _namesToSymbols; + #endregion + + #region Constructor + /// + /// Initializes a new, empty . + /// + public SymbolCache() : this(new Dictionary()) { } + /// + /// Initializes a new with the names and symbols provided by the keys + /// and values of the provided dictionary. + /// + /// A lookup of symbol names to symbol identifiers to be added. + public SymbolCache(IDictionary namesToSymbols) + { + // Set our names to symbols lookup to a copy of the provided. + _namesToSymbols = new ConcurrentDictionary(namesToSymbols); + _symbolsToName = new ConcurrentDictionary(); + + // Create the reverse lookup. + foreach (string name in _namesToSymbols.Keys) + _symbolsToName[_namesToSymbols[name]] = name; + } + #endregion + + #region Functions + /// + /// Adds a new symbol with the provided name. + /// + /// The name of the symbol. + /// The symbol identifier. + /// Indicates whether an exception should be thrown if a different key-value pair already exists for either the name of symbol. + /// An exception is thrown if a different key-value pair already exists than the provided one. + public void Add(string name, long symbol, bool throwIfExists = false) + { + // Check if the symbol already exists in our lookup and throw an exception if we intend to. + bool exists = _namesToSymbols.TryGetValue(name, out long existingSymbol); + exists |= _symbolsToName.TryGetValue(symbol, out string? existingName); + if (exists) + { + if (throwIfExists && (existingName != name || existingSymbol != symbol)) + { + throw new ArgumentException($"Failed to add symbol to cache: conflicting symbol exists."); + } + + // Perform a clean removal if it existed in any case, to avoid losing track of a previous name in the two-way lookup operations. + Remove(name); + Remove(symbol); + } + + + // Add it to our lookups. + _namesToSymbols[name] = symbol; + _symbolsToName[symbol] = name; + } + /// + /// Adds all symbols from a provided to the current one. + /// + /// The to be absorbed into the current one. + /// Indicates whether an exception should be thrown if a different key-value pair already exists for either the name of symbol. + public void Add(SymbolCache symbolCache, bool throwIfExists = false) + { + // Add our cache to this one. + Add(symbolCache._namesToSymbols, throwIfExists); + } + /// + /// Adds all symbols from a provided lookup of symbol names to symbols. + /// + /// A lookup of symbol names to symbols, to be added to the . + /// An exception is thrown if a different key-value pair already exists than the provided one. + public void Add(IDictionary nameToSymbols, bool throwIfExists = false) + { + // Loop for each symbol + foreach (string name in _namesToSymbols.Keys) + { + Add(name, nameToSymbols[name], throwIfExists); + } + } + /// + /// Adds all symbols from .exe/.dll game files in the provided directory. + /// + /// The game directory containing assemblies to extract symbols from. + /// The depth to search for assemblies to extract symbols from. + /// An exception is thrown if a different key-value pair already exists than the provided one. + /// An exception thrown if the provided directory does not exist. + public void Add(string directory, SearchOption searchOption = SearchOption.AllDirectories, bool throwIfExists = false) + { + // Verify the path exists + if (!Directory.Exists(directory)) + throw new DirectoryNotFoundException($"Cannot extract symbols from directory \"{directory}\" as the path could not be found."); + + // Obtain all assembly files. + string[] targetFileExtensions = { "exe", "dll" }; + string[] assemblyFilePaths = Directory.GetFiles(directory, "*.*", searchOption) + .Where(f => targetFileExtensions.Contains(f.Split('.').Last().ToLower())).ToArray(); + + // Define the symbol marker + byte[] symbolMarker = Encoding.UTF8.GetBytes("OlPrEfIx"); + + // Load each assembly + foreach (string assemblyPath in assemblyFilePaths) + { + // Read the assembly data and begin looking for all symbols stored in it. + byte[] data = File.ReadAllBytes(assemblyPath); + Span dataSpan = data; + + // Loop throughout our entire buffer. + for (int currentPosition = 8; currentPosition < dataSpan.Length; currentPosition++) + { + // See if the symbol marker could be located at this position. + bool match = true; + for (int i = 0; i < symbolMarker.Length; i++) + { + if (symbolMarker[i] != dataSpan[currentPosition + i]) + { + match = false; + break; + } + } + + // If we couldn't match it at this position, advance. + if (!match) + continue; + + // Obtain the 64-bit symbol prior to this index + long symbol = BitConverter.ToInt64(dataSpan.Slice(currentPosition - 8, 8)); + + // Determine the location of the name + int symbolNameStart = currentPosition + symbolMarker.Length; + int symbolNameEnd = Array.IndexOf(data, (byte)0x00, symbolNameStart); + if (symbolNameEnd < symbolNameStart) + symbolNameEnd = data.Length; + else + symbolNameEnd = Math.Max(symbolNameStart, symbolNameEnd); + + // Obtain the null terminated symbol name. + string name = Encoding.UTF8.GetString(dataSpan.Slice(symbolNameStart, symbolNameEnd - symbolNameStart)); + + // Add the symbol + Add(name, symbol, throwIfExists); + + // Advance our position to the location of the end of our added symbol's name. + currentPosition = symbolNameEnd; + } + } + } + /// + /// Obtains a name associated with a symbol. + /// + /// The symbol to obtain a name for. + /// Returns the name associated with the symbol, or null if it could not be resolved. + public string? GetName(long symbol) + { + // Try to get a name from this symbol. + _symbolsToName.TryGetValue(symbol, out var name); + return name; + } + /// + /// Obtains a symbol for a given symbol name. + /// + /// The name to obtain a symbol for. + /// Returns the symbol for the provided name, or null if it could not be resolved. + public long? GetSymbol(string name) + { + // Try to get a symbol from this name. + _namesToSymbols.TryGetValue(name, out var symbol); + return symbol; + } + /// + /// Removes a symbol by its name, if it could be found. + /// + /// The name to remove a symbol for. + public void Remove(string name) + { + // Try to get the symbol for this name + if (_namesToSymbols.TryGetValue(name, out long symbol)) + { + _symbolsToName.Remove(symbol, out _); + _namesToSymbols.Remove(name, out _); + } + } + /// + /// Removes a symbol by its identifier, if it could be found. + /// + /// The symbol identifier for the symbol to be removed. + public void Remove(long symbol) + { + // Try to get the name for this symbol + if (_symbolsToName.TryGetValue(symbol, out string? name)) + { + _namesToSymbols.Remove(name, out _); + _symbolsToName.Remove(symbol, out _); + } + } + /// + /// Clears all symbols out of the . + /// + public void Clear() + { + _symbolsToName.Clear(); + _namesToSymbols.Clear(); + } + /// + /// Converts the into a dictionary lookup. + /// + /// Returns a symbol name to symbol identifier lookup. + public Dictionary ToDictionary() + { + return new Dictionary(_namesToSymbols); + } + #endregion + } + + + /// + /// A used to serialize/deserialize a . + /// + public class SymbolCacheSerializer : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return objectType == typeof(SymbolCache); + } + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + // Deserialize a dictionary of names-to-symbol-ids, and initialize a symbol cache with it. + var namesToSymbols = serializer.Deserialize>(reader) ?? new Dictionary(); + return new SymbolCache(namesToSymbols); + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + // Verify the value is of the correct type. + if (value?.GetType() != typeof(SymbolCache)) + return; + + // Serialize the names-to-symbol-ids lookup from the symbol cache. + serializer.Serialize(writer, ((SymbolCache)value).ToDictionary()); + } + } +} diff --git a/EchoRelay.Core/Server/Storage/ServerStorage.cs b/EchoRelay.Core/Server/Storage/ServerStorage.cs new file mode 100644 index 0000000..f363aaa --- /dev/null +++ b/EchoRelay.Core/Server/Storage/ServerStorage.cs @@ -0,0 +1,120 @@ +using EchoRelay.Core.Game; +using EchoRelay.Core.Server.Storage.Resources; +using EchoRelay.Core.Server.Storage.Types; + +namespace EchoRelay.Core.Server.Storage +{ + public abstract class ServerStorage + { + #region Properties + public bool Opened { get; private set; } + + public abstract ResourceProvider AccessControlList { get; } + public abstract ResourceCollectionProvider Accounts { get; } + public abstract ResourceProvider ChannelInfo { get; } + public abstract ResourceCollectionProvider<(string type, string identifier), ConfigResource> Configs { get; } + public abstract ResourceCollectionProvider<(string type, string language), DocumentResource> Documents { get; } + public abstract ResourceProvider LoginSettings { get; } + public abstract ResourceProvider SymbolCache { get; } + + private readonly object _openCloseLock = new object(); + #endregion + + #region Events + /// + /// Event for storage being opened or closed. + /// + /// The storage which was opened or closed. + public delegate void StorageOpenedClosedEventHandler(ServerStorage storage); + + /// + /// Event for storage being opened. This indicates resources may have been fully re-loaded. + /// + public event StorageOpenedClosedEventHandler? OnStorageOpened; + /// + /// Event for storage being opened. This indicates resources may no longer be available. + /// + public event StorageOpenedClosedEventHandler? OnStorageClosed; + #endregion + + #region General + public void Open() + { + // If this is already opened, exit early. + if (Opened) + return; + + // Open the storage + lock (_openCloseLock) + { + Opened = true; + OpenInternal(); + } + + // Fire the relevant event + OnStorageOpened?.Invoke(this); + } + protected virtual void OpenInternal() + { + // Signal opening to all resource providers. + AccessControlList.Open(); + Accounts.Open(); + ChannelInfo.Open(); + Configs.Open(); + Documents.Open(); + LoginSettings.Open(); + SymbolCache.Open(); + } + public void Close() + { + // Close storage + lock (_openCloseLock) + { + CloseInternal(); + Opened = false; + } + + // Fire the relevant event. + OnStorageClosed?.Invoke(this); + } + protected virtual void CloseInternal() + { + // Signal closing to all resource providers. + AccessControlList.Close(); + Accounts.Close(); + ChannelInfo.Close(); + Configs.Close(); + Documents.Close(); + LoginSettings.Close(); + SymbolCache.Close(); + } + public void Clear(bool accessControlList = true, bool accounts = true, bool channelInfo = true, bool configs = true, bool documents = true, bool loginSettings = true, bool symbolCache = true) + { + if (accessControlList) + AccessControlList.Delete(); + + if (channelInfo) + ChannelInfo.Delete(); + + if (loginSettings) + LoginSettings.Delete(); + + if (configs) + foreach (var configInfo in Configs.Keys()) + Configs.Delete((configInfo.type, configInfo.identifier)); + + if (documents) + foreach (var documentInfo in Documents.Keys()) + Documents.Delete((documentInfo.type, documentInfo.language)); + + if (accounts) + foreach (var userId in Accounts.Keys()) + Accounts.Delete(userId); + + if (symbolCache) + SymbolCache.Delete(); + } + #endregion + } + +} diff --git a/EchoRelay.Core/Utils/AsyncLock.cs b/EchoRelay.Core/Utils/AsyncLock.cs new file mode 100644 index 0000000..66e464c --- /dev/null +++ b/EchoRelay.Core/Utils/AsyncLock.cs @@ -0,0 +1,48 @@ +namespace EchoRelay.Core.Utils +{ + /// + /// A lock which can be used in asynchronous/awaitable contexts. It executes a given action with the lock. + /// + public class AsyncLock + { + /// + /// The internal semaphore used for asynchronous locking. + /// + public SemaphoreSlim _semaphore = new SemaphoreSlim(1, 1); + + /// + /// Executes a given action in a locked context. + /// + /// The action to execute within a lock. + /// + public async Task ExecuteLocked(Func action) + { + try + { + // Define a locked state. We will acquire the lock, execute the action, and release the lock. + var isLocked = false; + + // Keep trying to acquire the lock. + do + { + try + { + isLocked = await _semaphore.WaitAsync(TimeSpan.FromSeconds(1)); + } + catch + { + } + } + while (!isLocked); + + // Execute the action. + await action(); + } + finally + { + // If we obtained the lock, release it. + _semaphore.Release(); + } + } + } +} diff --git a/EchoRelay.Core/Utils/Compression.cs b/EchoRelay.Core/Utils/Compression.cs new file mode 100644 index 0000000..841bd8d --- /dev/null +++ b/EchoRelay.Core/Utils/Compression.cs @@ -0,0 +1,51 @@ +using Ionic.Zlib; +using ZstdSharp; + +namespace EchoRelay.Core.Utils +{ + /// + /// Provides zlib compression/decompression methods. + /// + public abstract class Compression + { + /// + /// Compresses a buffer with zlib compression. + /// + /// The buffer to compress with zlib. + /// Returns the zlib compressed buffer. + public static byte[] CompressZlib(byte[] data) + { + return ZlibStream.CompressBuffer(data); + } + + /// + /// Decompresses a zlib compressed buffer. + /// + /// The zlib compressed buffer to decompress. + /// Returns the decompressed buffer. + public static byte[] DecompressZlib(byte[] data) + { + return ZlibStream.UncompressBuffer(data); + } + + /// + /// Compresses a buffer with zstd compression. + /// + /// The buffer to compress with zstd. + /// Returns the zstd compressed buffer. + public static byte[] CompressZstd(byte[] data) + { + return new Compressor().Wrap(data).ToArray(); + } + + /// + /// Decompresses a zstd compressed buffer. + /// + /// The zstd compressed buffer to decompress. + /// Returns the decompressed buffer. + public static byte[] DecompressZstd(byte[] data) + { + return new Decompressor().Unwrap(data).ToArray(); + } + } +} diff --git a/EchoRelay.Core/Utils/IPAddressUtils.cs b/EchoRelay.Core/Utils/IPAddressUtils.cs new file mode 100644 index 0000000..b50ffd0 --- /dev/null +++ b/EchoRelay.Core/Utils/IPAddressUtils.cs @@ -0,0 +1,100 @@ +using System.Net; + +namespace EchoRelay.Core.Utils +{ + /// + /// Provides extensions for s. + /// + public static class IPAddressUtils + { + /// + /// Converts an into a big endian 32-bit unsigned integer. + /// + /// The address to convert into an integer. + /// The integer which represents the provided address. + public static uint ToUInt32(this IPAddress address) + { + byte[] bytes = address.GetAddressBytes(); + if (BitConverter.IsLittleEndian) + Array.Reverse(bytes); + return BitConverter.ToUInt32(bytes, 0); + } + + /// + /// Converts a big endian 32-bit unsigned integer into an . + /// + /// The integer to convert into an address. + /// Returns an that represents the integer. + public static IPAddress ToIPAddress(this uint addressUint) + { + byte[] bytes = BitConverter.GetBytes(addressUint); + if (BitConverter.IsLittleEndian) + Array.Reverse(bytes); + return new IPAddress(bytes); + } + + /// + /// Checks whether the IP address is an IPv4 address that falls within the private address range. + /// + /// The IPv4 address to check. + /// Returns true if the address is a knownprivate address, false otherwise. + public static bool IsPrivate(this IPAddress address) + { + // Check if the address is an IPv4 that falls in the private IP range. + if (address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + // Obtain the bytes and check if they fall in the private range. + byte[] ipBytes = address.GetAddressBytes(); + + // 10.x.x.x + if (ipBytes[0] == 10) + { + return true; + } + // 172.16.x.x + else if (ipBytes[0] == 172 && ipBytes[1] == 16) + { + return true; + } + // 192.168.x.x + else if (ipBytes[0] == 192 && ipBytes[1] == 168) + { + return true; + } + // 169.254.x.x + else if (ipBytes[0] == 169 && ipBytes[1] == 254) + { + return true; + } + // 127.0.0.1 + else if (ipBytes[0] == 127 && ipBytes[1] == 0 && ipBytes[2] == 0 && ipBytes[3] == 1) + { + return true; + } + } + + return false; + } + + /// + /// Obtains the public/external IP address of the current machine. + /// + /// Returns the public IP address of the current machine, or null if it could not be obtained. + public static async Task GetExternalIPAddress() + { + try + { + // Request the IP from a server, and sanitize the response. + string? externalIP = (await new HttpClient().GetStringAsync("https://ipinfo.io/ip")) + .Replace("\\r\\n", "").Replace("\\n", "").Trim(); + + // Try to parse an IP address from the sanitized response data. + if (IPAddress.TryParse(externalIP, out IPAddress? address)) + return address; + } + catch { } + + return null; + } + } +} diff --git a/EchoRelay.Core/Utils/JsonUtils.cs b/EchoRelay.Core/Utils/JsonUtils.cs new file mode 100644 index 0000000..1b8e309 --- /dev/null +++ b/EchoRelay.Core/Utils/JsonUtils.cs @@ -0,0 +1,91 @@ +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace EchoRelay.Core.Utils +{ + /// + /// Utilities to help with JSON operations. + /// + public abstract class JsonUtils + { + #region Fields + /// + /// Defines the settings for JSON merges. + /// + private static JsonMergeSettings _mergeSettings = new JsonMergeSettings + { + MergeArrayHandling = MergeArrayHandling.Union, + MergeNullValueHandling = MergeNullValueHandling.Ignore, + }; + #endregion + + #region Functions + /// + /// Merges given -compatible objects together. + /// + /// The type of the objects to merge. + /// The first object to be merged into. + /// The second object to merge into the first. + /// Returns the merged object. + public static T? MergeObjects(T obj, T obj2) + { + // If the second object is null, return the first simply. + if (obj2 == null) + return obj; + + // Otherwise merge. + return MergeObjects(obj, JObject.FromObject(obj2)); + } + + /// + /// Merges given -compatible objects together. + /// + /// The type of the objects to merge. + /// The first object to be merged into. + /// The second object to merge into the first. + /// Returns the merged object. + public static T? MergeObjects(T obj, JObject obj2) + { + // If the first object is null, return the second object. + if (obj == null) + return obj2.ToObject(); + + // Merge both objects and return the result. + JObject mergedObj = JObject.FromObject(obj); + mergedObj.Merge(obj2, _mergeSettings); + return mergedObj.ToObject(); + } + #endregion + + #region Classes + /// + /// A used to serialize/deserialize types. + /// + public class HashSetConverter : JsonConverter + { + public override bool CanConvert(Type objectType) + { + return objectType == typeof(HashSet); + } + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) + { + // Deserialize an array and initialize a hashset with it. + var arr = serializer.Deserialize(reader) ?? Array.Empty(); + return new HashSet(arr); + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + // Verify the value is of the correct type. + if (value?.GetType() != typeof(HashSet)) + return; + + // Serialize the value as an array. + serializer.Serialize(writer, ((HashSet)value).ToArray()); + } + } + #endregion + + } +} diff --git a/EchoRelay.Core/Utils/PathUtils.cs b/EchoRelay.Core/Utils/PathUtils.cs new file mode 100644 index 0000000..035c95a --- /dev/null +++ b/EchoRelay.Core/Utils/PathUtils.cs @@ -0,0 +1,17 @@ +namespace EchoRelay.Core.Utils +{ + public static class PathUtils + { + /// + /// Normalizes a path so it is consistent and deterministically resolved within the cache. + /// + /// The path to normalize. + /// Returns a normalized path. + public static string NormalizedPath(string path) + { + return Path.GetFullPath(new Uri(path).LocalPath) + .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + .ToLowerInvariant(); + } + } +} diff --git a/EchoRelay.Core/Utils/SecureGuidGenerator.cs b/EchoRelay.Core/Utils/SecureGuidGenerator.cs new file mode 100644 index 0000000..0b6b26e --- /dev/null +++ b/EchoRelay.Core/Utils/SecureGuidGenerator.cs @@ -0,0 +1,20 @@ +using System.Security.Cryptography; + +namespace EchoRelay.Core.Utils +{ + /// + /// A generator, backed by a cryptographically secure random number generator (CSRNG). + /// + public class SecureGuidGenerator + { + /// + /// Generates a new using a cryptographically secure RNG. + /// + /// Returns the newly generated . + public static Guid Generate() + { + // Create a new GUID from the cryptographically secure random generated bytes. + return new Guid(RandomNumberGenerator.GetBytes(16)); + } + } +} diff --git a/EchoRelay.Core/Utils/StreamIO.cs b/EchoRelay.Core/Utils/StreamIO.cs new file mode 100644 index 0000000..310f809 --- /dev/null +++ b/EchoRelay.Core/Utils/StreamIO.cs @@ -0,0 +1,864 @@ +using Newtonsoft.Json; +using System.Net; +using System.Runtime.InteropServices; +using System.Text; + +namespace EchoRelay.Core.Utils +{ + /// + /// Describes the byte order of a given architecture or value. + /// + public enum ByteOrder + { + LittleEndian, + BigEndian, + } + + /// + /// Describes whether is reading or writing data. + /// + public enum StreamMode + { + Read, + Write, + } + + /// + /// Describes the compression type for a JSON value written with + /// + public enum JSONCompressionMode + { + None, + Zlib, + Zstd, + } + + /// + /// An interface that entails an object being streamable with . + /// + public interface IStreamable + { + /// + /// Streams the data in/out based on the streaming mode set. + /// + /// The stream to read/write data from/to. + void Stream(StreamIO io); + } + + /// + /// A stream wrapper that provides read/write functionality for various types. + /// + public class StreamIO + { + #region Fields/Properties + /// + /// JSON serializer settings that prevent the "id" key from being lost when serializing our data. + /// + public static readonly JsonSerializerSettings JsonSerializerSettings = new JsonSerializerSettings { + PreserveReferencesHandling = PreserveReferencesHandling.None, + NullValueHandling = NullValueHandling.Ignore, + DateTimeZoneHandling = DateTimeZoneHandling.Utc, + }; + /// + /// The underlying stream which this provider wraps. + /// + private MemoryStream _stream; + /// + /// The byte order of the current executing machine. + /// + private ByteOrder _machineByteOrder; + + /// + /// The default byte order to use for streaming operations. + /// + public ByteOrder DefaultByteOrder { get; } + /// + /// The read/write mode to be using when streaming data. + /// + public StreamMode StreamMode { get; set; } + /// + /// Indicates the byte position within the stream. + /// + public long Position + { + get + { + return _stream.Position; + } + set + { + _stream.Position = value; + } + } + /// + /// Indicates the length of the stream. + /// + public long Length + { + get + { + return _stream.Length; + } + } + #endregion + + #region Constructors + public StreamIO(ByteOrder defaultByteOrder = ByteOrder.LittleEndian, StreamMode streamMode = StreamMode.Write) + { + // Set our stream parameters; + _stream = new MemoryStream(); + _machineByteOrder = BitConverter.IsLittleEndian ? ByteOrder.LittleEndian : ByteOrder.BigEndian; + DefaultByteOrder = defaultByteOrder; + StreamMode = streamMode; + } + public StreamIO(byte[] data, ByteOrder defaultByteOrder = ByteOrder.LittleEndian, StreamMode streamMode = StreamMode.Read) + { + // Set our stream parameters + _stream = new MemoryStream(data); + _machineByteOrder = BitConverter.IsLittleEndian ? ByteOrder.LittleEndian : ByteOrder.BigEndian; + DefaultByteOrder = defaultByteOrder; + StreamMode = streamMode; + } + #endregion + + #region Methods + #region General Methods + public void Close() + { + _stream.Close(); + } + /// + /// Returns a byte array of the stream data. + /// + /// Returns a byte array of the current stream data. + public byte[] ToArray() + { + return _stream.ToArray(); + } + #endregion + + #region Read Methods + /// + /// Reads a single byte from the stream. + /// + /// Returns the read byte. + /// An exception thrown if the end of stream has been reached. + public byte ReadByte() + { + // Read a byte, if it's -1, it failed to read. Otherwise, it succeeded. + int value = _stream.ReadByte(); + if (value == -1) + { + throw new IOException($"StreamIO failed to read byte"); + } + return (byte)value; + } + /// + /// Reads a span of bytes from the stream. + /// + /// The amount of bytes to read. + /// Returns the read bytes. + /// An exception thrown if the end of stream has been reached. + public Span ReadBytesSpan(int size) + { + // Create a span of the requested size and read the data into it. + Span value = new Span(new byte[size]); + int readSize = _stream.Read(value); + if (readSize != size) + { + throw new IOException($"StreamIO failed to read {size} bytes, obtained {readSize}"); + } + return value; + } + private T ReadValue(ByteOrder byteOrder) where T : struct, IComparable + { + // Read the value as bytes + Span valueBytes = ReadBytesSpan(Marshal.SizeOf(typeof(T))); + + // Determine if we should reverse the byte order + if (byteOrder != _machineByteOrder) + { + valueBytes.Reverse(); + } + + // Cast the memory to the type we are interested in. + Span value = MemoryMarshal.Cast(valueBytes); + + // Return the value + return value[0]; + } + + /// + /// Reads an array of bytes from the stream. + /// + /// The amount of bytes to read. + /// Returns the read bytes. + /// An exception thrown if the end of stream has been reached. + public byte[] ReadBytes(int size) + { + // Read bytes as an array. + return ReadBytesSpan(size).ToArray(); + } + public short ReadInt16() + { + return ReadInt16(DefaultByteOrder); + } + public short ReadInt16(ByteOrder byteOrder) + { + return ReadValue(byteOrder); + } + public ushort ReadUInt16() + { + return ReadUInt16(DefaultByteOrder); + } + public ushort ReadUInt16(ByteOrder byteOrder) + { + return ReadValue(byteOrder); + } + public int ReadInt32() + { + return ReadInt32(DefaultByteOrder); + } + public int ReadInt32(ByteOrder byteOrder) + { + return ReadValue(byteOrder); + } + public uint ReadUInt32() + { + return ReadUInt32(DefaultByteOrder); + } + public uint ReadUInt32(ByteOrder byteOrder) + { + return ReadValue(byteOrder); + } + public long ReadInt64() + { + return ReadInt64(DefaultByteOrder); + } + public long ReadInt64(ByteOrder byteOrder) + { + return ReadValue(byteOrder); + } + public ulong ReadUInt64() + { + return ReadUInt64(DefaultByteOrder); + } + public ulong ReadUInt64(ByteOrder byteOrder) + { + return ReadValue(byteOrder); + } + public Int128 ReadInt128() + { + return ReadInt128(DefaultByteOrder); + } + public Int128 ReadInt128(ByteOrder byteOrder) + { + return ReadValue(byteOrder); + } + public UInt128 ReadUInt128() + { + return ReadUInt128(DefaultByteOrder); + } + public UInt128 ReadUInt128(ByteOrder byteOrder) + { + return ReadValue(byteOrder); + } + public float ReadFloat() + { + return ReadFloat(DefaultByteOrder); + } + public float ReadFloat(ByteOrder byteOrder) + { + return ReadValue(byteOrder); + } + public double ReadDouble() + { + return ReadDouble(DefaultByteOrder); + } + public double ReadDouble(ByteOrder byteOrder) + { + return ReadValue(byteOrder); + } + public string ReadString(bool nullTerminated = true) + { + // Read bytes until end of file or null termination. + List valueBytes = new List(); + while (true) + { + // If we're at the end of the stream and not reading a null terminated string, stop + if (!nullTerminated && Position >= Length) + break; + + // Read a byte + byte valueByte = ReadByte(); + + // If the byte is a null terminator and this is a null terminated string, stop. + if (nullTerminated && valueByte == 0) + break; + + // Add the byte to our list + valueBytes.Add(valueByte); + } + + // Convert the bytes to a string and return them + string value = Encoding.UTF8.GetString(valueBytes.ToArray()); + return value; + } + public string ReadString(int size) + { + // Read the amount of bytes requested + byte[] valueBytes = ReadBytes(size); + + // Convert the bytes to a string and return them + string value = Encoding.UTF8.GetString(valueBytes.ToArray()); + return value.TrimEnd('\0'); + } + public Guid ReadGuid() + { + return ReadGuid(DefaultByteOrder); + } + public Guid ReadGuid(ByteOrder byteOrder) + { + // TODO: Support this later. + if (byteOrder == ByteOrder.BigEndian) + throw new NotImplementedException("Reading GUID in big endian byte order is unsupported"); + + return ReadValue(byteOrder); + } + public IPAddress ReadIPv4Address() + { + return ReadIPv4Address(DefaultByteOrder); + } + public IPAddress ReadIPv4Address(ByteOrder byteOrder) + { + // Read four bytes + Span bytes = ReadBytesSpan(4); + + // If the byte order is not big endian, swap it, as the IPAddress type expects this. + if (byteOrder != ByteOrder.BigEndian) + bytes.Reverse(); + + return new IPAddress(bytes); + } + public T ReadJSON(bool nullTerminated = true, JSONCompressionMode compressionMode = JSONCompressionMode.None, ByteOrder? byteOrder = null) + { + // If there is no compression, we simply read the underlying value. + if (compressionMode == JSONCompressionMode.None) + { + // Read and deserialize the JSON encoded value. + string encodedJson = ReadString(nullTerminated); + T? value = JsonConvert.DeserializeObject(encodedJson, JsonSerializerSettings); + + // If we successfully deserialized the value of our desired type, set it. Otherwise throw an exception. + if (value == null) + { + throw new InvalidDataException($"Could not stream null terminated JSON string, failed to decode type '{typeof(T).Name}'"); + } + return value; + } + else if (compressionMode == JSONCompressionMode.Zlib) + { + // Zlib mode writes a 64-bit decompressed length, followed by the compressed buffer (until end of file). + ulong decompressedLength = ReadUInt64(byteOrder ?? DefaultByteOrder); + byte[] data = ReadBytes((int)(Length - Position)); + + // Decompress the data + data = Compression.DecompressZlib(data); + + // Read the uncompressed data. + StreamIO uncompressedIO = new StreamIO(data, DefaultByteOrder, StreamMode.Read); + T value = uncompressedIO.ReadJSON(nullTerminated, JSONCompressionMode.None, byteOrder); + uncompressedIO.Close(); + return value; + } + else if (compressionMode == JSONCompressionMode.Zstd) + { + // Zstd mode writes a 32-bit decompressed length, followed by the compressed buffer (until end of file). + uint decompressedLength = ReadUInt32(byteOrder ?? DefaultByteOrder); + byte[] data = ReadBytes((int)(Length - Position)); + + // Decompress the data + data = Compression.DecompressZstd(data); + + // Read the uncompressed data. + StreamIO uncompressedIO = new StreamIO(data, DefaultByteOrder, StreamMode.Read); + T value = uncompressedIO.ReadJSON(nullTerminated, JSONCompressionMode.None, byteOrder); + uncompressedIO.Close(); + return value; + } + + // Throw an exception if an unsupported compression mode was provided. + throw new ArgumentException($"Invalid {nameof(JSONCompressionMode)} ({compressionMode}) provided to {nameof(StreamIO)}"); + } + #endregion + + #region Write Methods + public void Write(byte value) + { + _stream.WriteByte(value); + } + public void Write(Span value) + { + _stream.Write(value); + } + public void Write(Span value, int size) + { + _stream.Write(value.Slice(0, size)); + } + public void Write(short value) + { + Write(value, DefaultByteOrder); + } + public void Write(short value, ByteOrder byteOrder) + { + WriteValue(value, byteOrder); + } + public void Write(ushort value) + { + Write(value, DefaultByteOrder); + } + public void Write(ushort value, ByteOrder byteOrder) + { + WriteValue(value, byteOrder); + } + public void Write(int value) + { + Write(value, DefaultByteOrder); + } + public void Write(int value, ByteOrder byteOrder) + { + WriteValue(value, byteOrder); + } + public void Write(uint value) + { + Write(value, DefaultByteOrder); + } + public void Write(uint value, ByteOrder byteOrder) + { + WriteValue(value, byteOrder); + } + public void Write(long value) + { + Write(value, DefaultByteOrder); + } + public void Write(long value, ByteOrder byteOrder) + { + WriteValue(value, byteOrder); + } + public void Write(ulong value) + { + Write(value, DefaultByteOrder); + } + public void Write(ulong value, ByteOrder byteOrder) + { + WriteValue(value, byteOrder); + } + public void Write(Int128 value) + { + Write(value, DefaultByteOrder); + } + public void Write(Int128 value, ByteOrder byteOrder) + { + WriteValue(value, byteOrder); + } + public void Write(UInt128 value) + { + Write(value, DefaultByteOrder); + } + public void Write(UInt128 value, ByteOrder byteOrder) + { + WriteValue(value, byteOrder); + } + public void Write(float value) + { + Write(value, DefaultByteOrder); + } + public void Write(float value, ByteOrder byteOrder) + { + WriteValue(value, byteOrder); + } + public void Write(double value) + { + Write(value, DefaultByteOrder); + } + public void Write(double value, ByteOrder byteOrder) + { + WriteValue(value, byteOrder); + } + public void Write(string value, bool nullTerminated = true) + { + Write(Encoding.UTF8.GetBytes(value)); + if (nullTerminated) + { + Write((byte)0x00); + } + } + public void Write(string value, int fixedSize) + { + // Ensure our string doesn't exceed the provided size, and convert it to bytes. + byte[] valueBytes = Encoding.UTF8.GetBytes(value.Substring(0, Math.Min(fixedSize, value.Length))); + + // Write the string data. + Write(valueBytes); + + // If there is remaining bytes to write to reach our requested size, fill with zero padding. + if (valueBytes.Length < fixedSize) + Write(new byte[fixedSize - valueBytes.Length]); + } + public void Write(Guid value) + { + Write(value, DefaultByteOrder); + } + public void Write(Guid value, ByteOrder byteOrder) + { + // TODO: Support this later. + if (byteOrder == ByteOrder.BigEndian) + throw new NotImplementedException("Writing GUID in big endian byte order is unsupported"); + + WriteValue(value, byteOrder); + } + public void Write(IPAddress value) + { + Write(value, DefaultByteOrder); + } + public void Write(IPAddress value, ByteOrder byteOrder) + { + // Obtain the address bytes, which are always in big endian. + byte[] addrBytes = value.GetAddressBytes(); + if (byteOrder != ByteOrder.BigEndian) + addrBytes.Reverse(); + + // Obtain the bytes for this IP address and write them + Write(addrBytes, 4); + } + public void WriteJSON(T value, bool nullTerminated = true, JSONCompressionMode compressionMode = JSONCompressionMode.None, ByteOrder? byteOrder = null) + { + // If there is no compression, we simply write the underlying value. + if (compressionMode == JSONCompressionMode.None) + { + // JSON encode the value + string encodedJson = JsonConvert.SerializeObject(value, JsonSerializerSettings); + + // Write it as a null terminated string + Write(encodedJson, nullTerminated); + return; + } + else if (compressionMode == JSONCompressionMode.Zlib) + { + // Obtain the uncompressed data. + StreamIO uncompressedIO = new StreamIO(DefaultByteOrder, StreamMode.Write); + uncompressedIO.WriteJSON(value, nullTerminated, JSONCompressionMode.None, byteOrder); + byte[] data = uncompressedIO.ToArray(); + uncompressedIO.Close(); + + // Zlib mode writes a 64-bit decompressed length, followed by the compressed buffer (until end of file). + // Compress the data and write it. + ulong decompressedLength = (ulong)data.Length; + data = Compression.CompressZlib(data); + Write(decompressedLength, byteOrder ?? DefaultByteOrder); + Write(data); + return; + } + else if (compressionMode == JSONCompressionMode.Zstd) + { + // Obtain the uncompressed data. + StreamIO uncompressedIO = new StreamIO(DefaultByteOrder, StreamMode.Write); + uncompressedIO.WriteJSON(value, nullTerminated, JSONCompressionMode.None, byteOrder); + byte[] data = uncompressedIO.ToArray(); + uncompressedIO.Close(); + + // Zstd mode writes a 32-bit decompressed length, followed by the compressed buffer (until end of file). + // Compress the data and write it. + uint decompressedLength = (uint)data.Length; + data = Compression.CompressZstd(data); + Write(decompressedLength, byteOrder ?? DefaultByteOrder); + Write(data); + return; + } + + // Throw an exception if an unsupported compression mode was provided. + throw new ArgumentException($"Invalid {nameof(JSONCompressionMode)} ({compressionMode}) provided to {nameof(StreamIO)}"); + } + private void WriteValue(T value, ByteOrder byteOrder) where T : struct, IComparable + { + // Read the value as bytes + Span valueSpan = MemoryMarshal.CreateSpan(ref value, 1); + Span valueSpanBytes = MemoryMarshal.Cast(valueSpan); + + // Determine if we should reverse the byte order + if (byteOrder != _machineByteOrder) + { + // Copy to a new buffer so as not to alter the original value. + Span valueSpanBytesReversed = new Span(new byte[valueSpanBytes.Length]); + valueSpanBytes.CopyTo(valueSpanBytesReversed); + valueSpanBytesReversed.Reverse(); + valueSpanBytes = valueSpanBytesReversed; + } + + // Write the bytes + Write(valueSpanBytes); + } + #endregion + + #region Stream Methods + /// + /// Streams into/from the provided value based on the set. + /// + /// The value to stream. + public void Stream(ref byte value) + { + if (StreamMode == StreamMode.Read) + { + value = ReadByte(); + } + else + { + Write(value); + } + } + /// + /// Streams into/from the provided value based on the set. + /// + /// The value to stream. + public void Stream(ref byte[] value) + { + Stream(ref value, value.Length); + } + /// + /// Streams into/from the provided value based on the set. + /// + /// The value to stream. + public void Stream(ref byte[] value, int size) + { + if (StreamMode == StreamMode.Read) + { + value = ReadBytes(size); + } + else + { + Write(value, size); + } + } + public void Stream(ref short value) + { + Stream(ref value, DefaultByteOrder); + } + public void Stream(ref short value, ByteOrder byteOrder) + { + if (StreamMode == StreamMode.Read) + { + value = ReadInt16(byteOrder); + } + else + { + Write(value, byteOrder); + } + } + public void Stream(ref ushort value) + { + Stream(ref value, DefaultByteOrder); + } + public void Stream(ref ushort value, ByteOrder byteOrder) + { + if (StreamMode == StreamMode.Read) + { + value = ReadUInt16(byteOrder); + } + else + { + Write(value, byteOrder); + } + } + public void Stream(ref int value) + { + Stream(ref value, DefaultByteOrder); + } + public void Stream(ref int value, ByteOrder byteOrder) + { + if (StreamMode == StreamMode.Read) + { + value = ReadInt32(byteOrder); + } + else + { + Write(value, byteOrder); + } + } + public void Stream(ref uint value) + { + Stream(ref value, DefaultByteOrder); + } + public void Stream(ref uint value, ByteOrder byteOrder) + { + if (StreamMode == StreamMode.Read) + { + value = ReadUInt32(byteOrder); + } + else + { + Write(value, byteOrder); + } + } + public void Stream(ref long value) + { + Stream(ref value, DefaultByteOrder); + } + public void Stream(ref long value, ByteOrder byteOrder) + { + if (StreamMode == StreamMode.Read) + { + value = ReadInt64(byteOrder); + } + else + { + Write(value, byteOrder); + } + } + public void Stream(ref ulong value) + { + Stream(ref value, DefaultByteOrder); + } + public void Stream(ref ulong value, ByteOrder byteOrder) + { + if (StreamMode == StreamMode.Read) + { + value = ReadUInt64(byteOrder); + } + else + { + Write(value, byteOrder); + } + } + public void Stream(ref Int128 value) + { + Stream(ref value, DefaultByteOrder); + } + public void Stream(ref Int128 value, ByteOrder byteOrder) + { + if (StreamMode == StreamMode.Read) + { + value = ReadInt128(byteOrder); + } + else + { + Write(value, byteOrder); + } + } + public void Stream(ref UInt128 value) + { + Stream(ref value, DefaultByteOrder); + } + public void Stream(ref UInt128 value, ByteOrder byteOrder) + { + if (StreamMode == StreamMode.Read) + { + value = ReadUInt128(byteOrder); + } + else + { + Write(value, byteOrder); + } + } + public void Stream(ref float value) + { + Stream(ref value, DefaultByteOrder); + } + public void Stream(ref float value, ByteOrder byteOrder) + { + if (StreamMode == StreamMode.Read) + { + value = ReadFloat(byteOrder); + } + else + { + Write(value, byteOrder); + } + } + public void Stream(ref double value) + { + Stream(ref value, DefaultByteOrder); + } + public void Stream(ref double value, ByteOrder byteOrder) + { + if (StreamMode == StreamMode.Read) + { + value = ReadDouble(byteOrder); + } + else + { + Write(value, byteOrder); + } + } + public void Stream(ref string value, bool nullTerminated = true) + { + if (StreamMode == StreamMode.Read) + { + value = ReadString(nullTerminated); + } + else + { + Write(value, nullTerminated); + } + } + public void Stream(ref string value, int size) + { + if (StreamMode == StreamMode.Read) + { + value = ReadString(size); + } + else + { + Write(value, size); + } + } + public void Stream(ref Guid value) + { + Stream(ref value, DefaultByteOrder); + } + public void Stream(ref Guid value, ByteOrder byteOrder) + { + if (StreamMode == StreamMode.Read) + { + value = ReadGuid(byteOrder); + } + else + { + Write(value, byteOrder); + } + } + public void Stream(ref IPAddress value) + { + Stream(ref value, DefaultByteOrder); + } + public void Stream(ref IPAddress value, ByteOrder byteOrder) + { + if (StreamMode == StreamMode.Read) + { + value = ReadIPv4Address(byteOrder); + } + else + { + Write(value, byteOrder); + } + } + public void StreamJSON(ref T value, bool nullTerminated = true, JSONCompressionMode compressionMode = JSONCompressionMode.None, ByteOrder? byteOrder = null) + { + if (StreamMode == StreamMode.Read) + { + value = ReadJSON(nullTerminated, compressionMode, byteOrder); + } + else + { + WriteJSON(value, nullTerminated, compressionMode, byteOrder); + } + } + public void Stream(IStreamable value) + { + value.Stream(this); + } + #endregion + + #endregion + + } +} diff --git a/EchoRelay.GameServer/EchoRelay.GameServer.vcxproj b/EchoRelay.GameServer/EchoRelay.GameServer.vcxproj new file mode 100644 index 0000000..0540b6a --- /dev/null +++ b/EchoRelay.GameServer/EchoRelay.GameServer.vcxproj @@ -0,0 +1,118 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 16.0 + {BBEFCC27-4FD2-4F18-9BA2-5977026A143C} + Win32Proj + EchoRelayGameServer + 10.0 + + + + DynamicLibrary + true + v143 + x64 + Unicode + + + DynamicLibrary + false + v143 + true + x64 + Unicode + + + + + + + + + + + + + + + $(ProjectName) + true + + + $(ProjectName) + false + + + + NotUsing + Level3 + Disabled + true + _DEBUG;ECHORELAYGAMESERVER_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + pch.h + ../common/ + + + Windows + true + false + exports.def + + + + + + + + + NotUsing + Level3 + MaxSpeed + true + true + true + NDEBUG;ECHORELAYGAMESERVER_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + pch.h + ../common/ + + + Windows + true + true + true + false + exports.def + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/EchoRelay.GameServer/EchoRelay.GameServer.vcxproj.filters b/EchoRelay.GameServer/EchoRelay.GameServer.vcxproj.filters new file mode 100644 index 0000000..c391b1f --- /dev/null +++ b/EchoRelay.GameServer/EchoRelay.GameServer.vcxproj.filters @@ -0,0 +1,37 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + + + + + Header Files + + + Header Files + + + + + Source Files + + + Source Files + + + \ No newline at end of file diff --git a/EchoRelay.GameServer/README.md b/EchoRelay.GameServer/README.md new file mode 100644 index 0000000..7c42d53 --- /dev/null +++ b/EchoRelay.GameServer/README.md @@ -0,0 +1,17 @@ +# EchoRelay.GameServer + +This library is loaded after Echo VR successfully completes startup and has reached the mainmenu, while in dedicated server mode (`-server`). + +It provides a game server library interface which is responsible for intercommunication between Echo VR and the `SERVERDB` service. + +The game server library receives calls from Echo VR, providing information for the game server to register itself with `SERVERDB`. +The library also listens for messages from websocket services requesting a new session be started, expectation of a new peer connection +with given packet encoder settings, acceptance of a new player requesting to join over an established connection, rejection/kicking of a player. + +To install this component, read the installation instructions within the solution's [README](../README.md). + +## Known issues + +- There are some minor edge cases where we should send some failure messages internally for some conditions that we are not. These are marked with TODOs inline in the code. They are non-critical. +- The "start session" message which is provided by `EchoRelay.Core` and relayed by this library to Echo VR, is not complete. There is a structure at the end of the message that was not supported/included. This is rarely an issue as the game will treat it as empty if it's not provided, but is likely the cause of cooperative AI match requests crashing game servers. The structure defines parameters such as player ids in the game (including bots), and team limits. +- Echo VR's heap allocator structures should be used to allocate heap memory safely. This isn't difficult, but requires a bit more work to support. Instead we allocate on stack where we can, which is safe as well. diff --git a/EchoRelay.GameServer/dllmain.cpp b/EchoRelay.GameServer/dllmain.cpp new file mode 100644 index 0000000..729b27c --- /dev/null +++ b/EchoRelay.GameServer/dllmain.cpp @@ -0,0 +1,89 @@ +// dllmain.cpp : Defines the entry point for the DLL application. +#include "pch.h" +#include "echovr.h" +#include "gameserver.h" + +// The initialized ServerLib which Echo VR will call upon to communicate with central services. +EchoVR::IServerLib* g_ServerLib; + +BOOL APIENTRY DllMain( HMODULE hModule, + DWORD ul_reason_for_call, + LPVOID lpReserved + ) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + case DLL_PROCESS_DETACH: + break; + } + return TRUE; +} + +HRESULT RadPluginInit() +{ + return ERROR_SUCCESS; +} + +HRESULT RadPluginInitMemoryStatics(HMODULE hModule) { + return ERROR_SUCCESS; +} + +HRESULT RadPluginInitNonMemoryStatics(HMODULE hModule) { + return ERROR_SUCCESS; +} + +HRESULT RadPluginMain(CHAR* x) { + return ERROR_SUCCESS; +} + +HRESULT RadPluginSetAllocator(VOID* x) { + return ERROR_SUCCESS; +} + +HRESULT RadPluginSetEnvironment(VOID* x) { + return ERROR_SUCCESS; +} + +HRESULT RadPluginSetEnvironmentMethods(VOID* x, VOID* y) { + return ERROR_SUCCESS; +} + +HRESULT RadPluginSetFileTypes(VOID* x) { + return ERROR_SUCCESS; +} + +HRESULT RadPluginSetPresenceFactory(VOID* x) { + return ERROR_SUCCESS; +} + +HRESULT RadPluginSetSymbolDebugMethodsMethod(VOID* a, VOID* b, VOID* c, VOID* d) { + return ERROR_SUCCESS; +} + +HRESULT RadPluginShutdown() { + // TODO: This is ugly, but for now the platform provider can panic from the server hacks + no ovr, etc. + // There is some state transition confusion to rectify. + // + // As a temporary quick fix for panics which hang the app on close, we just force exit the whole process + // when we detect shutdown. + delete g_ServerLib; + exit(1); + return ERROR_SUCCESS; +} + +EchoVR::IServerLib* ServerLib() { + +#ifdef _DEBUG + // Set a debug breakpoint on startup if we're debugging. + //DebugBreak(); +#endif + + // If the server library hasn't been initialized, set it now. + if (g_ServerLib == NULL) { + g_ServerLib = new GameServerLib(); + } + return g_ServerLib; +} diff --git a/EchoRelay.GameServer/exports.def b/EchoRelay.GameServer/exports.def new file mode 100644 index 0000000..82e203e --- /dev/null +++ b/EchoRelay.GameServer/exports.def @@ -0,0 +1,14 @@ +LIBRARY EchoRelay.GameServer +EXPORTS + RadPluginInit @1 + RadPluginInitMemoryStatics @2 + RadPluginInitNonMemoryStatics @3 + RadPluginMain @4 + RadPluginSetAllocator @5 + RadPluginSetEnvironment @6 + RadPluginSetEnvironmentMethods @7 + RadPluginSetFileTypes @8 + RadPluginSetPresenceFactory @9 + RadPluginSetSymbolDebugMethodsMethod @10 + RadPluginShutdown @11 + ServerLib @12 diff --git a/EchoRelay.GameServer/gameserver.cpp b/EchoRelay.GameServer/gameserver.cpp new file mode 100644 index 0000000..0bb98b8 --- /dev/null +++ b/EchoRelay.GameServer/gameserver.cpp @@ -0,0 +1,453 @@ +#include +#include "pch.h" +#include "echovr.h" +#include "echovrunexported.h" +#include "messages.h" +#include "gameserver.h" + +/// +/// Connects to the ServerDB websocket service, a central service +/// used to manage live, connected game servers and their sessions. +/// The ServerDB service is to game servers, as the Matching service is to clients. +/// +/// The game server library which should connect to ServerDB. +/// The game config containing service endpoints (located in ./_local/config.json from the root of game folder). +/// None +BOOL ConnectToServerDb(GameServerLib* self, EchoVR::Json* localConfig) +{ + // Obtain the serverdb URI from our config (or fall back to default) + CHAR* serverDbServiceUri = EchoVR::JsonValueAsString((EchoVR::Json*)localConfig, (CHAR*)"serverdb_host", (CHAR*)"ws://localhost:777/serverdb", false); + EchoVR::UriContainer serverDbUriContainer; + memset(&serverDbUriContainer, 0, sizeof(serverDbUriContainer)); + if (EchoVR::UriContainerParse(&serverDbUriContainer, serverDbServiceUri) != ERROR_SUCCESS) + { + Log(EchoVR::LogLevel::Error, "[ECHORELAY.GAMESERVER] Failed to register game server: error parsing matching service URI"); + return false; + } + + // Connect to the serverdb service + self->tcpBroadcasterData->CreatePeer(&self->serverDbPeer, (const EchoVR::UriContainer*)&serverDbUriContainer); + return true; +} + +/// +/// Subscribes the TCP broadcasters (websocket) to a given message type. The provided +/// function is used as a callback when a message of the type is received from any service. +/// +/// The game server library which is listening for the message. +/// The 64-bit symbol used to describe a message type/identifier to listen for. +/// The function to use as callback when a TCP/webosocket message of the given type is received. +/// None +VOID ListenForTcpBroadcasterMessage(GameServerLib* self, EchoVR::SymbolId msgId, VOID* func) +{ + // Subscribe to the provided message id. + // Normally a proxy function is provided, which calls the underlying method provided, but we just use the proxy function callback to receive everything to keep it simple. + EchoVR::DelegateProxy listenerProxy; + memset(&listenerProxy, 0, sizeof(listenerProxy)); + listenerProxy.method[0] = 0xFFFFFFFFFFFFFFFF; + listenerProxy.instance = (VOID*)self; + listenerProxy.proxyFunc = func; + + EchoVR::TcpBroadcasterListen(self->lobby->tcpBroadcaster, msgId, 0, 0, 0, (VOID*)&listenerProxy, true); +} + +/// +/// Sends a message using the TCP broadcaster to the ServerDB websocket service. +/// +/// The game server library which is sending the message to the service. +/// The 64-bit symbol used to describe the message type/identifier being sent. +/// A pointer to the message data to be sent. +/// The size of the msg to be sent, in bytes. +/// None +VOID SendServerdbTcpMessage(GameServerLib* self, EchoVR::SymbolId msgId, VOID* msg, UINT64 msgSize) +{ + // Wrap the send call provided by the TCP broadcaster. + self->tcpBroadcasterData->SendToPeer(self->serverDbPeer, msgId, NULL, 0, msg, msgSize); +} + +/// +/// Subscribes to internal local events for a given message type. These are typically sent internally by the game +/// to its self, or derived from connected peer's messages (UDP broadcast port forwards events). The provided +/// function is used as a callback when a message of the type is received from any peer or onesself. +/// +/// The game server library which is listening for the message. +/// The 64-bit symbol used to describe a message type/identifier to listen for. +/// Indicates whether we are listening for events for messages sent over the reliable or mailbox game server message inbox types. +/// The function to use as callback when a broadcaster message of the given type is received. +/// None +VOID ListenForBroadcasterMessage(GameServerLib* self, EchoVR::SymbolId msgId, BOOL isMsgReliable, VOID* func) +{ + // Subscribe to the provided message id. + EchoVR::DelegateProxy listenerProxy; + memset(&listenerProxy, 0, sizeof(listenerProxy)); + listenerProxy.method[0] = 0xFFFFFFFFFFFFFFFF; + listenerProxy.instance = (VOID*)self; + listenerProxy.proxyFunc = func; + + EchoVR::BroadcasterListen(self->lobby->broadcaster, msgId, isMsgReliable, (VOID*)&listenerProxy, true); +} + +/// +/// Event handler for receiving a game server registration success message from the TCP (websocket) ServerDB service. +/// This message indicates the game server registration with ServerDB was accepted. +/// +/// None +VOID OnTcpMsgRegistrationSuccess(GameServerLib* self, VOID* proxymthd, EchoVR::TcpPeer sender, VOID* msg, VOID* unk, UINT64 msgSize) +{ + // Set the registration status + self->registered = TRUE; + + // Forward the received registration success event to the internal broadcast. + EchoVR::BroadcasterReceiveLocalEvent(self->broadcaster, SYMBOL_BROADCASTER_LOBBY_REGISTRATION_SUCCESS, "SNSLobbyRegistrationSuccess", msg, msgSize); +} + +/// +/// Event handler for receiving a game server registration failure message from the TCP (websocket) ServerDB service. +/// This message indicates the game server registration with ServerDB was rejected. +/// +/// None +VOID OnTcpMsgRegistrationFailure(GameServerLib* self, VOID* proxymthd, EchoVR::TcpPeer sender, VOID* msg, VOID* unk, UINT64 msgSize) +{ + // Set the registration status + self->registered = FALSE; + + // Forward the received registration failure event to the internal broadcast. + EchoVR::BroadcasterReceiveLocalEvent(self->broadcaster, SYMBOL_BROADCASTER_LOBBY_REGISTRATION_FAILURE, "SNSLobbyRegistrationFailure", msg, msgSize); +} + +/// +/// Event handler for receiving a start session request from the TCP (websocket) ServerDB service. +/// This message directs the game to start loading a new game session with the provided request arguments. +/// +/// None +VOID OnTcpMessageStartSession(GameServerLib* self, VOID* proxymthd, EchoVR::TcpPeer sender, VOID* msg, VOID* unk, UINT64 msgSize) +{ + // Obtain our message from this. + ERGameServerStartSession* tcpMsgStartSession = (ERGameServerStartSession*)msg; + + // Create an internal broadcast message that derives from this. + SNSLobbyStartSessionv4 msgStartSession; + memset(&msgStartSession, 0, sizeof(msgStartSession)); + msgStartSession.sessionUuid = tcpMsgStartSession->sessionUuid; + msgStartSession.playerLimit = tcpMsgStartSession->playerLimit; + msgStartSession.lobbyType = tcpMsgStartSession->lobbyType; + msgStartSession.json = (CHAR*)&tcpMsgStartSession->jsonStart; + msgStartSession.jsonLen = strlen(msgStartSession.json); + + // Set our session to active. + self->sessionActive = TRUE; + self->lobbyType = tcpMsgStartSession->lobbyType; + + // Forward the received start session event to the internal broadcast. + Log(EchoVR::LogLevel::Info, "[ECHORELAY.GAMESERVER] Starting new session"); + EchoVR::BroadcasterReceiveLocalEvent(self->broadcaster, SYMBOL_BROADCASTER_LOBBY_START_SESSION_V4, "SNSLobbyStartSessionv4", msg, msgSize); +} + +/// +/// Event handler for receiving a players accepted message from the TCP (websocket) ServerDB service. +/// This message indicates that ServerDB / Matching services accepted a player into this session, and is now +/// requesting the game server accept them. +/// +/// None +VOID OnTcpMsgPlayersAccepted(GameServerLib* self, VOID* proxymthd, EchoVR::TcpPeer sender, VOID* msg, VOID* unk, UINT64 msgSize) +{ + // Forward the received player acceptance success event to the internal broadcast. + EchoVR::BroadcasterReceiveLocalEvent(self->broadcaster, SYMBOL_BROADCASTER_LOBBY_ACCEPT_PLAYERS_SUCCESS_V2, "SNSLobbyAcceptPlayersSuccessv2", msg, msgSize); +} + +/// +/// Event handler for receiving a players rejected message from the TCP (websocket) ServerDB service. +/// This message indicates that ServerDB / Matching services rejected a player from this session, and is now +/// requesting the game server kick / reject them. +/// +/// None +VOID OnTcpMsgPlayersRejected(GameServerLib* self, VOID* proxymthd, EchoVR::TcpPeer sender, VOID* msg, VOID* unk, UINT64 msgSize) +{ + // Forward the received player acceptance failure event to the internal broadcast. + EchoVR::BroadcasterReceiveLocalEvent(self->broadcaster, SYMBOL_BROADCASTER_LOBBY_ACCEPT_PLAYERS_FAILURE_V2, "SNSLobbyAcceptPlayersFailurev2", msg, msgSize); +} + +/// +/// Event handler for receiving a join session success message from the TCP (websocket) ServerDB service. +/// This message indicates that ServerDB / Matching matched a player to this server.The message provides +/// connection parameters for both parties, including encryption / verification keys for client / game server to use. +/// +/// None +VOID OnTcpMsgSessionSuccessv5(GameServerLib* self, VOID* proxymthd, EchoVR::TcpPeer sender, VOID* msg, VOID* unk, UINT64 msgSize) +{ + // Forward the received join session success event to the internal broadcast. + // NOTE: For some reason, currently the session success message for servers parses differently than clients by some offset when setting packet encoding settings. + // To account for this, we shift the message pointer, and its size. This is non-problematic for the delegate proxy method wrapper, which only validates minimum size. + EchoVR::BroadcasterReceiveLocalEvent(self->broadcaster, SYMBOL_BROADCASTER_LOBBY_SESSION_SUCCESS_V5, "SNSLobbySessionSuccessv5", (CHAR*)msg - 0x10, msgSize + 0x10); +} + +/// +/// Event handler for receiving a session start success message from events (internal game server broadcast). +/// This message indicates that a new session is starting.This is triggered after a SNSLobbyStartSessionv4 message +/// is processed. +/// +/// None +VOID OnMsgSessionStarting(GameServerLib* self, VOID* proxymthd, VOID* msg, UINT64 msgSize, EchoVR::Peer destination, EchoVR::Peer sender) +{ + // NOTE: `msg` here has no substance (one uninitialized byte). + Log(EchoVR::LogLevel::Info, "[ECHORELAY.GAMESERVER] Session starting"); +} + +/// +/// Event handler for receiving a session error message from events (internal game server broadcast). +/// This message indicates that the game session encountered an error either when starting or running. +/// +/// None +VOID OnMsgSessionError(GameServerLib* self, VOID* proxymthd, VOID* msg, UINT64 msgSize, EchoVR::Peer destination, EchoVR::Peer sender) +{ + // NOTE: `msg` here has no substance (one uninitialized byte). + Log(EchoVR::LogLevel::Error, "[ECHORELAY.GAMESERVER] Session error encountered"); +} + +/// +/// TODO: This vtable slot is not verified to be for this purpose. +/// In any case, it seems not to be called or problematic, so we'll leave this definition as a placeholder. +/// +/// TODO: Unknown +/// TODO: Unknown +/// TODO: Unknown +/// TODO: Unknown +INT64 GameServerLib::UnkFunc0(VOID* unk1, INT64 a2, INT64 a3) +{ + return 1; +} + +/// +/// Initializes the game server library. This is called by the game after the library has been loaded. +/// +/// The current game lobby structure to reference/leverage when operating the game server. +/// The internal game server broadcast to use to communicate with clients. +/// TODO: Unknown. +/// The file path where the current log file resides. +/// None +VOID* GameServerLib::Initialize(EchoVR::Lobby* lobby, EchoVR::Broadcaster* broadcaster, VOID* unk2, const CHAR* logPath) +{ + // Set up our game server state. + this->lobby = lobby; + this->broadcaster = broadcaster; + this->tcpBroadcasterData = lobby->tcpBroadcaster->data; + + // Subscribe to websocket events. + ListenForTcpBroadcasterMessage(this, SYMBOL_TCPBROADCASTER_LOBBY_REGISTRATION_SUCCESS, (VOID*)OnTcpMsgRegistrationSuccess); + ListenForTcpBroadcasterMessage(this, SYMBOL_TCPBROADCASTER_LOBBY_REGISTRATION_FAILURE, (VOID*)OnTcpMsgRegistrationFailure); + ListenForTcpBroadcasterMessage(this, SYMBOL_TCPBROADCASTER_LOBBY_START_SESSION, (VOID*)OnTcpMessageStartSession); + ListenForTcpBroadcasterMessage(this, SYMBOL_TCPBROADCASTER_LOBBY_PLAYERS_ACCEPTED, (VOID*)OnTcpMsgPlayersAccepted); + ListenForTcpBroadcasterMessage(this, SYMBOL_TCPBROADCASTER_LOBBY_PLAYERS_REJECTED, (VOID*)OnTcpMsgPlayersRejected); + ListenForTcpBroadcasterMessage(this, SYMBOL_TCPBROADCASTER_LOBBY_SESSION_SUCCESS_V5, (VOID*)OnTcpMsgSessionSuccessv5); + + // Subscribe to broadcaster events + ListenForBroadcasterMessage(this, SYMBOL_BROADCASTER_LOBBY_SESSION_STARTING, true, (VOID*)OnMsgSessionStarting); + ListenForBroadcasterMessage(this, SYMBOL_BROADCASTER_LOBBY_SESSION_ERROR, true, (VOID*)OnMsgSessionError); + + // Log the interaction. + Log(EchoVR::LogLevel::Info, "[ECHORELAY.GAMESERVER] Initialized game server"); + //lobby->hosting |= 0x1; + + // If we built the module in debug mode, print the base address into logs for debugging purposes. + #if _DEBUG + Log(EchoVR::LogLevel::Debug, "[ECHORELAY.GAMESERVER] EchoVR base address = 0x%p", (VOID*)EchoVR::g_GameBaseAddress); + #endif + + // This should return a valid pointer to simply dereference. + return this; +} + +/// +/// Terminates the game server library. This is called by the game prior to unloading the library. +/// +/// None +VOID GameServerLib::Terminate() +{ + Log(EchoVR::LogLevel::Info, "[ECHORELAY.GAMESERVER] Terminated game server"); +} + +/// +/// Updates the game server library. This is called by the game at a frequent interval. +/// +/// None +VOID GameServerLib::Update() +{ + // TODO: This is temporary code to test if the profile JSON is updated (but not sent to server). + // If it is not updated in this structure, one of the "apply loadout" or "save loadout" operations may trigger the update? + for (int i = 0; i < this->lobby->entrantData.count; i++) + { + // Obtain the entrant at the given index. + EchoVR::Lobby::EntrantData* entrantData = (this->lobby->entrantData.items + i); + + // TODO: If the entrant is marked dirty... + if (entrantData->userId.accountId != 0 && entrantData->dirty) + { + entrantData->dirty = entrantData->dirty; + } + } +} + +/// +/// TODO: Unknown. This is called during initialization with a value of 6. Maybe it is platform/game server privilege/role related? +/// +/// TODO: Unknown. +/// None +VOID GameServerLib::UnkFunc1(UINT64 unk) +{ + // Note: This function is called prior to Initialize. +} + +/// +/// Requests registration of the game server with central TCP/websocket services (ServerDB). +/// This is called by the game after the library has been initialized. +/// +/// The identifier to use for the game server when registering with ServerDB. +/// TODO: Unknown. +/// A 64-bit symbol identifier indicating the region that the game server should be registering to. +/// A version to use when locking out clients which request matching with a differing version. +/// The game config containing service endpoints (located in ./_local/config.json from the root of game folder). +/// None +VOID GameServerLib::RequestRegistration(INT64 serverId, CHAR* radId, EchoVR::SymbolId regionId, EchoVR::SymbolId lockedVersion, const EchoVR::Json* localConfig) +{ + // Store the registration information. + this->serverId = serverId; + this->regionId = regionId; + this->versionLock = lockedVersion; + + // Connect to the serverdb service + if (!ConnectToServerDb(this, (EchoVR::Json*)localConfig)) + return; + + // Obtain address information about our game server broadcaster + sockaddr_in gameServerAddr = (*(sockaddr_in*)&this->broadcaster->data->addr); + + // Create a registration request. + // Note: Only IP address is in network order (big endian). + ERLobbyRegistrationRequest regRequest; + regRequest.serverId = this->serverId; + regRequest.port = (UINT16)this->broadcaster->data->broadcastSocketInfo.port; + regRequest.internalIp = gameServerAddr.sin_addr.S_un.S_addr; + regRequest.regionId = this->regionId; + regRequest.versionLock = this->versionLock; + + // Send the registration request. + SendServerdbTcpMessage(this, SYMBOL_TCPBROADCASTER_LOBBY_REGISTRATION_REQUEST, ®Request, sizeof(regRequest)); + + // Log the interaction. + Log(EchoVR::LogLevel::Info, "[ECHORELAY.GAMESERVER] Requested game server registration"); +} + +/// +/// Requests unregistration of the game server with central TCP/websocket services (ServerDB). +/// This is called by the game during game server library unloading. +/// +/// None +VOID GameServerLib::Unregister() { + // Reset our game server library state. + registered = FALSE; + sessionActive = FALSE; + serverId = -1; + regionId = -1; + versionLock = -1; + + // TODO: These probably aren't necessary, but it would be good to.. + // - Set lobbytype to public + // - Clear the JSON for lobby + + // Log the interaction. + Log(EchoVR::LogLevel::Info, "[ECHORELAY.GAMESERVER] Unregistered game server"); +} + +/// +/// Signals the ending of the current game server session with central TCP/websocket services (ServerDB). +/// This is called by the game when a game has ended, or a session was started but no players joined for some time, +/// causing the game server to load back to the mainmenu, awaiting further orders from ServerDB. +/// +/// None +VOID GameServerLib::EndSession() { + // If there is a running session, inform the websocket so it can track the state change. + if (sessionActive) + { + ERLobbyEndSession message; + SendServerdbTcpMessage(this, SYMBOL_TCPBROADCASTER_LOBBY_END_SESSION, &message, sizeof(message)); + } + Log(EchoVR::LogLevel::Info, "[ECHORELAY.GAMESERVER] Signaling end of session"); +} + +/// +/// Signals the locking of player sessions in the current game server session with central TCP/websocket services (ServerDB), +/// indicating players should no longer be able to join the current game session. +/// This is called by the game after a game has been started by some time, to avoid players from joining during +/// the later halves of game sessions. +/// +/// None +VOID GameServerLib::LockPlayerSessions() { + // If there is a running session, inform the websocket so it can track the state change. + if (sessionActive) + { + ERLobbyPlayerSessionsLocked message; + SendServerdbTcpMessage(this, SYMBOL_TCPBROADCASTER_LOBBY_PLAYER_SESSIONS_LOCKED, &message, sizeof(message)); + } + + // Log the interaction. + Log(EchoVR::LogLevel::Info, "[ECHORELAY.GAMESERVER] Signaling game server locked"); +} + +/// +/// Signals the unlocking of player sessions in the current game server session with central TCP/websocket services (ServerDB), +/// indicating players should be able to join the current game session. +/// +/// None +VOID GameServerLib::UnlockPlayerSessions() { + // If there is a running session, inform the websocket so it can track the state change. + if (sessionActive) + { + ERLobbyPlayerSessionsUnlocked message; + SendServerdbTcpMessage(this, SYMBOL_TCPBROADCASTER_LOBBY_PLAYER_SESSIONS_UNLOCKED, &message, sizeof(message)); + } + + // Log the interaction. + Log(EchoVR::LogLevel::Info, "[ECHORELAY.GAMESERVER] Signaling game server unlocked"); +} + +/// +/// Signals acceptance of player sessions by the game server, with central TCP/websocket services (ServerDB). +/// This is called by the game after a peer connects to the game server, typically after ServerDB signals its +/// acceptance to game server, and gives peer the appropriate information to join.Both parties must have +/// exchanged packet encoding settings via receipt of SNSLobbySessionSuccessv5 from ServerDB / Matching to communicate. +/// +/// An array of player session UUIDs which have been accepted by the game server. +/// None +VOID GameServerLib::AcceptPlayerSessions(EchoVR::Array* playerUuids) { + // If we have an active session, signal to serverdb that we are accepting the provided player UUIDs. + if (sessionActive) + { + SendServerdbTcpMessage(this, SYMBOL_TCPBROADCASTER_LOBBY_ACCEPT_PLAYERS, playerUuids->items, playerUuids->count * sizeof(GUID)); + } + else + { + // TODO: Receive local event "SNSLobbyAcceptPlayersFailurev2" + } + + // Log the interaction. + Log(EchoVR::LogLevel::Info, "[ECHORELAY.GAMESERVER] Accepted %d players into game server", playerUuids->count); +} + +/// +/// Signals rejection of player sessions by the game server, with central TCP/websocket services (ServerDB). +/// This is called by the game after a peer connects to the game server, but the game server did not accept them, +/// possibly due to inability to communicate(e.g.invalid packet encoder settings for one party), or due to +/// general communication / peer state errors. +/// +/// A single player session UUID which have been removed by the game server. +/// None +VOID GameServerLib::RemovePlayerSession(GUID* playerUuid) { + // If we have an active session, signal to serverdb that we are removing the provided player UUID. + if (sessionActive) + { + SendServerdbTcpMessage(this, SYMBOL_TCPBROADCASTER_LOBBY_PLAYERS_REMOVE_PLAYER, (VOID*)playerUuid, sizeof(GUID)); + } + + // Log the interaction. + Log(EchoVR::LogLevel::Info, "[ECHORELAY.GAMESERVER] Removed a player from game server"); +} diff --git a/EchoRelay.GameServer/gameserver.h b/EchoRelay.GameServer/gameserver.h new file mode 100644 index 0000000..0271d19 --- /dev/null +++ b/EchoRelay.GameServer/gameserver.h @@ -0,0 +1,51 @@ +#pragma once + +#include "pch.h" +#include "EchoVR.h" + +/// +/// A symbol representing the game server's special websocket service. +/// +const EchoVR::SymbolId SYMBOL_GAMESERVER_DB = 0x25E886012CED8064; + +/// +/// A game server library implementation which connects to EchoRelay's ServerDB implementation. +/// +class GameServerLib : public EchoVR::IServerLib { +public: + INT64 UnkFunc0(VOID* unk1, INT64 a2, INT64 a3); + VOID* Initialize(EchoVR::Lobby* lobby, EchoVR::Broadcaster* broadcaster, VOID* unk2, const CHAR* logPath); + VOID Terminate(); + VOID Update(); + VOID UnkFunc1(UINT64 unk); + + + VOID RequestRegistration(INT64 serverId, CHAR* radId, EchoVR::SymbolId regionId, EchoVR::SymbolId lockedVersion, const EchoVR::Json* localConfig); + VOID Unregister(); + VOID EndSession(); + VOID LockPlayerSessions(); + VOID UnlockPlayerSessions(); + VOID AcceptPlayerSessions(EchoVR::Array* playerUuids); + VOID RemovePlayerSession(GUID* playerUuid); + + // Game related fields + + EchoVR::Lobby* lobby; + EchoVR::Broadcaster* broadcaster; + EchoVR::TcpBroadcasterData* tcpBroadcasterData; + + + // ServerDB related fields + + EchoVR::TcpPeer serverDbPeer; + BOOL registered; + + + // Session related fields. + + BOOL sessionActive; + EchoVR::LobbyType lobbyType; + UINT64 serverId; + EchoVR::SymbolId regionId; + EchoVR::SymbolId versionLock; +}; diff --git a/EchoRelay.GameServer/messages.h b/EchoRelay.GameServer/messages.h new file mode 100644 index 0000000..6fe0c98 --- /dev/null +++ b/EchoRelay.GameServer/messages.h @@ -0,0 +1,100 @@ +#pragma once + +#include "EchoVR.h" + + +// Symbols representing messages to the broadcaster + +const EchoVR::SymbolId SYMBOL_BROADCASTER_LOBBY_REGISTRATION_SUCCESS = 0xFEF8EFEC97A3B98; +const EchoVR::SymbolId SYMBOL_BROADCASTER_LOBBY_REGISTRATION_FAILURE = 0xCC3A40870CDBC852; +const EchoVR::SymbolId SYMBOL_BROADCASTER_LOBBY_SESSION_STARTING = 0x233E6E7E3A13BABC; +const EchoVR::SymbolId SYMBOL_BROADCASTER_LOBBY_SESSION_ERROR = 0x425393736F0CDB8B; +const EchoVR::SymbolId SYMBOL_BROADCASTER_LOBBY_TERMINATE_PROCESS = 0xF0FA52B6F8A33A49; + +const EchoVR::SymbolId SYMBOL_BROADCASTER_LOBBY_START_SESSION_V4 = 0x96101C684E7F325; +const EchoVR::SymbolId SYMBOL_BROADCASTER_LOBBY_JOIN_REQUESTED_V4 = 0xB4D724E8564BFE88; +const EchoVR::SymbolId SYMBOL_BROADCASTER_LOBBY_ADD_ENTRANT_REQUEST_V4 = 0x5A44AB3E283D136D; +const EchoVR::SymbolId SYMBOL_BROADCASTER_LOBBY_SESSION_SUCCESS_V5 = 0x83E96504A9FC81C6; +const EchoVR::SymbolId SYMBOL_BROADCASTER_LOBBY_ACCEPT_PLAYERS_SUCCESS_V2 = 0xFCBA8F2834F8DE40; +const EchoVR::SymbolId SYMBOL_BROADCASTER_LOBBY_ACCEPT_PLAYERS_FAILURE_V2 = 0xED9A4B86F8F3640A; +const EchoVR::SymbolId SYMBOL_BROADCASTER_LOBBY_SMITE_ENTRANT = 0xCCBC52F97F2E0EF1; +const EchoVR::SymbolId SYMBOL_BROADCASTER_LOBBY_CHAT_ENTRY = 0xDCB7130D1BEB9AC4; +const EchoVR::SymbolId SYMBOL_BROADCASTER_LOBBY_VOICE_ENTRY = 0x27504F14881C1A43; + +// Symbols representing messages to the serverdb. + +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_REGISTRATION_REQUEST = 0x7777777777777777; // unofficial +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_REGISTRATION_SUCCESS = -5369924845641990433; +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_REGISTRATION_FAILURE = -5373034290044534839; +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_SESSION_SUCCESS_V5 = 0x6d4de3650ee3110e; +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_START_SESSION = 0x7777777777770000; // unofficial +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_SESSION_STARTED = 0x7777777777770100; // unofficial +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_END_SESSION = 0x7777777777770200; // unofficial +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_PLAYER_SESSIONS_LOCKED = 0x7777777777770300; // unofficial +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_PLAYER_SESSIONS_UNLOCKED = 0x7777777777770400; // unofficial +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_ACCEPT_PLAYERS = 0x7777777777770500; // unofficial +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_PLAYERS_ACCEPTED = 0x7777777777770600; // unofficial +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_PLAYERS_REJECTED = 0x7777777777770700; // unofficial +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_PLAYERS_REMOVE_PLAYER = 0x7777777777770800; // unofficial +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_CHALLENGE_REQUEST = 0x7777777777770900; // unofficial +const EchoVR::SymbolId SYMBOL_TCPBROADCASTER_LOBBY_CHALLENGE_RESPONSE = 0x7777777777770A00; // unofficial + +/// +/// A message sent from game server to server to register the game server. +/// +struct ERLobbyRegistrationRequest +{ + UINT64 serverId; + UINT32 internalIp; + UINT16 port; + BYTE padding[4]; + EchoVR::SymbolId regionId; + EchoVR::SymbolId versionLock; +}; + +/// +/// A message sent from game server to server to indicate the current session has ended. +/// +struct ERLobbyEndSession { + CHAR unused; +}; + +/// +/// A message sent from game server to server to indicate the current session has been locked. +/// +struct ERLobbyPlayerSessionsLocked { + CHAR unused; +}; + +/// +/// A message sent from game server to server to indicate the current session has been unlocked. +/// +struct ERLobbyPlayerSessionsUnlocked { + CHAR unused; +}; + +/// +/// A message sent from server to game server, describing a new session to be started. +/// This is processed into a SNSLobbyStartSessionv4 before being forwarded as a local event. +/// +struct ERGameServerStartSession { + GUID sessionUuid; + UINT64 playerLimit; + EchoVR::LobbyType lobbyType; + BYTE padding[7]; + CHAR jsonStart; +}; + +/// +/// A message sent across the UDP game server broadcaster (or local events), indicating a new session should be started. +/// This actually invokes the starting of the game session. +/// +struct SNSLobbyStartSessionv4 +{ + GUID sessionUuid; + UINT64 playerLimit; + EchoVR::LobbyType lobbyType; + BYTE padding[7]; + const CHAR* json; // 0x30? + UINT64 jsonLen; // 0x38? +}; diff --git a/EchoRelay.Patch/EchoRelay.Patch.vcxproj b/EchoRelay.Patch/EchoRelay.Patch.vcxproj new file mode 100644 index 0000000..8cbf320 --- /dev/null +++ b/EchoRelay.Patch/EchoRelay.Patch.vcxproj @@ -0,0 +1,123 @@ + + + + + Debug + x64 + + + Release + x64 + + + + 16.0 + Win32Proj + {53f7621a-b5ae-4029-a9d1-2b0d6fe0af77} + EchoRelayPatch + 10.0 + + + + DynamicLibrary + true + v143 + Unicode + + + DynamicLibrary + false + v143 + true + Unicode + + + + + + + + + + + + + + + ../common;$(IncludePath) + + + ../common;$(IncludePath) + + + + Level3 + true + _DEBUG;ECHORELAY_PATCH_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + NotUsing + pch.h + + + + + Windows + true + false + %(AdditionalDependencies) + + + + + + + + + Level3 + true + true + true + NDEBUG;ECHORELAY_PATCH_EXPORTS;_WINDOWS;_USRDLL;%(PreprocessorDefinitions) + true + NotUsing + + + + + + + Windows + true + true + true + false + %(AdditionalDependencies) + + + + + + + + + + + + + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + \ No newline at end of file diff --git a/EchoRelay.Patch/EchoRelay.Patch.vcxproj.filters b/EchoRelay.Patch/EchoRelay.Patch.vcxproj.filters new file mode 100644 index 0000000..b53f0d7 --- /dev/null +++ b/EchoRelay.Patch/EchoRelay.Patch.vcxproj.filters @@ -0,0 +1,37 @@ + + + + + {4FC737F1-C7A5-4376-A066-2A32D752A2FF} + cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx + + + {93995380-89BD-4b04-88EB-625FBE52EBFB} + h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd + + + {67DA6AB6-F800-4c08-8B7A-83BB121AAD01} + rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms + + + + + Header Files + + + Header Files + + + + + Source Files + + + Source Files + + + + + + + \ No newline at end of file diff --git a/EchoRelay.Patch/README.md b/EchoRelay.Patch/README.md new file mode 100644 index 0000000..953a3c7 --- /dev/null +++ b/EchoRelay.Patch/README.md @@ -0,0 +1,23 @@ +# EchoRelay.Patch + +This library is intended to be loaded alongside Echo VR. It applies startup patches and hooks to add to, unlock, and fix functionality within Echo VR. + +To install this component, read the installation instructions within the solution's [README](../README.md). + +## Patches + +`EchoRelay.Patch` adds or updates a number of Echo VR's command-line arguments: +- `-server`: A new CLI argument that runs an Echo VR instance as a game server. It will automatically register itself to the `SERVERDB` service on the central server after startup. It is then ready to serve users requesting matchmaking. When a client is matched, the new session will be started. When all clients disconnect, the game server will end the game session and await a new one from matched clients (recycling itself). +- `-offline`: A new CLI argument that enables offline gameplay. This must be paired with a `-level`, `-gametype` and `-region` argument to successfully load a desired level. +- `-windowed`: A new CLI argument that allows the game to be run in a windowed mode (rather than through a VR headset). This is similar to the original `-spectatorstream` argument, but without requesting a spectator game on startup. +- `-headless`: This existing CLI command was intended to start the game in a console window without graphics/audio, but typically crashed the game on startup. This feature was partially redesigned. + +In addition to updated CLI commands, `EchoRelay.Patch` also applies the following patches: +- Adds support for a `apiservice_host` JSON key in the local service config, to override the HTTP(S) API server URI typically hardcoded in the game. +- Allows `-noovr` to be used without `-spectatorstream`, allowing demo profiles to be used with a VR headset or within `-windowed` mode. +- (If compiled in `DEBUG` build configuration) Disables the deadlock monitor which ensures threads do not hang. This is inadvertently triggered when setting breakpoints on Echo VR for too long, which circumvents research efforts. Removing it bypasses this, but should not be used outside of testing, in case a real deadlock occurs which the game does not respond to. + +## Known issues + +- `-headless` produces much higher than usual CPU usage. +- `-headless`'s console window is prone to thread blocking/crashing if you hold a mouse key down on the console window or otherwise block the window UI, as it may block any game threads trying to log a message in the window. diff --git a/EchoRelay.Patch/dllmain.cpp b/EchoRelay.Patch/dllmain.cpp new file mode 100644 index 0000000..9123cc1 --- /dev/null +++ b/EchoRelay.Patch/dllmain.cpp @@ -0,0 +1,23 @@ +// dllmain.cpp : Defines the entry point for the DLL application. +#include "pch.h" +#include "patches.h" + + +BOOL APIENTRY DllMain( HMODULE hModule, + DWORD ul_reason_for_call, + LPVOID lpReserved + ) +{ + switch (ul_reason_for_call) + { + case DLL_PROCESS_ATTACH: + Initialize(); + break; + case DLL_THREAD_ATTACH: + case DLL_THREAD_DETACH: + case DLL_PROCESS_DETACH: + break; + } + return TRUE; +} + diff --git a/EchoRelay.Patch/packages.config b/EchoRelay.Patch/packages.config new file mode 100644 index 0000000..31cfc2d --- /dev/null +++ b/EchoRelay.Patch/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/EchoRelay.Patch/patches.cpp b/EchoRelay.Patch/patches.cpp new file mode 100644 index 0000000..2cf5d9f --- /dev/null +++ b/EchoRelay.Patch/patches.cpp @@ -0,0 +1,433 @@ +#include "echovrunexported.h" +#include "patches.h" +#include "processmem.h" +#include + +/// +/// Indicates whether the patches have been applied (to avoid re-application). +/// +BOOL initialized = false; + +/// +/// A CLI argument flag indicating whether the game is booting as a dedicated server. +/// +BOOL isServer = false; +/// +/// A CLI argument flag indicating whether the game is booting as an offline client. +/// +BOOL isOffline = false; +/// +/// A CLI argument flag indicating whether the game is booting in headless mode (no graphics/audio). +/// +BOOL isHeadless = false; +/// +/// A CLI argument flag indicating whether the game is booting in a windowed mode, rather than with a VR headset. +/// +BOOL isWindowed = false; + +/// +/// The local config stored in ./_local/config.json. +/// +EchoVR::Json* localConfig = NULL; + +/// +/// Reports a fatal error with a message box, then exits the game. +/// +/// The window message to display. +/// The window title to display. +/// None +VOID FatalError(const CHAR* msg, const CHAR* title) +{ + // If no title or msg was provided, set it to a generic value. + if (title == NULL) + title = "Echo Relay: Error"; + if (msg == NULL) + msg = "An unknown error occurred."; + + // Show a message box. + MessageBoxA(NULL, msg, title, MB_OK); + + // Force process exit with an error code. + exit(1); +} + +/// +/// Patches a given function pointer with an hook function (matching the equivalent function signature as the original). +/// +/// The function to detour. +/// The function hook to use as a detour. +/// None +VOID PatchDetour(PVOID* ppPointer, PVOID pDetour) +{ + DetourTransactionBegin(); + DetourUpdateThread(GetCurrentThread()); + DetourAttach(ppPointer, pDetour); + DetourTransactionCommit(); +} + +/// +/// A detour hook for the game's "write log" function. It intercepts overly noisy logs and ensures they are outputted over stdout/stderr for headless mode. +/// +/// The level the message was logged with. +/// TODO: Unknown +/// The format string to log with. +/// The list of variables to use to format the format string before logging. +/// None +VOID WriteLogHook(EchoVR::LogLevel logLevel, UINT64 unk, const CHAR* format, va_list vl) +{ + // Filter out very noisy messages by quitting early. + if (!strcmp(format, "[DEBUGPRINT] %s %s")) + { + // If the overall template matched, format it + CHAR formattedLog[0x1000]; + memset(formattedLog, 0, sizeof(formattedLog)); + vsprintf_s(formattedLog, format, vl); + + // If the final output matches the strings below, we do not log. + if (!strcmp(formattedLog, "[DEBUGPRINT] PickRandomTip: context = 0x41D2C432172E0810")) // noisy in main menu / loading screen + return; + } + else if (!strcmp(format, "[NETGAME] No screen stats info for game mode %s")) // noisy in social lobby + return; + + // Print the ANSI color code prefix for the given log level. + switch (logLevel) + { + case EchoVR::LogLevel::Debug: + printf("\u001B[36m"); + break; + + case EchoVR::LogLevel::Warning: + printf("\u001B[33m"); + break; + + case EchoVR::LogLevel::Error: + printf("\u001B[31m"); + break; + + case EchoVR::LogLevel::Info: + default: + printf("\u001B[0m"); + break; + } + + // Print the output to our allocated console. + vprintf(format, vl); + printf("\n"); + + // Print the ANSI color code for restoring the default text style. + printf("\u001B[0m"); + + // Call the original method + EchoVR::WriteLog(logLevel, unk, format, vl); +} + +/// +/// Patches the game to enable headless mode, spawning a console window and applying patches to avoid game crashes. +/// +/// The pointer to the instance of the game structure. +/// None +VOID PatchEnableHeadless(PVOID pGame) +{ + // Disable audio by clearing the same bits as the `-noaudio` command would. + UINT32* flags = (UINT32*)((CHAR*)pGame + 468); + *flags &= 0xFFFFFFFD; // clear second bit + + // Create a console + // Note: We do this because attaching to the parent process console would already be detached due to /SUBSYSTEM:WINDOWS. + // Attaching two processes to a console at once would be messy and. + AllocConsole(); + + // Redirect our standard streams to the new console. + FILE* fConsole; + freopen_s(&fConsole, "CONIN$", "r", stdin); + freopen_s(&fConsole, "CONOUT$", "w", stderr); + freopen_s(&fConsole, "CONOUT$", "w", stdout); + + // Enable ANSI color coding on the console. + HANDLE hStdOut = GetStdHandle(STD_OUTPUT_HANDLE); + HANDLE hStdErr = GetStdHandle(STD_ERROR_HANDLE); + DWORD consoleMode; + + GetConsoleMode(hStdOut, &consoleMode); + consoleMode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING | DISABLE_NEWLINE_AUTO_RETURN; + SetConsoleMode(hStdOut, consoleMode); + + GetConsoleMode(hStdErr, &consoleMode); + consoleMode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING | DISABLE_NEWLINE_AUTO_RETURN; + SetConsoleMode(hStdErr, consoleMode); + + // Install our hook to capture logs to the console. + PatchDetour(&(PVOID&)EchoVR::WriteLog, WriteLogHook); + + // Patch the engine initialization/configuration to skip initialization of the rendering providers. + BYTE pbPatch[] = { + 0xA8, 0x00 // TEST al, 0 (replaces a test against 1, to skip the renderer initialization). + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0xFF581, pbPatch, sizeof(pbPatch)); + + // Patch effects resource loading to be skipped over. + BYTE pbPatch2[] = { + 0xEB, 0x41 // JMP 0x43 + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0x62CA91, pbPatch2, sizeof(pbPatch2)); +} + +/// +/// Patches the game to run as a dedicated server, exposing its game server broadcast port, adjusting its log file path. +/// +/// None +VOID PatchEnableServer() +{ + // Patch the flags for our game to indicate we are a game server. This replaces checks to see if we + // are a server, with code to set the flag permanently, and skips over the rest of the checking code. + BYTE pbPatch[] = { + 0x48, 0x83, 0x08, 0x06, // OR QWORD ptr[rax], 0x6 (bit 2 = load sessions received from broadcast, bit 3 = patch flag to set as dedicated server) + + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, // NOP instructions to replace server flag checks (with above setting operation) until it sets the 'enabled' flag. + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, + 0x90, 0x90, 0x90, 0x90 + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0x1580C3, pbPatch, sizeof(pbPatch)); + + // Patch to avoid enabling "r14netserver" logging, as this depends on files we do not have and will panic. + BYTE pbPatch2[] = { + 0x48, 0x89, 0xC3, 0x90 // NOPs to avoid a comparison->move to overwrite "r14netserver" + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0xFFA58, pbPatch2, sizeof(pbPatch2)); + + // Patch the update the logging subject to "r14(server)" + BYTE pbPatch3[] = { + 0xEB, 0x0E + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0xFFB0E, pbPatch3, sizeof(pbPatch3)); + + // Patch the ./sourcedb/rad15/json/r14/config/netconfig_*.json file parsing routine so the "allow_incoming" key is always interpreted as `true`. + // This is necessary for a game server to accept players. Otherwise they will be denied, disallowing client connections. + BYTE pbPatch4[] = { + 0xB8, 0x01, 0x00, 0x00, 0x00 // MOV eax, 1 (set the flag to `true`). + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0xF7F904, pbPatch4, sizeof(pbPatch4)); + + // Patch the CLI pre-processing method to assume the process was provided "-spectatorstream". + // This causes the game to enter a "load lobby" state, which as a game server, starts the game server on startup. + // Otherwise, you would need to manually click the "play" button before the server began serving. + BYTE pbPatch5[] = { + 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 // NOP the jump that is taken if "-spectatorstream" is not provided. + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0x116F3D, pbPatch5, sizeof(pbPatch5)); +} + +/// +/// Patches the game to run as an offline client, loading a game of the configuration specified by -gametype, -level, and -region CLI arguments. +/// +/// None +VOID PatchEnableOffline() +{ + // Patch "starting multiplayer" + BYTE pbPatch[] = { + 0xE8, 0xCD, 0x02, 0x00, 0x00 + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0xFDE0E, pbPatch, sizeof(pbPatch)); + + // Patch "incidents" + BYTE pbPatch3[] = { + 0x75, 0x0A, // TODO: Can probably be made JMP (0xEB) / NOP + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0x17F0B1, pbPatch3, sizeof(pbPatch3)); + + // TODO: Title + BYTE pbPatch4[] = { + 0x74, 0x12, // TODO: Can probably be made JMP (0xEB) / NOP + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0x17F77B, pbPatch4, sizeof(pbPatch4)); + + // Force transaction service to load + BYTE pbNopConditionalJump[] = { + 0x90, 0x90, // NOP condition jump + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0x17F817, pbNopConditionalJump, sizeof(pbNopConditionalJump)); + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0x17F823, pbNopConditionalJump, sizeof(pbNopConditionalJump)); + + // Skip failed logon service code + BYTE pbPatch5[] = { + 0xE9, 0x92, 0x00, 0x00, 0x00, 0x00 // JMP 0x97 + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0x1AC83E, pbPatch5, sizeof(pbPatch5)); + + // Redirect "beginning tutorial" + BYTE pbPatch6[] = { + 0xE8, 0xD6, 0x17, 0x68, 0xFF + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0xA7C685, pbPatch6, sizeof(pbPatch6)); +} + +/// +/// Patches the game to allow -noovr (demo accounts) without use of spectator stream. This provides a temporary player profile. +/// +/// None +VOID PatchNoOvrRequiresSpectatorStream() +{ + // Patch "-noovr requires -spectatorstream" to allow us to use -noovr independently. + BYTE pbPatch[] = { + 0xEB, 0x35 // JMP (past the respective code). + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0x11690D, pbPatch, sizeof(pbPatch)); +} + +/// +/// Patches the dead lock monitor, which monitors threads to ensure they have not stopped processing. If one does, it triggers a fatal error. +/// This patch is provided to ensure breakpoints set during testing do not trigger the deadlock monitor, thereby killing the process. +/// +/// None +VOID PatchDeadlockMonitor() +{ + // Patch out the deadlock monitor thread's validation routine. This is necessary during debugging, as this thread acts as a watchdog + // for updates and will panic if an update has not occurred for some time (e.g. waiting too long between breakpoints when debugging). + BYTE pbPatch[] = { + 0x90, 0x90 // NOPs (to replace the JLE instruction which checks failing deadlock conditions). + }; + ProcessMemcpy(EchoVR::g_GameBaseAddress + 0x1D3881, pbPatch, sizeof(pbPatch)); +} + +/// +/// A detour hook for the game's method it uses to build CLI argument definitions. +/// Adds additional definitions to the structure, so that they may be parsed successfully without error. +/// +/// A pointer to the game instance. +/// A pointer to the CLI argument structure tracking all CLI arguments. +UINT64 BuildCmdLineSyntaxDefinitionsHook(PVOID pGame, PVOID pArgSyntax) +{ + // Add all original CLI argument options. + UINT64 result = EchoVR::BuildCmdLineSyntaxDefinitions(pGame, pArgSyntax); + + // Add our additional options + EchoVR::AddArgSyntax(pArgSyntax, "-server", 0, 0, FALSE); + EchoVR::AddArgHelpString(pArgSyntax, "-server", "[patch] Run as a dedicated game server"); + + EchoVR::AddArgSyntax(pArgSyntax, "-offline", 0, 0, FALSE); + EchoVR::AddArgHelpString(pArgSyntax, "-offline", "[patch] Run the game in offline mode"); + + EchoVR::AddArgSyntax(pArgSyntax, "-windowed", 0, 0, FALSE); + EchoVR::AddArgHelpString(pArgSyntax, "-windowed", "[patch] Run the game with no headset, in a window"); + + return result; +} + +/// +/// A detour hook for the game's command line pre-processing method, used to parse command line arguments. +/// +/// A pointer to the game instance. +UINT64 PreprocessCommandLineHook(PVOID pGame) +{ + // Check which were set with command line arguments. + int argc; + LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); + for (int i = 0; i < argc; i++) + { + if (lstrcmpW(argv[i], L"-server") == 0) + isServer = true; + else if (lstrcmpW(argv[i], L"-offline") == 0) + isOffline = true; + else if (lstrcmpW(argv[i], L"-headless") == 0) + isHeadless = true; + else if (lstrcmpW(argv[i], L"-windowed") == 0) + isWindowed = true; + } + + // Verify server and offline flags are not enabled. + if (isServer && isOffline) + FatalError("-server and -offline arguments cannot be provided at the same time.", NULL); + + // If offline flag was provided, enable offline. + if (isOffline) + PatchEnableOffline(); + + // If the headless flag was provided, enable it. + if (isHeadless) + PatchEnableHeadless(pGame); + + // If the windowed, server, or headless flags were provided, apply the windowed mode patch to not use a VR headset. + if (isWindowed || isServer || isHeadless) + { + // Set the game to run in windowed mode. + UINT64* flags = (UINT64*)((CHAR*)pGame + 31456); + *flags |= 0x0100000; // Spectator stream uses 0x2100000 (an additional flag). This changes level setting in some way(?). Seemingly unnecessary here. + } + + // Apply patches to force the game to load as a server. + if (isServer) + PatchEnableServer(); + + // Run the original method + UINT64 result = EchoVR::PreprocessCommandLine(pGame); + return result; +} + +/// +/// A detour hook for the game's function to load the local config.json for the game instance. +/// +/// A pointer to the game struct to load the config for. +UINT64 LoadLocalConfigHook(PVOID pGame) +{ + // Store a reference to the local config. + localConfig = (EchoVR::Json*)((CHAR*)pGame + 0x63240); + return EchoVR::LoadLocalConfig(pGame); +} + +/// +/// A detour hook for the game's method it uses to connect to an HTTP(S) endpoint. This is used to redirect additional hardcoded endpoints in the game. +/// +/// TODO: Unknown +/// The HTTP(S) URI string to connect to. +UINT64 HttpConnectHook(PVOID unk, CHAR* uri) +{ + // If we have a local config, check for additional service overrides. + if (localConfig != NULL) + { + // Perform overrides for different hosts + CHAR* originalApiHostPrefix = (CHAR*)"https://api."; + CHAR* originalOculusGraphHost = (CHAR*)"https://graph.oculus.com"; + if (!strncmp(uri, originalApiHostPrefix, strlen(originalApiHostPrefix))) + { + // Check for JSON keys definition host overrides. + uri = EchoVR::JsonValueAsString(localConfig, (CHAR*)"api_host", uri, false); + uri = EchoVR::JsonValueAsString(localConfig, (CHAR*)"apiservice_host", uri, false); + } + else if (!strncmp(uri, originalOculusGraphHost, strlen(originalOculusGraphHost))) + { + // Check for JSON keys definition host overrides. + uri = EchoVR::JsonValueAsString(localConfig, (CHAR*)"graph_host", uri, false); + uri = EchoVR::JsonValueAsString(localConfig, (CHAR*)"graphservice_host", uri, false); + } + } + + // Call the original function + return EchoVR::HttpConnect(unk, uri); +} + +/// +/// Initializes the patcher, executing startup patchs on the game and installing detours/hooks on various game functions. +/// +/// None +VOID Initialize() +{ + // If we already initialized the library, stop. + if (initialized) + return; + initialized = true; + + // Patch our CLI argument options to add our additional options. + PatchDetour(&(PVOID&)EchoVR::BuildCmdLineSyntaxDefinitions, BuildCmdLineSyntaxDefinitionsHook); + PatchDetour(&(PVOID&)EchoVR::PreprocessCommandLine, PreprocessCommandLineHook); + PatchDetour(&(PVOID&)EchoVR::LoadLocalConfig, LoadLocalConfigHook); + PatchDetour(&(PVOID&)EchoVR::HttpConnect, HttpConnectHook); + + // Run some startup patches + PatchNoOvrRequiresSpectatorStream(); + + // Patch out the deadlock monitor thread's validation routine if we're compiling in debug mode, as this will panic from process suspension. +#if _DEBUG + PatchDeadlockMonitor(); +#endif +} \ No newline at end of file diff --git a/EchoRelay.Patch/patches.h b/EchoRelay.Patch/patches.h new file mode 100644 index 0000000..625701c --- /dev/null +++ b/EchoRelay.Patch/patches.h @@ -0,0 +1,4 @@ +#pragma once +#include "pch.h" + +VOID Initialize(); diff --git a/EchoRelay.Patch/processmem.h b/EchoRelay.Patch/processmem.h new file mode 100644 index 0000000..266fc03 --- /dev/null +++ b/EchoRelay.Patch/processmem.h @@ -0,0 +1,39 @@ +#pragma once +#include "pch.h" + +/// +/// Copies memory from a source buffer of a given size to a destination process memory buffer. +/// +/// The process address where the source buffer should be copied to. +/// The source buffer to copy to process memory. +/// The size of the data to copy. +/// None +VOID ProcessMemcpy(PVOID pDestAddr, PVOID pSrcAddr, size_t szSrcSize) +{ + // Change the memory protection on the given address range, write process memory, then restore the original protection. + DWORD dwOldProtect; + if (VirtualProtect(pDestAddr, szSrcSize, PAGE_EXECUTE_READWRITE, &dwOldProtect)) + { + WriteProcessMemory(GetCurrentProcess(), pDestAddr, pSrcAddr, szSrcSize, NULL); + VirtualProtect(pDestAddr, szSrcSize, dwOldProtect, &dwOldProtect); + } +} + +/// +/// Sets a buffer of the given size in process memory to the provided byte value. +/// +/// The process address where the memory should be set. +/// The value to set each byte to. +/// The size of the destination buffer to set. +/// None +VOID ProcessMemset(PVOID pDestAddr, BYTE val, size_t szDestSize) +{ + // Memset a new buffer, copy it over, and free it. + char* pbScratchPad = (char*)malloc(szDestSize); + if (pbScratchPad != NULL) + { + memset(pbScratchPad, val, szDestSize); + ProcessMemcpy(pDestAddr, pbScratchPad, szDestSize); + free(pbScratchPad); + } +} diff --git a/EchoRelay.sln b/EchoRelay.sln new file mode 100644 index 0000000..59c9110 --- /dev/null +++ b/EchoRelay.sln @@ -0,0 +1,83 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.6.33815.320 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EchoRelay.GameServer", "EchoRelay.GameServer\EchoRelay.GameServer.vcxproj", "{BBEFCC27-4FD2-4F18-9BA2-5977026A143C}" +EndProject +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "EchoRelay.Patch", "EchoRelay.Patch\EchoRelay.Patch.vcxproj", "{53F7621A-B5AE-4029-A9D1-2B0D6FE0AF77}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EchoRelay.Core", "EchoRelay.Core\EchoRelay.Core.csproj", "{40A0DB64-3C5A-4634-8BF8-DCDC01502118}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EchoRelay.App", "EchoRelay.App\EchoRelay.App.csproj", "{D9D6C0CA-7635-484A-BD20-91D11F36CC31}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "common", "common", "{082AAAF6-2918-491A-A01C-E28773C873F6}" + ProjectSection(SolutionItems) = preProject + common\echovr.h = common\echovr.h + common\echovrunexported.h = common\echovrunexported.h + common\pch.h = common\pch.h + EndProjectSection +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EchoRelay.Core.Test", "EchoRelay.Core.Test\EchoRelay.Core.Test.csproj", "{AC74979A-48B7-460E-8590-0A78280813EB}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{0676E88F-FAF3-47AF-AC44-BD0EAF162327}" + ProjectSection(SolutionItems) = preProject + README.md = README.md + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BBEFCC27-4FD2-4F18-9BA2-5977026A143C}.Debug|Any CPU.ActiveCfg = Debug|x64 + {BBEFCC27-4FD2-4F18-9BA2-5977026A143C}.Debug|Any CPU.Build.0 = Debug|x64 + {BBEFCC27-4FD2-4F18-9BA2-5977026A143C}.Debug|x64.ActiveCfg = Debug|x64 + {BBEFCC27-4FD2-4F18-9BA2-5977026A143C}.Debug|x64.Build.0 = Debug|x64 + {BBEFCC27-4FD2-4F18-9BA2-5977026A143C}.Release|Any CPU.ActiveCfg = Release|x64 + {BBEFCC27-4FD2-4F18-9BA2-5977026A143C}.Release|Any CPU.Build.0 = Release|x64 + {BBEFCC27-4FD2-4F18-9BA2-5977026A143C}.Release|x64.ActiveCfg = Release|x64 + {BBEFCC27-4FD2-4F18-9BA2-5977026A143C}.Release|x64.Build.0 = Release|x64 + {53F7621A-B5AE-4029-A9D1-2B0D6FE0AF77}.Debug|Any CPU.ActiveCfg = Debug|x64 + {53F7621A-B5AE-4029-A9D1-2B0D6FE0AF77}.Debug|Any CPU.Build.0 = Debug|x64 + {53F7621A-B5AE-4029-A9D1-2B0D6FE0AF77}.Debug|x64.ActiveCfg = Debug|x64 + {53F7621A-B5AE-4029-A9D1-2B0D6FE0AF77}.Debug|x64.Build.0 = Debug|x64 + {53F7621A-B5AE-4029-A9D1-2B0D6FE0AF77}.Release|Any CPU.ActiveCfg = Release|x64 + {53F7621A-B5AE-4029-A9D1-2B0D6FE0AF77}.Release|Any CPU.Build.0 = Release|x64 + {53F7621A-B5AE-4029-A9D1-2B0D6FE0AF77}.Release|x64.ActiveCfg = Release|x64 + {53F7621A-B5AE-4029-A9D1-2B0D6FE0AF77}.Release|x64.Build.0 = Release|x64 + {40A0DB64-3C5A-4634-8BF8-DCDC01502118}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {40A0DB64-3C5A-4634-8BF8-DCDC01502118}.Debug|Any CPU.Build.0 = Debug|Any CPU + {40A0DB64-3C5A-4634-8BF8-DCDC01502118}.Debug|x64.ActiveCfg = Debug|Any CPU + {40A0DB64-3C5A-4634-8BF8-DCDC01502118}.Debug|x64.Build.0 = Debug|Any CPU + {40A0DB64-3C5A-4634-8BF8-DCDC01502118}.Release|Any CPU.ActiveCfg = Release|Any CPU + {40A0DB64-3C5A-4634-8BF8-DCDC01502118}.Release|Any CPU.Build.0 = Release|Any CPU + {40A0DB64-3C5A-4634-8BF8-DCDC01502118}.Release|x64.ActiveCfg = Release|Any CPU + {40A0DB64-3C5A-4634-8BF8-DCDC01502118}.Release|x64.Build.0 = Release|Any CPU + {D9D6C0CA-7635-484A-BD20-91D11F36CC31}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D9D6C0CA-7635-484A-BD20-91D11F36CC31}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D9D6C0CA-7635-484A-BD20-91D11F36CC31}.Debug|x64.ActiveCfg = Debug|Any CPU + {D9D6C0CA-7635-484A-BD20-91D11F36CC31}.Debug|x64.Build.0 = Debug|Any CPU + {D9D6C0CA-7635-484A-BD20-91D11F36CC31}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D9D6C0CA-7635-484A-BD20-91D11F36CC31}.Release|Any CPU.Build.0 = Release|Any CPU + {D9D6C0CA-7635-484A-BD20-91D11F36CC31}.Release|x64.ActiveCfg = Release|Any CPU + {D9D6C0CA-7635-484A-BD20-91D11F36CC31}.Release|x64.Build.0 = Release|Any CPU + {AC74979A-48B7-460E-8590-0A78280813EB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AC74979A-48B7-460E-8590-0A78280813EB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AC74979A-48B7-460E-8590-0A78280813EB}.Debug|x64.ActiveCfg = Debug|Any CPU + {AC74979A-48B7-460E-8590-0A78280813EB}.Debug|x64.Build.0 = Debug|Any CPU + {AC74979A-48B7-460E-8590-0A78280813EB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AC74979A-48B7-460E-8590-0A78280813EB}.Release|Any CPU.Build.0 = Release|Any CPU + {AC74979A-48B7-460E-8590-0A78280813EB}.Release|x64.ActiveCfg = Release|Any CPU + {AC74979A-48B7-460E-8590-0A78280813EB}.Release|x64.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {CFD94808-C8AB-448D-9A14-10ECFD9DA2ED} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md new file mode 100644 index 0000000..496ef8f --- /dev/null +++ b/README.md @@ -0,0 +1,214 @@ +# EchoRelay + +## About + +`EchoRelay` is a proof-of-concept reimplementation of [Echo VR](https://en.wikipedia.org/wiki/Lone_Echo)'s web services and dedicated game servers. It was created for +educational/research purposes, to explore video game backend infrastructure design and service implementations. + +This project's aim is not to enable unofficial matches for the public, please read the see the [Disclaimer](#disclaimer) +section for more information about the project, its aim, goals, and sensitivites. + +![EchoRelay ](./EchoRelay.App/Resources/screenshot.png) + +Echo VR's official servers were shutdown on August 1st, 2023. `EchoRelay` established the first networked match since that time, on August 29th, 2023. This project was made public October 31st, 2023. + +## Features + +The following features are supported by `EchoRelay`: + +- ✔️ Extended Echo VR command-line arguments to launch the game: + - ✔️ As an offline client (no server) + - ✔️ As a dedicated game server + - ✔️ In windowed mode (no VR headset) + - ✔️ In headless mode, a console-based process with no graphics or audio + - ✔️ Use `-noovr` without `-spectatorstream`, allowing demo profiles with a VR headset or in windowed mode +- ✔️ Support for most standard in-game features: + - ✔️ Social Lobby + - ✔️ Echo Arena + - ✔️ Echo Combat + - ✔️ Local AI matches + - ❌ Cooperative AI matches + - ✔️ Public or private match game types + - ✔️ Spectator and Game Admin (moderator) support + - ✔️ Support for different client flows (e.g. `-lobbyid`, which requests joining a specific lobby by UUID) + - ❌ Partying-up with friends in a squad + - ❌ Persisted armor changes/updates across game sessions +- ✔️ Support for basic server operator and administrator flows: + - ✔️ Kick users from game session + - ✔️ Ban accounts until a given date/time + - ✔️ Enforce allowed/denied clients through IP-based Access Control Lists. + - ✔️ Modify server-provided resources such as accounts, login settings, channel descriptions, etc. +- ✔️ Support for most network messages, e.g. profile fetches and updates, server resource fetching, matching, etc. + + +## Architecture + +### Design + +At a high level, Echo VR's backend infrastructure can be boiled down to: + +1. **Central services**: + - `LOGIN` (websocket): User login, session management, account operations. + - `CONFIG` (websocket): Seasonal in-game settings (e.g. season start/end, in-game display banners). + - `MATCHING` (websocket): Fulfills client requests for matchmaking with a game server registered with `SERVERDB`. + - `SERVERDB` (websocket): Game server registration and game session management for clients joining through `MATCHING`. + - `TRANSACTION` (websocket): Provides an in-game store and transaction processing. + - `API` (HTTP/HTTPS): An API server used to report server status, news, and record additional data. +2. **Dedicated game servers**: responsible for actually hosting game lobbies for clients to matchmake to. + - For the original developers, a special build of the game is presumably built, which exposes routines that run Echo VR in dedicated server mode. + - Dedicated game servers load a `pnsradgameserver.dll` library from the game folder after startup (if it exists). This library satisfies an interface for the game to register and communicate with `SERVERDB` and coordinate game sessions/matching. + - Game servers register themselves through the `SERVERDB` service, and clients matchmake to a game server through the `MATCHING` service (invoking further communication between `SERVERDB` and the dedicated game server to accept the client). + - Clients connect to a UDP port exposed on dedicated game servers, exposed to them by `MATCHING`. + +### Implementation + +`EchoRelay` provides a C#.NET implementation of the abovementioned central services, and a library + patches to run the Echo VR game as a game server. Each project directory contains its own README with additional information: + +- [**EchoRelay.Core**](./EchoRelay.Core/): A C#.NET library providing an implementation of the central server with supported services. +- [**EchoRelay.App**](./EchoRelay.App/): A simple/silly C#.NET WinForms UI app providing visual configuration, operation, and monitoring of a central server powered by `EchoRelay.Core`. +- [**EchoRelay.Patch**](./EchoRelay.Patch/): A C++ library to be loaded alongside Echo VR. It applies patches to the game on startup, enabling additional CLI commands in Echo VR (e.g. `-server`, required to operate a game server). +- [**EchoRelay.GameServer**](./EchoRelay.GameServer/): A C++ library which reimplements the interface the game expects from `pnsradgameserver.dll`. It accepts requests to register the game server, listens for websocket messages from `SERVERDB` such as starting a new session, accepting new players, rejecting/kicking a player, etc. + - This introduces unofficial websocket messages, likely similar to the original `pnsradgameserver.dll`, but specific to `EchoRelay.Core`'s central service reimplementation. + + +## Installation + +### Requirements + +- A legitimately owned, licensed copy of Echo VR (version 34.4.631547.1). +- Windows 10+ +- .NET 7.0 SDK +- Microsoft Visual Studio 2022 with C# and C++ (vc143) installed. + + +### Building the solution + +- Clone this repository and open the solution (.sln) file in Visual Studio. +- Build the solution, it should succeed without any errors. + - If you encounter an error related to a missing MSDetours dependency, manually install the MSDetours 4.0.1 nuget package to the `EchoRelay.Patch` project. + +### Setting up central services + +1. Run the `EchoRelay.App` you built, as an administrator. It will ask you to configure a storage folder and locate the Echo VR game executable. Note the TCP port number you set here. +2. Within the application, click the button with the "Play" icon to start the server, if it isn't started/listening already. +3. Note the application should now display a generated "service config" containing your endpoints necessary to connect to the server. This will be important to configure game servers and clients later. +4. Ensure your access controls are appropriately set in the `Storage`->`Access Controls` tab of the application, to prevent unwanted connections from being established. +5. Port forward the TCP port you configured for `EchoRelay.App` on your router (and Windows or software-based firewalls, if enabled). + - **Context**: This is necessary, as `EchoRelay` only supports external/public IPs connections, hence why the service config is generated with your external IP. + +### Setting up game servers + +1. Rename the `EchoRelay.Patch.dll` you built to `dbgcore.dll` and place it in your game folder where `echovr.exe` is located. +2. Rename the `EchoRelay.GameServer.dll` you built to `pnsradgameserver.dll` and place it in your game folder where `echovr.exe` is located. +3. The service config generated in Step 3 of the central services setup should be saved to `ready-at-dawn-echo-arena/_local/config.json` in your game folder. + - You should update the `displayname` and `auth` parameters of the login service URI to values personal to your account, as specified by the [FAQs](#faqs) section of this document. + - **Context**: Echo VR uses this config at this file path to determine the endpoints for its services. +4. Look at `ready-at-dawn-echo-arena\sourcedb\rad15\json\r14\config\netconfig_dedicatedserver.json`, and note the `port` and `retries` JSON keys. + - `port` will be the first UDP port that game servers you start will try to bind to. If they fail, they will increase the port number for as many `retries` as defined, and try again. + - e.g. With a `port` of 1234 and a `retries` set to 10, game servers will bind to the first available UDP port in the range: 1234-1244 + - You may wish to increase `retries` in the previous step to a larger number. This will avoid multiple game servers/clients reserving all these ports. Note that `netconfig_client.json` is configured by default to also reserve one of these ports from the same range when a client is run. +5. Port forward the UDP game server port range on your router (and Windows/software firewalls, if enabled). Ensure the external port matches the internal port when forwarding. + +Note: If you wish to run game servers from multiple machines, they can all register to the same `EchoRelay.App`. +- Ensure the game server ports observed in Step 4 are set to different values on each machine on the same network, to avoid port collisions. + +### Setting up clients + +The service config generated in Step 3 of the central services setup should be saved to `ready-at-dawn-echo-arena/_local/config.json` in your game folder. + - If a machine is meant to run client-only, the `serverdb_host` key in the service config should not be provided, or be removed, as it allows game server registration. + - You should update the `displayname` and `auth` parameters of the login service URI to values personal to your account, as specified by the [FAQs](#faqs) section of this document. + - **Context**: Echo VR uses this config at this file path to determine the endpoints for its services. + +### Playing Echo VR + +Now your game is set up to use your `EchoRelay.App`'s endpoint. Whether you run the game as a client (normally), or as a dedicated server (`-server`), +it will now connect to your `EchoRelay.App` server and be able to create/log into accounts, etc. + +Start at least two game server instances, one for your player's current lobby, another for the player to transition to a new lobby with a new gametype. +- This can be done with quick launch options within `EchoRelay.App` under `Tools`->`Launch Echo VR` if you are running game servers from the same machine. +- Alternatively, launch the game with a command such as `echovr.exe -server -windowed -noovr` or `echovr.exe -server -headless -noovr` through CLI. + - Dedicated game servers have primarily been tested with `-noovr` to avoid additional complexities of non-demo accounts + the OVR platform. Although using the OVR platform as a dedicated server is unlikely to be problematic in practice, it is not recommended. + +Now any configured clients can simply start the game as they normally would, and play! + +## Disclaimer + +This project is not intended to host unofficial services for the public, nor is it reflective of what true +infrastructure might look like (e.g. use of a real web framework such as ASP.NET, integration with IIS, +real database support, logging, monitoring, cloud-scalable/elastic microservices, secrets management, +load balancing, rate limiting, isolation, etc). + +Instead, `EchoRelay` aims to provide a lightweight and portable proof-of-concept which shows the results of my work to those similarly interested +in exploring these topics for research/educational purposes. The proof-of-concept has been publicized so it can be potentially referenced in a future +document detailing design considerations for game server architecture and sharing reverse engineering techniques for those looking to educate themselves. + +Echo VR is an online-enabled, free-to-play game which had its online services retired on August 1, 2023. Its compiled binaries do not employ +anti-cheat mechanisms or any meaningful security mitigations. This made it an ideal target for me to explore the topic without introducing risk +to an active online ecosystem or its users. + +This project was developed with additional considerations to uphold ethical integrity and ensure fair-use: + +- **EchoRelay DOES NOT contain copyrighted or illegal material**: The code and resources contained within this project were written entirely by me. +- **Entitlement checks HAVE NOT been modified**: You must legally own a license for the game to launch Echo VR or use this project. +- **Unmodified clients CAN NOT mistakenly connect to an unofficial server**: + - Clients MUST explicitly set the endpoints to the new server in their game config to connect. +- **In the event of project misuse, sensitive Oculus account details of a client CAN NOT be directly exposed:** + - Any Oculus data exposed is done so through the [Oculus User Ownership](https://developer.oculus.com/documentation/unity/ps-ownership/) model. + - Under this model, authentication is done service-to-service, with only the real developers having the server-sided `APP_SECRET` needed to authenticate and obtain user information. This is never distributed. + - Clients do not require any code modifications, thus client-side security measures are intact. The patches are only necessary to enable operation of a game server. +- **Paid content (e.g. player skins) unlocks ARE NOT supported**: + - Users are only given unlocks the game provides by default to all users. + - The config resources for store items, battle pass seasons, and other paid content are not implemented. + - The transaction service and store features are purposefully unimplemented. The transaction service implementation only responds with dummy data for one message (to avoid client errors), but does not reveal details regarding a real implementation. +- **Access Control Lists (ACLs) enforce IP-based whitelisting**: Researchers looking to play with this solution are to leverage ACLs to prevent unauthorized parties from communicating with their server instance. +- **Support IS NOT provided**: This project will never aim to go beyond the scope of personal educational research. + - The issue tracker has been purposefully closed. + - I do not care about feature completion. + - I do not care about rich features for end users. + - I do not condone publicly hosting matches with this solution. + - Do not ask for support! + +TLDR: If you're not looking to explore reverse engineering techniques or server/service implementations as a personal researcher, this project is not meant for you. It should not be thought of as a feature-complete or bug-free server solution. + + +## FAQs + +**What are the `displayname` and `auth` parameters in the `LOGIN` service endpoint?** +- Oculus' authentication scheme is not something this project can employ and Oculus account details are not visible to this solution, as noted in the [Disclaimer](#disclaimer) section. +- This is a lazy replacement for Oculus account APIs: + - To provide some kind of account lock, the `auth` parameter, when set once on login, will be verified to match on all future logins to prevent clients with another user's user identifier from logging into their account on the server. + This is a sensitive field that should not be exposed to other users. The intent would be to use a unique throw-away password, as the password will be visible to server operators. + - To provide a way to change your in-game display name, the `displayname` parameter can be used. Your account's display name can be changed on each login. Your account will otherwise remain the same as this is not a user name or unique user identifier. + +**What is API key authentication for `SERVERDB`?** +- This appends an additional URI query parameter (`api_key`) to the `serverdb_host` endpoint in the generated service config. +- This parameter must match the expected value set in `Tools`->`Settings` for all game servers connecting, otherwise they will be rejected. +- This is a lazier authentication implementation that disallows unauthorized game servers, in lieu of a real certificate-based authentication system. +- This should not be exposed to a machine which is not authorized to operating a game server. + +**Why do I need to port forward and use external IPs?** +- I wanted to ensure this project works across networks as a real implementation would, which meant supporting external IPs. +- Your local config file can specify an external IP as an endpoint, which will result in central services seeing a dedicated game server as an external connection, recording the correct IP to serve to remote clients. Yay! +- However, with complex local network configurations, you may find some routers enforcing NAT rules that cause the external request to take a hop internally, registering the wrong IP address (a local one) as the source IP address. The C# API we rely on returns one of the IPs, but you cannot select which. +- This is a lazy solution to ensure compatibility with different network configurations. Better IP address translation can be added to avoid this requirement, but it's uninteresting to invest into for this proof-of-concept. + +**Why don't central services leverage SSL/TLS?** +- Adding TLS support to `EchoRelay.Core`'s server implementation is trivial, but was considered uninteresting for the purpose of this proof-of-concept. +- It would require installation of trusted certificates to your certificate store, which adds a bit more overhead to setup and testing. +- This _may_ require patching for clients anyways, if the game employs TLS certificate pinning (expects the original TLS certificate originally used). + +**Will EchoRelay overwrite my profile prior to the official server shutdown?** +- If you connect with the same machine/account, yes, the new profile from the new server environment will be synced and overwrite any profile with a matching ID saved prior. + +**Is it possible to restore profiles prior to official server shutdown?** +- Yes, local copies of profiles downloaded from servers are stored in `%LOCALAPPDATA%\rad`. +- They can be manually merged into the `EchoRelay.Core` server's account resources. + +**Why is my client profile broken on new server deployments (e.g. permanently 'ghosted')?** +- During heavy testing switching between new environments, I had broken my profile once and became permanently 'ghosted' (unable to appear as my character in social lobbies). It may have been something that I triggered inadvertently outside of `EchoRelay` usage. +- Conceptually, it's possible that some corruption occurs on your local profile state while moving between servers. +- Backup, then clear your local user profile in your `%LOCALAPPDATA%\rad\` folder. It is likely your local client profile is simply corrupted. + +## Shoutouts + +Thanks to [@dualgame](https://github.com/Dualgame) for information about how different user flows are expected to work, you were a tremendous help in providing context, so this could be reimplemented and operate as expected. diff --git a/common/echovr.h b/common/echovr.h new file mode 100644 index 0000000..ce0e84d --- /dev/null +++ b/common/echovr.h @@ -0,0 +1,377 @@ +#pragma once + +namespace EchoVR +{ + // Forward declarations + + class IServerLib; + struct Broadcaster; + struct TcpBroadcaster; + + /// + /// TODO: Allocator structure, used to track heap allocations and provide game plugin modules the ability to use a standardized + /// heap. + /// + struct Allocator {}; + + /// + /// TODO: Some type of pool buffer structure. + /// + struct PoolBuffer {}; + /// + /// TODO: A pool managing arbitrary-type objects by managing their underlying PoolBuffer objects. + /// Note: This is incomplete and reflects an incorrect struct size. + /// + /// The type of object to manage. + template + struct Pool {}; + + /// + /// An array of a given type. + /// + /// The type of elements within this array. + template + struct Array + { + T* items; + UINT64 count; + }; + + /// + /// An array of a given type, allocated with a heap allocator. + /// + /// The type of elements within this array (allocated on heap with a given allocator). + template + struct HeapArray + { + T* items; + UINT64 count; + Allocator* allocator; + }; + + /// + /// TODO: A structure which tracks address info, often represented as a padded sockaddr_in struct. + /// + struct AddressInfo + { + // This can be interpreted as a sockaddr_in struct at the start, the rest is padded. + UINT64 raw[16]; + }; + + /// + /// TODO: Parsed URI object. + /// + const struct UriContainer + { + // TODO: Placeholder to enforce structure size. + CHAR _unk0[0x120]; + }; + + /// + /// A parsed JSON object. + /// + struct Json + { + VOID* root; + VOID* cache; + }; + + /// + /// Describes the level at which logging-related messages should be logged. + /// + enum LogLevel : INT32 + { + Debug = 0x1, + Info = 0x2, + Warning = 0x4, + Error = 0x8, + Default = 0xE, + Any = 0xF, + }; + + /// + /// A structure used to track a method which should be invoked (e.g. as callback) for a given operation. + /// + struct DelegateProxy + { + // The instance of the caller. + VOID* instance; + + // The method to actually call through the proxy wrapper `proxyFunc`. + UINT64 method[2]; + + // The first function to call when the delegate is invoked. This is a wrapper function which is provided + // the `method` and `instance` and prepares the data before invoking the underlying `method`. + VOID* proxyFunc; + }; + + /// + /// A 64-bit integer identifying a given symbol (which has an associated name, not always known). + /// This is obtained through a hashing function. + /// + typedef INT64 SymbolId; + + /// + /// A user's primary identifier for the account/platform they play on. + /// + struct XPlatformId + { + UINT64 platformCode; + UINT64 accountId; + }; + + /// + /// Peer refers to an index of a connected game server peer. + /// + typedef UINT64 Peer; + const Peer Peer_Self = 0xFFFFFFFFFFFFFFFC; + const Peer Peer_AllPeers = 0xFFFFFFFFFFFFFFFD; + const Peer Peer_SelfAndAllPeers = 0xFFFFFFFFFFFFFFFE; + const Peer Peer_InvalidPeer = 0xFFFFFFFFFFFFFFFF; + + + /// + /// Contains information about the UDP game server broadcast socket used by the server. + /// + struct BroadcastSocketInfo + { + UINT64 port : 16; + UINT64 read : 24; + UINT64 write : 24; + UINT64 socket; + + // TODO: Everything past this point is unknown (size of this struct is incorrect). + }; + + /// + /// The underlying data structure for a UDP game server Broadcaster. + /// + struct BroadcasterData + { + Allocator* allocator; // 0x00 + Broadcaster* owner; // 0x08 + BroadcastSocketInfo broadcastSocketInfo; // 0x10 + CHAR _unk0[0xE8 + (0xE8 - sizeof(broadcastSocketInfo))]; // TODO: BroadcastSocketInfo is around 0xE8 in size. Then we have 0xE8 of unknown data. + DelegateProxy logFunc; // 0x1e0 + UINT32 selfType; // 0x200 + UINT32 dummyType; // 0x204 + + // TODO: Temporarily replaced the below + CHAR _unk1[0x78]; + //CTimer timer; + + AddressInfo addr; // 0x280 (sockaddr_in is here, padded by zeros) (currently 0x140 in size) + CHAR displayName[128]; // @ 0x300 + CHAR name[128]; // @ 0x380 + + // TODO: Everything past this point is unknown. + }; + + /// + /// A UDP game server broadcaster provides broadcasting for the game server itself. + /// + struct Broadcaster + { + BroadcasterData* data; + }; + + /// + /// TcpPeer refers to a TCP peer (e.g. a connection to a websocket service). + /// + struct TcpPeer + { + UINT32 index; + UINT32 gen; + }; + const TcpPeer TcpPeer_Self = { 0xFFFFFFFD, 0 }; + const TcpPeer TcpPeer_AllPeers = { 0xFFFFFFFE, 0 }; + const TcpPeer TcpPeer_InvalidPeer = { 0xFFFFFFFF, 0 }; + + /// + /// TODO: Map this out + /// + struct TcpPeerConnectionStats{}; + + /// + /// The underlying data structure for a TCP websocket connection. + /// + class TcpBroadcasterData + { + // TODO: The vtable below may be wrong in a few places, but CreatePeer() and SendToPeer() work, + // which are of utmost importance to this library. + public: + virtual VOID __Unknown0() = 0; + virtual ~TcpBroadcasterData() = 0; + virtual VOID Shutdown() = 0; + virtual UINT32 IsServer() = 0; + virtual VOID AddPeerFromBuffer(PoolBuffer* buffer) = 0; + virtual UINT64 GetPeerCount() = 0; + virtual UINT32 HasPeer(TcpPeer) = 0; + virtual UINT32 IsPeerConnecting(TcpPeer) = 0; + virtual UINT32 IsPeerConnected(TcpPeer) = 0; + virtual UINT32 IsPeerDisconnecting(TcpPeer) = 0; + virtual AddressInfo* GetPeerAddress(AddressInfo* result, TcpPeer) = 0; + virtual VOID __Unknown1() = 0; + virtual const CHAR* GetPeerDisplayName(TcpPeer) = 0; + virtual TcpPeer* GetPeerByAddress(TcpPeer* result, const AddressInfo*) = 0; + virtual TcpPeer* GetPeerByIndex(TcpPeer* result, UINT32) = 0; + virtual VOID FreePeer(TcpPeer) = 0; + virtual VOID DisconnectPeer(TcpPeer) = 0; + virtual VOID DisconnectAllPeers() = 0; + virtual VOID __Unknown2() = 0; // TODO: This was put here to shift vtable to allow CreatePeer/SendToPeer to work. Maybe the shift is higher? + virtual TcpPeer* CreatePeer( TcpPeer* result, const UriContainer*) = 0; + virtual VOID DestroyPeer(TcpPeer) = 0; + virtual VOID SendToPeer(TcpPeer, SymbolId msgtype, const VOID* item, UINT64 itemSize, const VOID* buffer, UINT64 bufferSize) = 0; + virtual VOID Update() = 0; + virtual UINT32 Update_2(UINT32, UINT32) = 0; + virtual UINT32 HandlePeer(SymbolId, TcpPeer, const VOID*, UINT64) = 0; + virtual const TcpPeerConnectionStats* GetPeerConnectionStats(TcpPeer) = 0; + virtual TcpPeerConnectionStats* GetPeerConnectionStats_0(TcpPeer) = 0; + + // Data + TcpBroadcaster* owner; + AddressInfo addressInfo; + CHAR displayName[24]; + CHAR name[24]; + + // TODO: Everything past this point is unknown. + }; + + /// + /// A TCP broadcaster/connection, used as a client to connect to central services. + /// + struct TcpBroadcaster + { + TcpBroadcasterData* data; + }; + + /// + /// Lobby type describes the privacy-access level of a game session. + /// + enum LobbyType : INT8 + { + Public = 0x0, + Private = 0x1, + Unassigned = 0x2, + }; + + /// + /// The main structure used to track lobby/game session information for the current game. + /// Lobby objects can be local, dedicated, etc. As a game server, this is a dedicated lobby object. + /// + struct Lobby { + + /// + /// Information for each entrant/player in the game server. + /// + struct EntrantData + { + XPlatformId userId; + SymbolId platformId; + CHAR uniqueName[36]; + CHAR displayName[36]; + CHAR sfwDisplayName[36]; + INT32 censored; + UINT16 owned : 1; + UINT16 dirty : 1; + UINT16 crossplayEnabled : 1; + UINT16 unused : 13; + UINT16 ping; + UINT16 genIndex; + UINT16 teamIndex; + Json json; + }; + + /// + /// Information for each local entrant on this machine, in the game server. + /// + struct LocalEntrantv2 + { + GUID loginSession; + XPlatformId userId; + GUID playerSession; + UINT16 teamIndex; + BYTE padding[6]; + }; + + + // TODO + VOID* _unk0; // 0x00 + + Broadcaster* broadcaster; // 0x08 + TcpBroadcaster* tcpBroadcaster; // 0x10 + UINT32 maxEntrants; // 0x18 + + UINT32 hostingFlags; // 0x1C (second bit set => pass ownership of host) + CHAR _unk2[0x10]; // 0x20 + + INT64 serverLibraryModule; // 0x30 + IServerLib* serverLibray; // 0x38 + + DelegateProxy acceptEntrantFunc; // 0x40 + CHAR _unk3[0xD0]; // 0x60 + + UINT32 hosting; // 0x130 + + CHAR _unk4[0x04]; // 0x134 + + Peer hostPeer; // 0x138 + Peer internalHostPeer; // 0x140 + + Pool localEntrants; // 0x148 + + CHAR _unk5[0x84 - sizeof(localEntrants)]; // unknown data until 0x1CC. + + GUID gameSessionId; // 0x1CC + + CHAR _unk6[0x10]; // 0x1DC + + UINT32 entrantsLocked; // 0x1EC + UINT64 ownerSlot; // 0x1F0 + UINT32 ownerChanged; // 0x1F8 (TODO: verify) + + CHAR _unk7[0x360 - 0x1FC]; // 0x1FC + + HeapArray entrantData;// 0x360 + + + // TODOs: + // + // Known to exist, but missing: + // - entrant connections struct array (HeapArray) + // - registration pending (bool, 32-bit) (indicates game server registration succeeded) + // - server's platform symbol (SymbolId) + // - crossplay enabled (bool, 32-bit) + // - lobby type of current game session (LobbyType type) + // + // Notes: + // 0x358 (QWORD) set to 1 will load map instead of load server in some circumstances. + }; + + + + + /// + /// IServerLib describes an interface for a Echo VR game server library which the game + /// loads by default from "pnsradgameserver.dll" in the game folder, or alternatively + /// can be set using a JSON key in the config. + /// + class IServerLib + { + public: + virtual INT64 UnkFunc0(VOID* unk1, INT64 a2, INT64 a3) = 0; + virtual VOID* Initialize(Lobby* lobby, Broadcaster* broadcaster, VOID* unk2, const CHAR* logPath) = 0; + virtual VOID Terminate() = 0; + virtual VOID Update() = 0; + virtual VOID UnkFunc1(UINT64 unk) = 0; + + + virtual VOID RequestRegistration(INT64 serverId, CHAR* radId, SymbolId regionId, SymbolId lockedVersion, const Json* localConfig) = 0; + virtual VOID Unregister() = 0; + virtual VOID EndSession() = 0; + virtual VOID LockPlayerSessions() = 0; + virtual VOID UnlockPlayerSessions() = 0; + virtual VOID AcceptPlayerSessions(Array* playerUuids) = 0; + virtual VOID RemovePlayerSession(GUID* playerUuid) = 0; + }; +} diff --git a/common/echovrunexported.h b/common/echovrunexported.h new file mode 100644 index 0000000..554339c --- /dev/null +++ b/common/echovrunexported.h @@ -0,0 +1,172 @@ +#pragma once + +#include "pch.h" +#include "echovr.h" + +namespace EchoVR +{ + // Obtain a handle for the game + CHAR* g_GameBaseAddress = (CHAR*)GetModuleHandle(NULL); + + /// + /// Registers a callback for a certain type of websocket message. + /// + /// None + typedef VOID TcpBroadcasterListenFunc( + EchoVR::TcpBroadcaster* broadcaster, + EchoVR::SymbolId messageId, + INT64 unk1, + INT64 unk2, + INT64 unk3, + VOID* delegateProxy, + BOOL prepend + ); + TcpBroadcasterListenFunc* TcpBroadcasterListen = (TcpBroadcasterListenFunc*)(g_GameBaseAddress + 0xF81100); + + /// + /// Sends a message to a game server broadcaster. + /// + /// TODO: Unverified, probably success result or size. + typedef INT32 BroadcasterSendFunc( + EchoVR::Broadcaster* broadcaster, + EchoVR::SymbolId messageId, + INT32 mbThreadPriority, // note: most use 0 + VOID* item, + UINT64 size, + VOID* buffer, + UINT64 bufferLen, + EchoVR::Peer peer, + UINT64 dest, + FLOAT priority, + EchoVR::SymbolId unk + ); + BroadcasterSendFunc* BroadcasterSend = (BroadcasterSendFunc*)(g_GameBaseAddress + 0xF89AF0); + + /// + /// Receives/relays a local event on the broadcaster, triggering a listener. + /// + /// TODO: Unverified, probably success result. + typedef UINT64 BroadcasterReceiveLocalEventFunc( + EchoVR::Broadcaster* broadcaster, + EchoVR::SymbolId messageId, + const CHAR* msgName, + VOID* msg, + UINT64 msgSize + ); + BroadcasterReceiveLocalEventFunc* BroadcasterReceiveLocalEvent = (BroadcasterReceiveLocalEventFunc*)(g_GameBaseAddress + 0xF87AA0); + + /// + /// Listens for an event on the broadcaster. + /// + /// TODO: Unverified, probably success result. + typedef UINT64 BroadcasterListenFunc( + EchoVR::Broadcaster* broadcaster, + EchoVR::SymbolId messageId, + BOOL isReliableMsgType, + VOID* px, + BOOL prepend + ); + BroadcasterListenFunc* BroadcasterListen = (BroadcasterListenFunc*)(g_GameBaseAddress + 0xF80ED0); + + /// + /// Obtains a JSON string value(with a default fallback value if it could not be obtained). + /// + /// The resulting string returned from the JSON get string operation. + typedef CHAR* JsonValueAsStringFunc( + EchoVR::Json* root, + CHAR* keyName, + CHAR* defaultValue, + BOOL reportFailure + ); + JsonValueAsStringFunc* JsonValueAsString = (JsonValueAsStringFunc*)(g_GameBaseAddress + 0x5FE290); + + /// + /// Parses a URI string into a URI container structure. + /// + /// The result of the URI parsing operation. + typedef HRESULT UriContainerParseFunc( + EchoVR::UriContainer* uriContainer, + CHAR* uri + ); + UriContainerParseFunc* UriContainerParse = (UriContainerParseFunc*)(g_GameBaseAddress + 0x621EC0); + + /// + /// Builds the CLI argument options and help descriptions list. + /// + /// TODO: Unverified, probably success result + typedef UINT64 BuildCmdLineSyntaxDefinitionsFunc( + PVOID pGame, + PVOID pArgSyntax + ); + BuildCmdLineSyntaxDefinitionsFunc* BuildCmdLineSyntaxDefinitions = (BuildCmdLineSyntaxDefinitionsFunc*)(g_GameBaseAddress + 0xFEA00); + + /// + /// Adds an argument to the CLI argument syntax object. + /// + /// None + typedef VOID AddArgSyntaxFunc( + PVOID pArgSyntax, + const CHAR* sArgName, + UINT64 minOptions, + UINT64 maxOptions, + BOOL validate + ); + AddArgSyntaxFunc* AddArgSyntax = (AddArgSyntaxFunc*)(g_GameBaseAddress + 0xD31B0); + + /// + /// Adds an argument help string to the CLI argument syntax object. + /// + /// None + typedef VOID AddArgHelpStringFunc( + PVOID pArgSyntax, + const CHAR* sArgName, + const CHAR* sArgHelpDescription + ); + AddArgHelpStringFunc* AddArgHelpString = (AddArgHelpStringFunc*)(g_GameBaseAddress + 0xD30D0); + + /// + /// Processes the provided command line options for the running process. + /// + /// TODO: Unverified, probably success result + typedef UINT64 PreprocessCommandLineFunc( + PVOID pGame + ); + PreprocessCommandLineFunc* PreprocessCommandLine = (PreprocessCommandLineFunc*)(g_GameBaseAddress + 0x116720); + + /// + /// Writes a log to the logger, if all conditions such as log level are met. + /// + /// None + typedef VOID WriteLogFunc( + EchoVR::LogLevel logLevel, + UINT64 unk, + const CHAR* format, + va_list vl + ); + WriteLogFunc* WriteLog = (WriteLogFunc*)(g_GameBaseAddress + 0xEBE70); + + /// + /// A wrapper for WriteLog, simplifying logging operations. + /// + /// None + VOID Log(EchoVR::LogLevel level, const CHAR* format, ...) { + va_list args; + va_start(args, format); + WriteLog(level, 0, format, args); + va_end(args); + } + + + /// + /// TODO: Seemingly parses an HTTP/HTTPS URI to be connected to. + /// + /// TODO: Unknown + typedef UINT64 HttpConnectFunc(VOID* unk, CHAR* uri); + HttpConnectFunc* HttpConnect = (HttpConnectFunc*)(g_GameBaseAddress + 0x1F60C0); + + /// + /// Loads the local config (located at ./_local/config.json) for the provided game instance. + /// + typedef UINT64 LoadLocalConfigFunc(PVOID pGame); + LoadLocalConfigFunc* LoadLocalConfig = (LoadLocalConfigFunc*)(g_GameBaseAddress + 0x179EB0); +} diff --git a/common/pch.h b/common/pch.h new file mode 100644 index 0000000..cf50bd6 --- /dev/null +++ b/common/pch.h @@ -0,0 +1,23 @@ +// pch.h: This is a precompiled header file. +// Files listed below are compiled only once, improving build performance for future builds. +// This also affects IntelliSense performance, including code completion and many code browsing features. +// However, files listed here are ALL re-compiled if any one of them is updated between builds. +// Do not add files here that you will be updating frequently as this negates the performance advantage. + +#ifndef PCH_H +#define PCH_H + +#define _WINSOCK_DEPRECATED_NO_WARNINGS +#pragma comment(lib, "Ws2_32.lib") + +#include +#include +#include +#include + + +#define WIN32_LEAN_AND_MEAN +#include + + +#endif //PCH_H