From 388f53dbc7c12d9c985a222cc60f83700f26af95 Mon Sep 17 00:00:00 2001 From: OpenSteam001 <248184252+OpenSteam001@users.noreply.github.com> Date: Sun, 31 May 2026 15:54:19 +0800 Subject: [PATCH 1/3] Run removals on the UI thread via a CSteamUIAppController::RunFrame hook and drain ~1/3 of existing apps per frame --- src/Hook/Hooks_Package.cpp | 17 ++----- src/Hook/Hooks_SteamUI.cpp | 91 +++++++++++++++++++++++++++++++------- src/Hook/Hooks_SteamUI.h | 5 +-- 3 files changed, 81 insertions(+), 32 deletions(-) diff --git a/src/Hook/Hooks_Package.cpp b/src/Hook/Hooks_Package.cpp index 1e265d2..35ecd92 100644 --- a/src/Hook/Hooks_Package.cpp +++ b/src/Hook/Hooks_Package.cpp @@ -3,7 +3,6 @@ #include "Hooks_SteamUI.h" #include "dllmain.h" #include "Utils/VehCommon.h" -#include namespace { RESOLVE_FUNC(CUtlMemoryGrow, void*, CUtlVector*, int); @@ -134,9 +133,6 @@ namespace Hooks_Package { UNHOOK_END(); } - constexpr size_t kBatchSize = 50; - constexpr DWORD kBatchSleepMs = 20; - void NotifyLicenseChanged() { PackageInfo* pPkg = g_pInjectedPackageInfo; if (!pPkg) { @@ -184,14 +180,9 @@ namespace Hooks_Package { } LOG_PACKAGE_INFO("NotifyLicenseChanged: {} added, {} removed", additions.size(), removedCount); - // every kBatchSize ids changed, sleep kBatchSleepMs milliseconds - size_t i = 0; - for (AppId_t id : removals) { - if (++i % kBatchSize == 0) { - LOG_PACKAGE_DEBUG("NotifyLicenseChanged: processed {} removals, sleeping for {} ms...", i, kBatchSleepMs); - std::this_thread::sleep_for(std::chrono::milliseconds(kBatchSleepMs)); - } - Hooks_SteamUI::RemoveAppAndSendChange(id); - } + // Queue UI removals for the main-thread RunFrame hook to drain. + // Never touch MarkAppChange from this (FileWatcher) thread. + for (AppId_t id : removals) + Hooks_SteamUI::QueueRemoval(id); } } diff --git a/src/Hook/Hooks_SteamUI.cpp b/src/Hook/Hooks_SteamUI.cpp index 3c41947..4596e47 100644 --- a/src/Hook/Hooks_SteamUI.cpp +++ b/src/Hook/Hooks_SteamUI.cpp @@ -4,6 +4,7 @@ #include "dllmain.h" #include "steam_messages.pb.h" #include "Utils/VehCommon.h" +#include namespace { @@ -22,6 +23,75 @@ namespace { } return oFillInAppOverview(pThis, pAppOverview, pApp); } + + // The library-state change originates on the FileWatcher background thread. + // MarkAppChange assumes the UI thread and takes no lock, so the watcher thread + // only enqueues appIds here; the RunFrame hook drains them on the UI thread. + std::mutex g_removalMutex; + std::vector g_pendingRemovals; + constexpr uint32 kBudgetDivisor = 3; + + // Clears the ownership flag for appId and queues an app change so the overview + // flush re-evaluates it. + bool RemoveAppAndSendChange(AppId_t appId) { + // skip on owned apps + if(LuaConfig::IsOwned(appId)){ + LOG_STEAMUI_WARN("RemoveAppAndSendChange: appId={} is owned, skipping", appId); + return false; + } + if(CAPTURE_READY(GetAppByID) && CAPTURE_READY(MarkAppChange)) { + CSteamApp* pApp = oGetAppByID(g_pController, appId, false); + if(pApp) { + pApp->OwnershipFlags = k_EAppOwnershipFlags_None; + LOG_STEAMUI_DEBUG("RemoveAppAndSendChange: cleared owned flag for appId={}", appId); + oMarkAppChange(g_pAppChangeSource, appId, EAppChangeFlags::AddedOrCreated); + return true; + } + LOG_STEAMUI_WARN("RemoveAppAndSendChange: appId={} not found in GetAppByID", appId); + } + return false; + } + + // CSteamUIAppController::RunFrame - the controller's per-frame tick on the UI + // thread; its tail flushes pending overview changes to the JS library. We drain + // a budgeted batch of removals here so each flush stays on the delta path. + HOOK_FUNC(CSteamUIAppControllerRunFrame, void*, void* pController) + { + static std::vector s_draining; + + // Pull anything the FileWatcher thread queued into our UI-thread work set. + { + std::lock_guard lock(g_removalMutex); + if (!g_pendingRemovals.empty()) { + s_draining.insert(s_draining.end(),g_pendingRemovals.begin(), g_pendingRemovals.end()); + g_pendingRemovals.clear(); + } + } + + if (!s_draining.empty() && CAPTURE_READY(GetAppByID)) { + // Recompute the budget from the library-visible apps still queued + // remove a third of them this frame. + size_t existing = 0; + for (AppId_t id : s_draining) { + if (oGetAppByID(g_pController, id, false)){ + ++existing; + } + } + LOG_STEAMUI_DEBUG("RunFrame: {} pending removals, {} still exist", s_draining.size(), existing); + + size_t budget = existing / kBudgetDivisor; + if (budget == 0) budget = 1; + size_t marked = 0; + while (!s_draining.empty() && marked < budget) { + AppId_t id = s_draining.back(); + s_draining.pop_back(); + if (RemoveAppAndSendChange(id)) // depotids/unknown ids are free + ++marked; + } + LOG_STEAMUI_DEBUG("RunFrame: removed {} app(s), {} left", marked, s_draining.size()); + } + return oCSteamUIAppControllerRunFrame(pController); + } } namespace Hooks_SteamUI { @@ -32,6 +102,7 @@ namespace Hooks_SteamUI { HOOK_BEGIN(); INSTALL_HOOK_U(FillInAppOverview); + INSTALL_HOOK_U(CSteamUIAppControllerRunFrame); HOOK_END(); } @@ -39,25 +110,13 @@ namespace Hooks_SteamUI { void Uninstall() { UNHOOK_BEGIN(); UNINSTALL_HOOK(FillInAppOverview); + UNINSTALL_HOOK(CSteamUIAppControllerRunFrame); UNHOOK_END(); } - void RemoveAppAndSendChange(AppId_t appId) { - // skip on owned apps - if(LuaConfig::IsOwned(appId)){ - LOG_STEAMUI_WARN("RemoveAppAndSendChange: appId={} is owned, skipping", appId); - return; - } - if(CAPTURE_READY(GetAppByID) && CAPTURE_READY(MarkAppChange)) { - CSteamApp* pApp = oGetAppByID(g_pController, appId, false); - if(pApp) { - pApp->OwnershipFlags = k_EAppOwnershipFlags_None; - LOG_STEAMUI_DEBUG("RemoveAppAndSendChange: cleared owned flag for appId={}", appId); - oMarkAppChange(g_pAppChangeSource, appId, EAppChangeFlags::AddedOrCreated); - } else { - LOG_STEAMUI_WARN("RemoveAppAndSendChange: appId={} not found in GetAppByID", appId); - } - } + void QueueRemoval(AppId_t appId) { + std::lock_guard lock(g_removalMutex); + g_pendingRemovals.push_back(appId); } } diff --git a/src/Hook/Hooks_SteamUI.h b/src/Hook/Hooks_SteamUI.h index 26f1025..c67d058 100644 --- a/src/Hook/Hooks_SteamUI.h +++ b/src/Hook/Hooks_SteamUI.h @@ -8,7 +8,6 @@ namespace Hooks_SteamUI { void Install(); void Uninstall(); - // Clears ownership flag for the given appId and - // sends an app change notification to update the library UI. - void RemoveAppAndSendChange(AppId_t appId); + // Queues an appId for removal from the library UI + void QueueRemoval(AppId_t appId); } From fcb07dec35e37253af2b49401baffa6b025d8d15 Mon Sep 17 00:00:00 2001 From: OpenSteam001 <248184252+OpenSteam001@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:59:13 +0800 Subject: [PATCH 2/3] Use ShouldShowAppInLibrary for removal batch sizing --- src/Hook/Hooks_SteamUI.cpp | 129 ++++++++++++++++++++++++------------- 1 file changed, 83 insertions(+), 46 deletions(-) diff --git a/src/Hook/Hooks_SteamUI.cpp b/src/Hook/Hooks_SteamUI.cpp index 4596e47..74f033e 100644 --- a/src/Hook/Hooks_SteamUI.cpp +++ b/src/Hook/Hooks_SteamUI.cpp @@ -10,6 +10,7 @@ namespace { CAPTURE_THIS_FUNC(GetAppByID, CSteamApp*, g_pController,void* pThis, AppId_t appId, bool bCreate); CAPTURE_THIS_FUNC(MarkAppChange,void*,g_pAppChangeSource,void* pThis,AppId_t appId, EAppChangeFlags changeFlags); + RESOLVE_FUNC(ShouldShowAppInLibrary,bool,CSteamApp* pApp); HOOK_FUNC(FillInAppOverview,void*,void* pThis,void* pAppOverview,CSteamApp* pApp) { @@ -31,25 +32,88 @@ namespace { std::vector g_pendingRemovals; constexpr uint32 kBudgetDivisor = 3; - // Clears the ownership flag for appId and queues an app change so the overview - // flush re-evaluates it. - bool RemoveAppAndSendChange(AppId_t appId) { - // skip on owned apps - if(LuaConfig::IsOwned(appId)){ - LOG_STEAMUI_WARN("RemoveAppAndSendChange: appId={} is owned, skipping", appId); + struct RemovalCandidate { + AppId_t appId; + CSteamApp* app; + }; + + void PullQueuedRemovals(std::vector& draining) { + std::lock_guard lock(g_removalMutex); + if (g_pendingRemovals.empty()) return; + + draining.insert(draining.end(), g_pendingRemovals.begin(), g_pendingRemovals.end()); + g_pendingRemovals.clear(); + } + + std::vector CollectVisibleRemovalCandidates(void* pController, const std::vector& draining) + { + std::vector candidates; + + for (AppId_t appId : draining) { + if (LuaConfig::IsOwned(appId)) { + LOG_STEAMUI_WARN("CollectVisibleRemovalCandidates: appId={} is owned, skipping", appId); + continue; + } + + CSteamApp* app = oGetAppByID(pController, appId, false); + if (!app) { + LOG_STEAMUI_TRACE("CollectVisibleRemovalCandidates: appId={} not found, skipping", appId); + continue; + } + + if (!oShouldShowAppInLibrary(app)) { + LOG_STEAMUI_TRACE("CollectVisibleRemovalCandidates: appId={} is not visible, skipping", appId); + continue; + } + + candidates.push_back({appId, app}); + } + return candidates; + } + + // Clears the ownership flag and queues an app change so the overview flush + // re-evaluates a candidate already validated during this UI frame. + bool RemoveAppAndSendChange(const RemovalCandidate& candidate) { + if (LuaConfig::IsOwned(candidate.appId)) { + LOG_STEAMUI_WARN("RemoveAppAndSendChange: appId={} became owned, skipping", candidate.appId); return false; } - if(CAPTURE_READY(GetAppByID) && CAPTURE_READY(MarkAppChange)) { - CSteamApp* pApp = oGetAppByID(g_pController, appId, false); - if(pApp) { - pApp->OwnershipFlags = k_EAppOwnershipFlags_None; - LOG_STEAMUI_DEBUG("RemoveAppAndSendChange: cleared owned flag for appId={}", appId); - oMarkAppChange(g_pAppChangeSource, appId, EAppChangeFlags::AddedOrCreated); - return true; + + candidate.app->OwnershipFlags = k_EAppOwnershipFlags_None; + LOG_STEAMUI_DEBUG("RemoveAppAndSendChange: cleared owned flag for appId={}", candidate.appId); + oMarkAppChange(g_pAppChangeSource, candidate.appId, EAppChangeFlags::AddedOrCreated); + return true; + } + + void DrainRemovalBatch(void* pController, std::vector& draining) { + if(!CAPTURE_READY(GetAppByID) || !CAPTURE_READY(MarkAppChange) || !oShouldShowAppInLibrary){ + LOG_STEAMUI_WARN("DrainRemovalBatch: dependencies not ready, skipping drain"); + return; + } + if (draining.empty()) return; + + std::vector candidates = CollectVisibleRemovalCandidates(pController, draining); + + size_t budget = candidates.size() / kBudgetDivisor; + if (!candidates.empty() && budget == 0) + budget = 1; + + size_t marked = 0; + // Rebuild the work queue with candidates deferred to the next frame. + // Items removed within this frame are intentionally left out. + draining.clear(); + for (const RemovalCandidate& candidate : candidates) { + if (marked >= budget) { + draining.push_back(candidate.appId); + continue; } - LOG_STEAMUI_WARN("RemoveAppAndSendChange: appId={} not found in GetAppByID", appId); + + if (RemoveAppAndSendChange(candidate)) + ++marked; } - return false; + + LOG_STEAMUI_DEBUG("RunFrame: visible removals={}, removed={}, deferred={}", + candidates.size(), marked, draining.size()); } // CSteamUIAppController::RunFrame - the controller's per-frame tick on the UI @@ -59,37 +123,8 @@ namespace { { static std::vector s_draining; - // Pull anything the FileWatcher thread queued into our UI-thread work set. - { - std::lock_guard lock(g_removalMutex); - if (!g_pendingRemovals.empty()) { - s_draining.insert(s_draining.end(),g_pendingRemovals.begin(), g_pendingRemovals.end()); - g_pendingRemovals.clear(); - } - } - - if (!s_draining.empty() && CAPTURE_READY(GetAppByID)) { - // Recompute the budget from the library-visible apps still queued - // remove a third of them this frame. - size_t existing = 0; - for (AppId_t id : s_draining) { - if (oGetAppByID(g_pController, id, false)){ - ++existing; - } - } - LOG_STEAMUI_DEBUG("RunFrame: {} pending removals, {} still exist", s_draining.size(), existing); - - size_t budget = existing / kBudgetDivisor; - if (budget == 0) budget = 1; - size_t marked = 0; - while (!s_draining.empty() && marked < budget) { - AppId_t id = s_draining.back(); - s_draining.pop_back(); - if (RemoveAppAndSendChange(id)) // depotids/unknown ids are free - ++marked; - } - LOG_STEAMUI_DEBUG("RunFrame: removed {} app(s), {} left", marked, s_draining.size()); - } + PullQueuedRemovals(s_draining); + DrainRemovalBatch(pController, s_draining); return oCSteamUIAppControllerRunFrame(pController); } } @@ -100,6 +135,8 @@ namespace Hooks_SteamUI { ARM_CAPTURE_U(GetAppByID); ARM_CAPTURE_U(MarkAppChange); + RESOLVE_U(ShouldShowAppInLibrary); + HOOK_BEGIN(); INSTALL_HOOK_U(FillInAppOverview); INSTALL_HOOK_U(CSteamUIAppControllerRunFrame); From f5baa1aca876291c670ea83019c376afff5e10c5 Mon Sep 17 00:00:00 2001 From: OpenSteam001 <248184252+OpenSteam001@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:10:06 +0800 Subject: [PATCH 3/3] Post-hook BuildCompleteAppOverviewChange to re-assert the persistent removed set into removed_appid after the snapshot is built. --- src/Hook/Hooks_Package.cpp | 24 ++++- src/Hook/Hooks_SteamUI.cpp | 176 ++++++++++++++------------------- src/Hook/Hooks_SteamUI.h | 2 + src/Steam/Enums.h | 31 ++++++ src/Steam/Structs.h | 84 ++++++++++++---- src/proto/steam_messages.proto | 150 +++++++++++++++++++++++++++- 6 files changed, 335 insertions(+), 132 deletions(-) diff --git a/src/Hook/Hooks_Package.cpp b/src/Hook/Hooks_Package.cpp index 35ecd92..3fc6177 100644 --- a/src/Hook/Hooks_Package.cpp +++ b/src/Hook/Hooks_Package.cpp @@ -155,12 +155,19 @@ namespace Hooks_Package { // ── Add depots that are newly loaded ── std::vector additions = LuaConfig::TakePendingAdditions(); + std::unordered_set addedIds; LOG_PACKAGE_DEBUG("NotifyLicenseChanged: processing {} additions", additions.size()); if (!additions.empty()) { uint32_t oldSize = pPkg->AppIdVec.m_Size; if (CUtlMemoryGrowWrap(&pPkg->AppIdVec, additions.size())) { + // An applied addition invalidates any UI removal that has not + // reached the UI thread yet. + for (AppId_t id : additions) + Hooks_SteamUI::CancelRemoval(id); + for (size_t i = 0; i < additions.size(); ++i) { pPkg->AppIdVec.m_Memory.m_pMemory[oldSize + i] = additions[i]; + addedIds.insert(additions[i]); LOG_PACKAGE_DEBUG("NotifyLicenseChanged: inserted AppId {} at [{}]", additions[i], oldSize + i); } }else { @@ -168,7 +175,7 @@ namespace Hooks_Package { } } - if (additions.empty() && removedCount == 0) { + if (addedIds.empty() && removedCount == 0) { LOG_PACKAGE_DEBUG("NotifyLicenseChanged: no changes"); return; } @@ -178,11 +185,20 @@ namespace Hooks_Package { LOG_PACKAGE_WARN("NotifyLicenseChanged: failed to mark license as changed"); return; } - LOG_PACKAGE_INFO("NotifyLicenseChanged: {} added, {} removed", additions.size(), removedCount); + LOG_PACKAGE_INFO("NotifyLicenseChanged: {} added, {} removed", addedIds.size(), removedCount); // Queue UI removals for the main-thread RunFrame hook to drain. // Never touch MarkAppChange from this (FileWatcher) thread. - for (AppId_t id : removals) - Hooks_SteamUI::QueueRemoval(id); + size_t queuedRemovalCount = 0; + for (AppId_t id : removals) { + // ParseFile unloads the old file before parsing the replacement. + // Do not queue that transient removal when the id was added again. + if (!addedIds.contains(id)) { + Hooks_SteamUI::QueueRemoval(id); + ++queuedRemovalCount; + } + } + LOG_PACKAGE_DEBUG("NotifyLicenseChanged: queued {} UI removals, skipped {} transient removals", + queuedRemovalCount, removals.size() - queuedRemovalCount); } } diff --git a/src/Hook/Hooks_SteamUI.cpp b/src/Hook/Hooks_SteamUI.cpp index 74f033e..868ebb1 100644 --- a/src/Hook/Hooks_SteamUI.cpp +++ b/src/Hook/Hooks_SteamUI.cpp @@ -6,17 +6,20 @@ #include "Utils/VehCommon.h" #include -namespace { +namespace +{ + RESOLVE_FUNC(RepeatedFieldUint32_Add, void, void* field, const uint32* value); CAPTURE_THIS_FUNC(GetAppByID, CSteamApp*, g_pController,void* pThis, AppId_t appId, bool bCreate); CAPTURE_THIS_FUNC(MarkAppChange,void*,g_pAppChangeSource,void* pThis,AppId_t appId, EAppChangeFlags changeFlags); - RESOLVE_FUNC(ShouldShowAppInLibrary,bool,CSteamApp* pApp); - HOOK_FUNC(FillInAppOverview,void*,void* pThis,void* pAppOverview,CSteamApp* pApp) + HOOK_FUNC(FillInAppOverview, void *, void *pThis, void *pAppOverview, CSteamApp *pApp) { - if (pApp && LuaConfig::HasDepot(pApp->nAppID,false)) { + if (pApp && LuaConfig::HasDepot(pApp->nAppID, false)) + { uint32_t t = LuaConfig::GetPurchaseTime(pApp->nAppID); - if(t) { + if (t) + { pApp->PurchasedTime = t; LOG_STEAMUI_TRACE("FillInAppOverview: set PurchasedTime={} for appId={}", pApp->PurchasedTime, pApp->nAppID); @@ -25,135 +28,100 @@ namespace { return oFillInAppOverview(pThis, pAppOverview, pApp); } - // The library-state change originates on the FileWatcher background thread. - // MarkAppChange assumes the UI thread and takes no lock, so the watcher thread - // only enqueues appIds here; the RunFrame hook drains them on the UI thread. - std::mutex g_removalMutex; - std::vector g_pendingRemovals; - constexpr uint32 kBudgetDivisor = 3; + // Apps to drop from the library: queued off-thread, marked on the UI thread. + std::mutex g_removalMutex; + std::vector g_pendingRemovals; + std::unordered_set g_removedAppIds; - struct RemovalCandidate { - AppId_t appId; - CSteamApp* app; - }; - - void PullQueuedRemovals(std::vector& draining) { - std::lock_guard lock(g_removalMutex); - if (g_pendingRemovals.empty()) return; - - draining.insert(draining.end(), g_pendingRemovals.begin(), g_pendingRemovals.end()); - g_pendingRemovals.clear(); - } - - std::vector CollectVisibleRemovalCandidates(void* pController, const std::vector& draining) + // A full rebuild never lists removed_appid for apps still in the map + // so re-assert our set after the snapshot is built. + HOOK_FUNC(BuildCompleteAppOverviewChange, void, void *pController, + CAppOverview_Change *pChange, void *optionalCallbackSlot) { - std::vector candidates; - - for (AppId_t appId : draining) { - if (LuaConfig::IsOwned(appId)) { - LOG_STEAMUI_WARN("CollectVisibleRemovalCandidates: appId={} is owned, skipping", appId); - continue; - } - - CSteamApp* app = oGetAppByID(pController, appId, false); - if (!app) { - LOG_STEAMUI_TRACE("CollectVisibleRemovalCandidates: appId={} not found, skipping", appId); - continue; - } - - if (!oShouldShowAppInLibrary(app)) { - LOG_STEAMUI_TRACE("CollectVisibleRemovalCandidates: appId={} is not visible, skipping", appId); - continue; + oBuildCompleteAppOverviewChange(pController, pChange, optionalCallbackSlot); + std::lock_guard lock(g_removalMutex); + if (pChange && !g_removedAppIds.empty() && oRepeatedFieldUint32_Add) + { + auto* field = pChange->mutable_removed_appid(); + for (AppId_t appId : g_removedAppIds){ + oRepeatedFieldUint32_Add(field, &appId); } - - candidates.push_back({appId, app}); + LOG_STEAMUI_DEBUG("BuildCompleteAppOverviewChange: appended {} removed_appid entries", + g_removedAppIds.size()); } - return candidates; } - // Clears the ownership flag and queues an app change so the overview flush - // re-evaluates a candidate already validated during this UI frame. - bool RemoveAppAndSendChange(const RemovalCandidate& candidate) { - if (LuaConfig::IsOwned(candidate.appId)) { - LOG_STEAMUI_WARN("RemoveAppAndSendChange: appId={} became owned, skipping", candidate.appId); - return false; - } - - candidate.app->OwnershipFlags = k_EAppOwnershipFlags_None; - LOG_STEAMUI_DEBUG("RemoveAppAndSendChange: cleared owned flag for appId={}", candidate.appId); - oMarkAppChange(g_pAppChangeSource, candidate.appId, EAppChangeFlags::AddedOrCreated); - return true; - } - - void DrainRemovalBatch(void* pController, std::vector& draining) { - if(!CAPTURE_READY(GetAppByID) || !CAPTURE_READY(MarkAppChange) || !oShouldShowAppInLibrary){ - LOG_STEAMUI_WARN("DrainRemovalBatch: dependencies not ready, skipping drain"); - return; - } - if (draining.empty()) return; - - std::vector candidates = CollectVisibleRemovalCandidates(pController, draining); - size_t budget = candidates.size() / kBudgetDivisor; - if (!candidates.empty() && budget == 0) - budget = 1; - - size_t marked = 0; - // Rebuild the work queue with candidates deferred to the next frame. - // Items removed within this frame are intentionally left out. - draining.clear(); - for (const RemovalCandidate& candidate : candidates) { - if (marked >= budget) { - draining.push_back(candidate.appId); - continue; + // Clearing ownership makes ShouldShowAppInLibrary() false (delta drops it, + // the full snapshot skips it); MarkAppChange triggers the flush. + HOOK_FUNC(CSteamUIAppControllerRunFrame, void *, void *pController) + { + if (CAPTURE_READY(GetAppByID) && CAPTURE_READY(MarkAppChange)) + { + std::vector draining; + { + std::lock_guard lock(g_removalMutex); + draining.swap(g_pendingRemovals); + } + for (AppId_t appId : draining) + { + if (LuaConfig::IsOwned(appId)) + { + LOG_STEAMUI_DEBUG("RunFrame: appId {} is owned again, skipping removal", appId); + continue; + } + if (CSteamApp *pApp = oGetAppByID(g_pController, appId, false)) + { + // Only remove from the library if it's not already uninstalled + pApp->OwnershipFlags = k_EAppOwnershipFlags_None; + if(pApp->AppStateFlags == k_EAppStateUninstalled){ + std::lock_guard lock(g_removalMutex); + g_removedAppIds.insert(appId); + } + } + + oMarkAppChange(g_pAppChangeSource, appId, EAppChangeFlags::AppInfoOrConfig); } - - if (RemoveAppAndSendChange(candidate)) - ++marked; } - - LOG_STEAMUI_DEBUG("RunFrame: visible removals={}, removed={}, deferred={}", - candidates.size(), marked, draining.size()); - } - - // CSteamUIAppController::RunFrame - the controller's per-frame tick on the UI - // thread; its tail flushes pending overview changes to the JS library. We drain - // a budgeted batch of removals here so each flush stays on the delta path. - HOOK_FUNC(CSteamUIAppControllerRunFrame, void*, void* pController) - { - static std::vector s_draining; - - PullQueuedRemovals(s_draining); - DrainRemovalBatch(pController, s_draining); return oCSteamUIAppControllerRunFrame(pController); } } -namespace Hooks_SteamUI { - void Install() { - +namespace Hooks_SteamUI +{ + void Install() + { ARM_CAPTURE_U(GetAppByID); ARM_CAPTURE_U(MarkAppChange); - RESOLVE_U(ShouldShowAppInLibrary); - + RESOLVE_U(RepeatedFieldUint32_Add); + HOOK_BEGIN(); INSTALL_HOOK_U(FillInAppOverview); + INSTALL_HOOK_U(BuildCompleteAppOverviewChange); INSTALL_HOOK_U(CSteamUIAppControllerRunFrame); HOOK_END(); - } - void Uninstall() { + void Uninstall() + { UNHOOK_BEGIN(); UNINSTALL_HOOK(FillInAppOverview); + UNINSTALL_HOOK(BuildCompleteAppOverviewChange); UNINSTALL_HOOK(CSteamUIAppControllerRunFrame); UNHOOK_END(); } - void QueueRemoval(AppId_t appId) { + void QueueRemoval(AppId_t appId) + { std::lock_guard lock(g_removalMutex); g_pendingRemovals.push_back(appId); } + void CancelRemoval(AppId_t appId) + { + std::lock_guard lock(g_removalMutex); + std::erase(g_pendingRemovals, appId); + g_removedAppIds.erase(appId); + } } diff --git a/src/Hook/Hooks_SteamUI.h b/src/Hook/Hooks_SteamUI.h index c67d058..062febe 100644 --- a/src/Hook/Hooks_SteamUI.h +++ b/src/Hook/Hooks_SteamUI.h @@ -10,4 +10,6 @@ namespace Hooks_SteamUI { // Queues an appId for removal from the library UI void QueueRemoval(AppId_t appId); + // Cancels a queued removal when the app is added again before the UI drains it. + void CancelRemoval(AppId_t appId); } diff --git a/src/Steam/Enums.h b/src/Steam/Enums.h index 5fc23c3..157c7ca 100644 --- a/src/Steam/Enums.h +++ b/src/Steam/Enums.h @@ -1923,4 +1923,35 @@ enum class EAppChangeFlags { GameAction = 0x2000, LibraryAssetCleanup = 0x4000, MRURegenerated = 0x8000, +}; + +// Steam account types +enum EAccountType +{ + k_EAccountTypeInvalid = 0, + k_EAccountTypeIndividual = 1, // single user account + k_EAccountTypeMultiseat = 2, // multiseat (e.g. cybercafe) account + k_EAccountTypeGameServer = 3, // game server account + k_EAccountTypeAnonGameServer = 4, // anonymous game server account + k_EAccountTypePending = 5, // pending + k_EAccountTypeContentServer = 6, // content server + k_EAccountTypeClan = 7, + k_EAccountTypeChat = 8, + k_EAccountTypeConsoleUser = 9, // Fake SteamID for local PSN account on PS3 or Live account on 360, etc. + k_EAccountTypeAnonUser = 10, + + // Max of 16 items in this field + k_EAccountTypeMax +}; + +// Steam universes. Each universe is a self-contained Steam instance. +enum EUniverse +{ + k_EUniverseInvalid = 0, + k_EUniversePublic = 1, + k_EUniverseBeta = 2, + k_EUniverseInternal = 3, + k_EUniverseDev = 4, + // k_EUniverseRC = 5, // no such universe anymore + k_EUniverseMax }; \ No newline at end of file diff --git a/src/Steam/Structs.h b/src/Steam/Structs.h index 3974e76..b63efd8 100644 --- a/src/Steam/Structs.h +++ b/src/Steam/Structs.h @@ -10,6 +10,7 @@ #include #include +#include template struct CUtlMemory{ @@ -60,6 +61,10 @@ struct CUtlBuffer{ const uint8* Base() const { return m_Memory.m_pMemory; } int32 TellPut() const { return m_Put; } int32 TellGet() const { return m_Get; } + + uint32 Size() const { return m_Memory.m_nAllocationCount; } + uint32 Capacity() const { return m_Memory.m_nAllocationCount; } + // Debug helper std::string DebugString() const{ return std::format("m_Memory:0x{:X} m_AllocCnt:{} m_Grow:{} m_Get:{} m_Put:{} m_nOffset:{} m_flags:{}", @@ -114,16 +119,6 @@ struct AppOwnership bool bAllSiteLicenses; bool bAllActivationRequired; bool bFamilyShared; - - std::string DebugString() const { - return std::format("PackageId={} ReleaseState={} SteamId32={} MasterSubscriptionAppID={} TrialSeconds={} ExistInPackageNums={} \ - CountryCode={} TimeStamp={} TimeExpire={} OwnsLicense={} LicenseExpired={} IsPermanent={} LowViolence={} \ - FreeLicense={} RegionRestricted={} FromFreeWeekend={} LicenseLocked={} LicensePending={} RetailLicense={} \ - AutoGrant={} LicensePermanent={} GuestPass={} Borrowed={} AnySiteLicense={} AllSiteLicenses={} AllActivationRequired={} FamilyShared={}", - PackageId, static_cast(ReleaseState), SteamId32, MasterSubscriptionAppID, TrialSeconds, ExistInPackageNums, PurchaseCountryCode, TimeStamp, TimeExpire, - bOwnsLicense, bLicenseExpired, bIsPermanent, bLowViolence, bFreeLicense, bRegionRestricted, bFromFreeWeekend, bLicenseLocked, bLicensePending, - bRetailLicense, bAutoGrant, bLicensePermanent, bGuestPass, bBorrowed, bAnySiteLicense, bAllSiteLicenses, bAllActivationRequired, bFamilyShared); - } }; // Single depot manifest entry (0x20 bytes) produced by BuildDepotDependency. @@ -274,7 +269,7 @@ struct CGameID{ k_EGameIDTypeShortcut = 2, k_EGameIDTypeP2P = 3, }; - + bool IsSteamApp() const { return ( m_gameID.m_nType == k_EGameIDTypeApp ); @@ -306,9 +301,56 @@ struct CGameID{ }; }; +struct CSteamID +{ + CSteamID() + { + m_steamid.m_comp.m_unAccountID = 0; + m_steamid.m_comp.m_EAccountType = k_EAccountTypeInvalid; + m_steamid.m_comp.m_EUniverse = k_EUniverseInvalid; + m_steamid.m_comp.m_unAccountInstance = 0; + } + + CSteamID( uint64 ulSteamID ) + { + SetFromUint64( ulSteamID ); + } + + void SetFromUint64( uint64 ulSteamID ) + { + m_steamid.m_unAll64Bits = ulSteamID; + } + + uint64 ConvertToUint64() const + { + return m_steamid.m_unAll64Bits; + } + + void SetAccountID( uint32 unAccountID ) { m_steamid.m_comp.m_unAccountID = unAccountID; } + AccountID_t GetAccountID() const { return m_steamid.m_comp.m_unAccountID; } + + friend std::ostream& operator<<(std::ostream& os, const CSteamID& steamId){ + return os << steamId.ConvertToUint64(); + } + +private: + union + { + struct SteamIDComponent_t + { + uint32 m_unAccountID : 32; // unique account identifier + unsigned int m_unAccountInstance : 20; // dynamic instance ID + unsigned int m_EAccountType : 4; // type of account - can't show as EAccountType, due to signed / unsigned difference + EUniverse m_EUniverse : 8; // universe this account belongs to + } m_comp; + uint64 m_unAll64Bits; + } m_steamid; + +}; + struct CAppData { - void** fptr; + void** vfptr; AppId_t nAppID; uint32 ChangeNumber; uint32 LastChangeTimeStamp; @@ -326,25 +368,25 @@ struct CAppData bool IsUnresolvedAppInfo() const { return HasEmptyAppInfoSha() && !bSkipFlag; } - - std::string DebugString() const { - return std::format("AppID={} ChangeNumber={} LastChangeTimeStamp={} SkipFlag={} DeniedToken={} MissingToken={} AccessToken={}", - nAppID, ChangeNumber, LastChangeTimeStamp, bSkipFlag, bDeniedToken, bMissingToken, accessToken); - } }; #pragma pack(push,1) struct CSteamApp { - void** fptr; + void** vfptr; CGameID GameID; AppId_t nAppID; uint16 _unknown1; uint16 _unknown2; EAppReleaseState ReleaseState; EAppOwnershipFlags OwnershipFlags; - uint32 _unknown3; - uint64 SteamID; + EAppState AppStateFlags; + CSteamID SteamID; uint32 PurchasedTime; + uint32 ChangeNumber; + uint32 LicenseExpirationTime; + AppId_t MasterSubAppID; + uint32 eProtoAppType; + AppId_t ParentAppID; }; -#pragma pack(pop) \ No newline at end of file +#pragma pack(pop) diff --git a/src/proto/steam_messages.proto b/src/proto/steam_messages.proto index da3604c..941c2e3 100644 --- a/src/proto/steam_messages.proto +++ b/src/proto/steam_messages.proto @@ -321,11 +321,155 @@ message CMsgClientRichPresenceUpload { repeated fixed64 steamid_broadcast = 2; } +// ============================================================ +// CAppOverview +// ============================================================ +message CAppOverview { + optional uint32 appid = 1; + optional string display_name = 2; + optional bool visible_in_game_list = 4; + optional bool subscribed_to = 5; + optional string sort_as = 6; + optional EProtoAppType app_type = 7; + optional uint32 mru_index = 13; + optional uint32 rt_recent_activity_time = 14 [default = 0]; + optional uint32 minutes_playtime_forever = 16 [default = 0]; + optional uint32 minutes_playtime_last_two_weeks = 17 [default = 0]; + optional uint32 rt_last_time_played = 18 [default = 0]; + repeated uint32 store_tag = 19; + repeated uint32 store_category = 23; + optional uint32 rt_original_release_date = 25 [default = 0]; + optional uint32 rt_steam_release_date = 26 [default = 0]; + optional string icon_hash = 27; + optional EAppControllerSupportLevel xbox_controller_support = 31; + optional bool vr_supported = 32; + optional uint32 metacritic_score = 36; + optional uint64 size_on_disk = 37; + optional bool third_party_mod = 38; + optional string icon_data = 39; + optional string icon_data_format = 40; + optional string gameid = 41; + optional string library_capsule_filename = 42; + repeated CAppOverview_PerClientData per_client_data = 43; + optional uint64 most_available_clientid = 44 [default = 0]; + optional uint64 selected_clientid = 45 [default = 0]; + optional uint32 rt_store_asset_mtime = 46; + optional uint32 rt_custom_image_mtime = 47; + optional uint32 optional_parent_app_id = 48; + optional uint32 owner_account_id = 49; + optional uint32 review_score_with_bombs = 53 [default = 0]; + optional uint32 review_percentage_with_bombs = 54 [default = 0]; + optional uint32 review_score_without_bombs = 55 [default = 0]; + optional uint32 review_percentage_without_bombs = 56 [default = 0]; + optional string library_id = 57; + optional bool vr_only = 58; + optional uint32 mastersub_appid = 59; + optional string mastersub_includedwith_logo = 60; + optional string site_license_site_name = 62; + optional uint32 shortcut_override_appid = 63; + optional uint32 rt_last_time_locally_played = 65; + optional uint32 rt_purchased_time = 66; + optional string header_filename = 67; + optional uint32 local_cache_version = 68; + optional uint32 number_of_copies = 72 [default = 1]; + optional uint32 steam_hw_compat_category_packed = 73 [default = 0]; + optional string album_cover_hash = 74; + optional int32 display_name_elanguage = 75 [default = -1]; + optional bool has_custom_sort_as = 76; + optional uint64 bitfield_supported_languages = 77 [default = 0]; + repeated CAppOverview_PerClientData remote_per_client_data = 78; +} +enum EProtoAppType { + k_EAppTypeInvalid = 0; + k_EAppTypeGame = 1; + k_EAppTypeApplication = 2; + k_EAppTypeTool = 4; + k_EAppTypeDemo = 8; + k_EAppTypeDeprected = 16; + k_EAppTypeDLC = 32; + k_EAppTypeGuide = 64; + k_EAppTypeDriver = 128; + k_EAppTypeConfig = 256; + k_EAppTypeHardware = 512; + k_EAppTypeFranchise = 1024; + k_EAppTypeVideo = 2048; + k_EAppTypePlugin = 4096; + k_EAppTypeMusicAlbum = 8192; + k_EAppTypeSeries = 16384; + k_EAppTypeComic = 32768; + k_EAppTypeBeta = 65536; + k_EAppTypeShortcut = 1073741824; + k_EAppTypeDepotOnly = -2147483648; +} +enum EAppControllerSupportLevel { + k_EAppControllerSupportLevelNone = 0; + k_EAppControllerSupportLevelPartial = 1; + k_EAppControllerSupportLevelFull = 2; +} + // ============================================================ // CAppOverview_Change // ============================================================ message CAppOverview_Change { - repeated uint32 removed_appid = 2; - optional bool full_update = 3; - optional bool update_complete = 4; + repeated CAppOverview app_overview = 1; + repeated uint32 removed_appid = 2; + optional bool full_update = 3; + optional bool update_complete = 4; +} +// ============================================================ +// CAppOverview_PerClientData +// ============================================================ +message CAppOverview_PerClientData { + optional uint64 clientid = 1 [default = 0]; + optional string client_name = 2; + optional EDisplayStatus display_status = 3; + optional uint32 status_percentage = 4; + optional string active_beta = 5; + optional bool installed = 6; + optional bool streaming_to_local_client = 9; + optional bool is_available_on_current_platform = 10; + optional bool is_invalid_os_type = 11; + optional uint32 playtime_left = 12; + optional bool update_available_but_disabled_by_app = 14; } +enum EDisplayStatus { + k_EDisplayStatusInvalid = 0; + k_EDisplayStatusLaunching = 1; + k_EDisplayStatusUninstalling = 2; + k_EDisplayStatusInstalling = 3; + k_EDisplayStatusRunning = 4; + k_EDisplayStatusValidating = 5; + k_EDisplayStatusUpdating = 6; + k_EDisplayStatusDownloading = 7; + k_EDisplayStatusSynchronizing = 8; + k_EDisplayStatusReadyToInstall = 9; + k_EDisplayStatusReadyToPreload = 10; + k_EDisplayStatusReadyToLaunch = 11; + k_EDisplayStatusRegionRestricted = 12; + k_EDisplayStatusPresaleOnly = 13; + k_EDisplayStatusInvalidPlatform = 14; + k_EDisplayStatusPreloadComplete = 16; + k_EDisplayStatusBorrowerLocked = 17; + k_EDisplayStatusUpdatePaused = 18; + k_EDisplayStatusUpdateQueued = 19; + k_EDisplayStatusUpdateRequired = 20; + k_EDisplayStatusUpdateDisabled = 21; + k_EDisplayStatusDownloadPaused = 22; + k_EDisplayStatusDownloadQueued = 23; + k_EDisplayStatusDownloadRequired = 24; + k_EDisplayStatusDownloadDisabled = 25; + k_EDisplayStatusLicensePending = 26; + k_EDisplayStatusLicenseExpired = 27; + k_EDisplayStatusAvailForFree = 28; + k_EDisplayStatusAvailToBorrow = 29; + k_EDisplayStatusAvailGuestPass = 30; + k_EDisplayStatusPurchase = 31; + k_EDisplayStatusUnavailable = 32; + k_EDisplayStatusNotLaunchable = 33; + k_EDisplayStatusCloudError = 34; + k_EDisplayStatusCloudOutOfDate = 35; + k_EDisplayStatusTerminating = 36; + k_EDisplayStatusOwnerLocked = 37; + k_EDisplayStatusDownloadFailed = 38; + k_EDisplayStatusUpdateFailed = 39; +} \ No newline at end of file