diff --git a/README.md b/README.md index 1f5d422..435e714 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A MetaMod plugin that allows you to use multiple workshop addons at once and hav Changes will only apply to future clients. - `mm_extra_addons_timeout (default 10)` How long until clients are timed out in between connects for extra addons, timed out clients will reconnect for their current pending download. +- `mm_addons_hard_timeout (default 30)` // How long a client may sit on the Workshop download popup before being dropped; 0 disables - `mm_print_searchpaths` Print all the search paths currently mounted by the server. - `mm_addon_mount_download <0/1> (default 0)` If enabled, the plugin will initiate an addon download every time even if it's already installed, this will guarantee that updates are applied immediately. - `mm_cache_clients_with_addons <0/1> (default 0)` If enabled, the plugin will keep track of which addons client SteamIDs have downloaded to prevent sending them addons when they already have them (i.e. when they rejoin or the map changes). diff --git a/cfg/multiaddonmanager/multiaddonmanager.cfg b/cfg/multiaddonmanager/multiaddonmanager.cfg index 412ecac..88699e4 100644 --- a/cfg/multiaddonmanager/multiaddonmanager.cfg +++ b/cfg/multiaddonmanager/multiaddonmanager.cfg @@ -2,6 +2,7 @@ mm_extra_addons "" // The workshop IDs of extra addons, separated by commas (e.g. "3090239773,3070231528") mm_client_extra_addons "" // The workshop IDs of extra client addons that will be applied to all clients, separated by commas mm_extra_addons_timeout 10 // How long until clients are timed out in between connects for extra addons in seconds, requires mm_extra_addons to be used +mm_addons_hard_timeout 30 // How long a client may sit on the Workshop download popup before being dropped; 0 disables mm_addon_mount_download 0 // Whether to download an addon upon mounting even if it's installed mm_cache_clients_with_addons 0 // Whether to cache clients addon download list, this will prevent reconnects on mapchange/rejoin mm_cache_clients_duration 0 // How long to cache clients' downloaded addons list in seconds, pass 0 for forever. diff --git a/src/multiaddonmanager.cpp b/src/multiaddonmanager.cpp index adee230..868e65f 100644 --- a/src/multiaddonmanager.cpp +++ b/src/multiaddonmanager.cpp @@ -44,6 +44,7 @@ CConVar mm_block_disconnect_messages("mm_block_disconnect_messages", FCVAR CConVar mm_cache_clients_with_addons("mm_cache_clients_with_addons", FCVAR_NONE, "Whether to cache clients addon download list, this will prevent reconnects on mapchange/rejoin", false); CConVar mm_cache_clients_duration("mm_cache_clients_duration", FCVAR_NONE, "How long to cache clients' downloaded addons list in seconds, pass 0 for forever.", 0.0f); CConVar mm_extra_addons_timeout("mm_extra_addons_timeout", FCVAR_NONE, "How long until clients are timed out in between connects for extra addons in seconds, requires mm_extra_addons to be used", 10.f); +CConVar mm_addons_hard_timeout("mm_addons_hard_timeout", FCVAR_NONE, "How long a client may sit on the Workshop download popup before being dropped; 0 disables", 10.f); CConVar mm_addon_debug("mm_addon_debug", FCVAR_NONE, "Whether to print some extra debug information", false); @@ -200,8 +201,12 @@ struct ClientAddonInfo_t CUtlVector addonsToLoad; CUtlVector downloadedAddons; std::string currentPendingAddon; + bool firstPopupHardTimeoutActive {}; + double firstPopupStartTime {}; }; +std::unordered_map g_ClientAddons; + CUtlVector *GetClientList() { if (!g_pNetworkServerService) @@ -209,7 +214,68 @@ CUtlVector *GetClientList() return (CUtlVector *)((char *)g_pNetworkServerService->GetIGameServer() + g_iClientListOffset); } -std::unordered_map g_ClientAddons; + +static CServerSideClient *FindClientBySteamID(uint64 steamID64) +{ + CUtlVector *clients = GetClientList(); + if (!clients) + return nullptr; + + CUtlVector &clientList = *clients; + FOR_EACH_VEC(clientList, i) + { + CServerSideClient *client = clientList[i]; + if (client && client->GetClientSteamID().ConvertToUint64() == steamID64) + return client; + } + + return nullptr; +} + +static void CheckAddonHardTimeouts() +{ + if (mm_addons_hard_timeout.Get() <= 0.0f || !g_pEngineServer->IsDedicatedServer()) + return; + + const double now = Plat_FloatTime(); + CUtlVector clientsToDrop; + + for (const auto &entry : g_ClientAddons) + { + uint64 steamID64 = entry.first; + const ClientAddonInfo_t &clientInfo = entry.second; + + if (!clientInfo.firstPopupHardTimeoutActive || clientInfo.currentPendingAddon.empty()) + continue; + + if (now - clientInfo.firstPopupStartTime >= mm_addons_hard_timeout.Get()) + clientsToDrop.AddToTail(steamID64); + } + + FOR_EACH_VEC(clientsToDrop, i) + { + uint64 steamID64 = clientsToDrop[i]; + ClientAddonInfo_t &clientInfo = g_ClientAddons[steamID64]; + + CServerSideClient *client = FindClientBySteamID(steamID64); + if (!client) + { + clientInfo.firstPopupHardTimeoutActive = false; + clientInfo.firstPopupStartTime = 0.0; + clientInfo.currentPendingAddon.clear(); + continue; + } + + if (mm_addon_debug.Get()) + Message("%s: Dropping client %lli after %.1f seconds on Workshop download popup for addon %s\n", + __func__, steamID64, now - clientInfo.firstPopupStartTime, clientInfo.currentPendingAddon.c_str()); + + clientInfo.firstPopupHardTimeoutActive = false; + clientInfo.firstPopupStartTime = 0.0; + clientInfo.currentPendingAddon.clear(); + client->Disconnect(NETWORK_DISCONNECT_KICKED, "Required Workshop addon download was not accepted in time"); + } +} CConVar mm_extra_addons("mm_extra_addons", FCVAR_NONE, "The workshop IDs of extra addons separated by commas, addons will be downloaded (if not present) and mounted", CUtlString(""), [](CConVar *cvar, CSplitScreenSlot slot, const CUtlString *new_val, const CUtlString *old_val) @@ -978,11 +1044,15 @@ bool FASTCALL Hook_SendNetMessage(CServerSideClientBase *pClient, CNetMessage *p pMsg->set_addons(addonsList.Head()); // Since the client will download the addon contained inside this messsage, we might as well add it to the list of client's downloaded addons. clientInfo.currentPendingAddon = addonsList.Head(); + clientInfo.firstPopupHardTimeoutActive = false; + clientInfo.firstPopupStartTime = 0.0; } else if (addonsList.Count() == 1) { // Nothing to do here, the rest of the required addons can be sent later. clientInfo.currentPendingAddon = pMsg->addons(); + clientInfo.firstPopupHardTimeoutActive = false; + clientInfo.firstPopupStartTime = 0.0; } return pOriginalFunc(pClient, pData, bufType); @@ -1003,6 +1073,8 @@ bool FASTCALL Hook_SendNetMessage(CServerSideClientBase *pClient, CNetMessage *p // Otherwise, send the next addon to the client. clientInfo.currentPendingAddon = addons.Head(); + clientInfo.firstPopupHardTimeoutActive = false; + clientInfo.firstPopupStartTime = 0.0; pMsg->set_addons(addons.Head().c_str()); pMsg->set_signon_state(SIGNONSTATE_CHANGELEVEL); @@ -1105,6 +1177,8 @@ void MultiAddonManager::CheckClientAddons(uint64 steamID64) } // Reset the current pending addon anyway, SendNetMessage tells us which addon to download next. clientInfo.currentPendingAddon.clear(); + clientInfo.firstPopupHardTimeoutActive = false; + clientInfo.firstPopupStartTime = 0.0; } g_ClientAddons[steamID64].lastActiveTime = Plat_FloatTime(); return; @@ -1144,6 +1218,7 @@ void MultiAddonManager::Hook_GameFrame(bool simulating, bool bFirstTick, bool bL { s_flTime = Plat_FloatTime(); PrintDownloadProgress(); + CheckAddonHardTimeouts(); } } @@ -1206,13 +1281,25 @@ void FASTCALL Hook_ReplyConnection(INetworkGameServer *server, CServerSideClient { // No addons to send. This means the list of original addons is empty as well. assert(originalAddons.IsEmpty()); + clientInfo.currentPendingAddon.clear(); + clientInfo.firstPopupHardTimeoutActive = false; + clientInfo.firstPopupStartTime = 0.0; g_pfnReplyConnection(server, client); return; } // Handle the first addon here. The rest should be handled in the SendNetMessage hook. if (g_ClientAddons[steamID64].downloadedAddons.Find(clientAddons[0]) == -1) + { + // Start the hard timeout only when the first popup is first assigned. ReplyConnection can be called repeatedly while the popup is open. + if (!clientInfo.firstPopupHardTimeoutActive || clientInfo.currentPendingAddon != clientAddons[0]) + { + clientInfo.firstPopupHardTimeoutActive = true; + clientInfo.firstPopupStartTime = Plat_FloatTime(); + } + g_ClientAddons[steamID64].currentPendingAddon = clientAddons[0]; + } // In some cases, clients can do a signature check on addons which fails and instantly disconnects them // As a mitigation, remove all undownloaded addons so the client never does the failing signature check