From 531609b46bb1fa1efc9f648b871821e329f09058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=AA=20St=CE=B1r?= <45707960+Staaar0@users.noreply.github.com> Date: Fri, 8 May 2026 13:24:06 +0300 Subject: [PATCH 1/7] Add hard timeout for Workshop download popup --- cfg/multiaddonmanager/multiaddonmanager.cfg | 1 + 1 file changed, 1 insertion(+) 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. From 900335328545fdae3767ef496a74e0504a7e6767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=AA=20St=CE=B1r?= <45707960+Staaar0@users.noreply.github.com> Date: Fri, 8 May 2026 13:24:28 +0300 Subject: [PATCH 2/7] Implement hard timeout for addon download popups Added a hard timeout for client addon download popups to disconnect clients if they do not accept the required addons in time. --- src/multiaddonmanager.cpp | 89 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) 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 From 6194c2a420755af2ac8fada0bb64ecd23cf4e8cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=AA=20St=CE=B1r?= <45707960+Staaar0@users.noreply.github.com> Date: Fri, 8 May 2026 13:25:59 +0300 Subject: [PATCH 3/7] Add mm_addons_hard_timeout configuration option Added a new configuration option for client timeout during Workshop downloads. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 1f5d422..8243e90 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ A MetaMod plugin that allows you to use multiple workshop addons at once and hav - `mm_client_extra_addons ` The workshop IDs of extra client-side only addons that will be loaded by all clients, separated by commas. These addons are not loaded or downloaded by the server. Changes will only apply to future clients. +## NEW +- `mm_addons_hard_timeout (default 30)` How long a client may sit on the Workshop download popup before being dropped; 0 disables + +-- - `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_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. From 179597abe9950b2102259b3ba6eead942d35a94a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=AA=20St=CE=B1r?= <45707960+Staaar0@users.noreply.github.com> Date: Sat, 9 May 2026 02:54:03 +0300 Subject: [PATCH 4/7] Update CI workflow for build and release --- .github/workflows/build.yml | 39 ++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 25c8ce8..c1dc0f7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,18 +1,20 @@ name: CI on: + workflow_dispatch: push: - tags: - - '*' branches: - main paths-ignore: - - LICENSE - - README.md + - LICENSE + - README.md pull_request: paths-ignore: - - LICENSE - - README.md + - LICENSE + - README.md + +permissions: + contents: write jobs: build: @@ -42,7 +44,7 @@ jobs: ref: master path: mmsource-2.0 submodules: recursive - + - name: Checkout HL2SDK uses: actions/checkout@v4 with: @@ -80,9 +82,9 @@ jobs: release: name: Release - if: startsWith(github.ref, 'refs/tags/') needs: build runs-on: ubuntu-latest + if: github.event_name != 'pull_request' steps: - name: Download artifacts @@ -90,23 +92,28 @@ jobs: - name: Package run: | - version=`echo $GITHUB_REF | sed "s/refs\/tags\///"` ls -Rall + if [ -d "./Linux/" ]; then cd ./Linux/ - tar -czf ../${{ github.event.repository.name }}-${version}-linux.tar.gz * + tar -czf ../MultiAddonManager-linux.tar.gz * cd - fi + if [ -d "./Windows/" ]; then cd ./Windows/ - zip -r ../${{ github.event.repository.name }}-${version}-windows.zip * + zip -r ../MultiAddonManager-windows.zip * cd - fi - name: Release - uses: svenstaro/upload-release-action@v2 + uses: softprops/action-gh-release@v2 with: - repo_token: ${{ secrets.GITHUB_TOKEN }} - file: ${{ github.event.repository.name }}-* - tag: ${{ github.ref }} - file_glob: true + tag_name: build-${{ github.run_number }} + name: build-${{ github.run_number }} + files: | + MultiAddonManager-linux.tar.gz + MultiAddonManager-windows.zip + generate_release_notes: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 882c969c1409543e5a407b8d9f5deb532047f787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=AA=20St=CE=B1r?= <45707960+Staaar0@users.noreply.github.com> Date: Mon, 11 May 2026 16:13:39 +0300 Subject: [PATCH 5/7] Implement addon resend after server update Added functionality to resend client addon prompts after server updates. --- src/multiaddonmanager.cpp | 74 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/multiaddonmanager.cpp b/src/multiaddonmanager.cpp index 868e65f..2524e72 100644 --- a/src/multiaddonmanager.cpp +++ b/src/multiaddonmanager.cpp @@ -45,6 +45,7 @@ CConVar mm_cache_clients_with_addons("mm_cache_clients_with_addons", FCVAR 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_resend_client_addons_on_update("mm_resend_client_addons_on_update", FCVAR_NONE, "Whether to resend required client addon prompts after the server finishes downloading/updating an addon", false); CConVar mm_addon_debug("mm_addon_debug", FCVAR_NONE, "Whether to print some extra debug information", false); @@ -138,6 +139,8 @@ funchook_t *g_pScriptGetAddonHook = nullptr; int g_iLoadEventsFromFileHookId = -1; +static void ResendClientAddonAfterServerUpdate(PublishedFileId_t addon); + class GameSessionConfiguration_t { }; SH_DECL_HOOK0_void(IServerGameDLL, GameServerSteamAPIActivated, SH_NOATTRIB, 0); @@ -723,6 +726,9 @@ void MultiAddonManager::OnAddonDownloaded(DownloadItemResult_t *pResult) if (!m_DownloadQueue.Check(pResult->m_nPublishedFileId)) return; + if (pResult->m_eResult == k_EResultOK && mm_resend_client_addons_on_update.Get()) + ResendClientAddonAfterServerUpdate(pResult->m_nPublishedFileId); + m_DownloadQueue.RemoveAtHead(); bool bFound = m_ImportantDownloads.FindAndRemove(pResult->m_nPublishedFileId); @@ -804,6 +810,74 @@ CNetMessagePB *GetAddonSignonStateMessage(const char *pszAd return pMsg; } +static void RemoveDownloadedAddonFromCache(ClientAddonInfo_t &clientInfo, const char *pszAddon) +{ + while (clientInfo.downloadedAddons.Find(pszAddon) != -1) + clientInfo.downloadedAddons.FindAndRemove(pszAddon); +} + +static void ResendClientAddonAfterServerUpdate(PublishedFileId_t addon) +{ + if (!g_pEngineServer->IsDedicatedServer()) + return; + + char szAddon[32]; + V_snprintf(szAddon, sizeof(szAddon), "%llu", (unsigned long long)addon); + + int resendCount = 0; + + for (auto &entry : g_ClientAddons) + RemoveDownloadedAddonFromCache(entry.second, szAddon); + + CUtlVector *clients = GetClientList(); + if (!clients) + return; + + CNetMessagePB *pMsg = GetAddonSignonStateMessage(szAddon); + if (!pMsg) + { + Panic("%s: Failed to create signon state message for %s\n", __func__, szAddon); + return; + } + + CUtlVector &clientList = *clients; + FOR_EACH_VEC(clientList, i) + { + CServerSideClient *pClient = clientList[i]; + if (!pClient) + continue; + + uint64 steamID64 = pClient->GetClientSteamID().ConvertToUint64(); + if (!steamID64) + continue; + + ClientAddonInfo_t &clientInfo = g_ClientAddons[steamID64]; + RemoveDownloadedAddonFromCache(clientInfo, szAddon); + + CUtlVector requiredAddons; + g_MultiAddonManager.GetClientAddons(requiredAddons, steamID64); + if (requiredAddons.Find(szAddon) == -1) + continue; + + // Client is already loading or already has another addon prompt in progress. + if (pClient->GetSignonState() == SIGNONSTATE_CHANGELEVEL || !clientInfo.currentPendingAddon.empty()) + continue; + + clientInfo.currentPendingAddon = szAddon; + clientInfo.firstPopupHardTimeoutActive = true; + clientInfo.firstPopupStartTime = Plat_FloatTime(); + clientInfo.lastActiveTime = Plat_FloatTime(); + + pClient->GetNetChannel()->SendNetMessage(pMsg, BUF_RELIABLE); + resendCount++; + } + + delete pMsg; + + if (mm_addon_debug.Get()) + Message("%s: Resent addon %s to %d connected clients after server-side download/update\n", __func__, szAddon, resendCount); +} + bool MultiAddonManager::HasUGCConnection() { return GetSteamUGC() != nullptr; From de756e5c0a9f048ed11b0fc0e8344b7fb4658aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=AA=20St=CE=B1r?= <45707960+Staaar0@users.noreply.github.com> Date: Mon, 11 May 2026 16:15:38 +0300 Subject: [PATCH 6/7] Add new configuration options for addon management --- cfg/multiaddonmanager/multiaddonmanager.cfg | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/cfg/multiaddonmanager/multiaddonmanager.cfg b/cfg/multiaddonmanager/multiaddonmanager.cfg index 88699e4..3631232 100644 --- a/cfg/multiaddonmanager/multiaddonmanager.cfg +++ b/cfg/multiaddonmanager/multiaddonmanager.cfg @@ -1,10 +1,11 @@ // Extra addon settings, this is only executed once on plugin load -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. -mm_block_disconnect_messages 0 // Whether to block "loop shutdown" disconnect messages -mm_addon_debug 0 // Whether to print some extra debug information +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_resend_client_addons_on_update 0 // Resend required client addon prompts after the server finishes downloading/updating an addon; 0 disables +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. +mm_block_disconnect_messages 0 // Whether to block "loop shutdown" disconnect messages +mm_addon_debug 0 // Whether to print some extra debug information From 64304c7c12447f93a11afd5269b68efd4dffbdcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=AA=20St=CE=B1r?= <45707960+Staaar0@users.noreply.github.com> Date: Tue, 12 May 2026 14:50:13 +0300 Subject: [PATCH 7/7] Remove resend client addons on update option Removed the option to resend client addon prompts after updates. --- cfg/multiaddonmanager/multiaddonmanager.cfg | 1 - 1 file changed, 1 deletion(-) diff --git a/cfg/multiaddonmanager/multiaddonmanager.cfg b/cfg/multiaddonmanager/multiaddonmanager.cfg index 3631232..7640c50 100644 --- a/cfg/multiaddonmanager/multiaddonmanager.cfg +++ b/cfg/multiaddonmanager/multiaddonmanager.cfg @@ -4,7 +4,6 @@ mm_client_extra_addons "" // The workshop IDs of extra client addons that wi 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_resend_client_addons_on_update 0 // Resend required client addon prompts after the server finishes downloading/updating an addon; 0 disables 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. mm_block_disconnect_messages 0 // Whether to block "loop shutdown" disconnect messages