From 0fff24acffb505f1b9770ae21f1c206b7cba2277 Mon Sep 17 00:00:00 2001 From: Selectively11 Date: Sun, 28 Jun 2026 21:24:07 -0400 Subject: [PATCH 1/2] Fix Cloud RPC timing (vtable hooks) and add stats sync support --- src/Hook/Hooks_NetPacket.cpp | 59 +++++++++++++--- src/Utils/CloudRedirect/CloudRedirectHost.cpp | 67 +++++++++++++++++-- src/Utils/CloudRedirect/CloudRedirectHost.h | 12 ++++ 3 files changed, 122 insertions(+), 16 deletions(-) diff --git a/src/Hook/Hooks_NetPacket.cpp b/src/Hook/Hooks_NetPacket.cpp index a5022d5..700dc8c 100644 --- a/src/Hook/Hooks_NetPacket.cpp +++ b/src/Hook/Hooks_NetPacket.cpp @@ -21,7 +21,7 @@ // ════════════════════════════════════════════════════════════════ namespace { - constexpr uint32 kMaxBodySize = 8092; + constexpr uint32 kMaxBodySize = 65536; constexpr uint32 kMaxHdrSize = 1024; constexpr uint32 kMaxPacketSize = 8 + kMaxHdrSize + kMaxBodySize; constexpr int kPacketPoolSize = 8; @@ -338,9 +338,9 @@ namespace Hooks_NetPacket_UserStats { LOG_ACHIEVEMENT_WARN("ClientGetUserStats request: appid={} is not in addappid", appId); return false; } - if (!req.has_schema_local_version() || req.schema_local_version() != -1) { - LOG_ACHIEVEMENT_WARN("ClientGetUserStats request: schema_local_version is not -1"); - return false; + if (req.schema_local_version() != -1) { + req.set_schema_local_version(-1); + LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats request: forced schema_local_version to -1"); } uint64_t newSteamId = LuaConfig::GetStatSteamId(appId); @@ -357,7 +357,7 @@ namespace Hooks_NetPacket_UserStats { } // ── Recv: CMsgClientGetUserStatsResponse (eMsg 819) ──────── - // Strip stats(5) + achievement_blocks(6), patch eresult->OK. + // Clear donor stats, overlay CR achievements, patch eresult->OK. bool HandleRecv_ClientGetUserStatsResponse(const uint8* pBody, uint32 cbBody) { CMsgClientGetUserStatsResponse resp; @@ -371,13 +371,39 @@ namespace Hooks_NetPacket_UserStats { resp.clear_stats(); resp.clear_achievement_blocks(); resp.set_eresult(1); // k_EResultOK - LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats response: clear stats and achievement_blocks, set eresult=OK"); - g_NewBodySize = static_cast(resp.ByteSizeLong()); - if (!resp.SerializeToArray(const_cast(pBody), cbBody)) + // Overlay CR's cloud-synced achievement state + const auto appId = static_cast(resp.game_id()); + CloudRedirectHost::AchievementBlock blocks[64]; + uint32_t n = CloudRedirectHost::GetAchievements(appId, blocks, 64); + if (n > 0) { + uint32_t crc = 0; + for (uint32_t i = 0; i < n; i++) { + auto* s = resp.add_stats(); + s->set_stat_id(blocks[i].statId); + s->set_stat_value(blocks[i].bits); + auto* ab = resp.add_achievement_blocks(); + ab->set_achievement_id(blocks[i].statId); + for (uint32_t bit = 0; bit < 32; bit++) + ab->add_unlock_time(blocks[i].unlockTimes[bit]); + crc ^= blocks[i].bits ^ blocks[i].statId; + } + resp.set_crc_stats(crc); + LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats response: injected {} CR achievement blocks for app {}", n, appId); + } else { + LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats response: cleared stats/achievements (no CR data for app {})", appId); + } + + auto newSize = resp.ByteSizeLong(); + if (newSize > sizeof(g_NewBody)) { + LOG_ACHIEVEMENT_WARN("ClientGetUserStats response: modified message too large ({} bytes)", newSize); + return false; + } + if (!resp.SerializeToArray(g_NewBody, sizeof(g_NewBody))) return false; - g_ResizedInPlace = true; + g_cbNewBody = static_cast(newSize); + g_NeedReplaceBody = true; LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats response: modified body:\n{}", resp.DebugString()); return true; } @@ -746,6 +772,8 @@ namespace Hooks_NetPacket_RichPresence { if (hdr.ParseFromArray(pHdr, cbHdr) && hdr.has_steamid() && hdr.steamid()) { g_LocalSteamId = hdr.steamid(); LOG_RICHPRESENCE_DEBUG("Captured local SteamID 0x{:X}", g_LocalSteamId); + CloudRedirectHost::SetAccountId( + static_cast(g_LocalSteamId & 0xFFFFFFFF)); } } @@ -766,8 +794,14 @@ namespace Hooks_NetPacket_RichPresence { newTracked = topmost; if (g_PlayingAppId == newTracked) return; + AppId_t oldTracked = g_PlayingAppId; g_PlayingAppId = newTracked; + if (oldTracked != 0) + CloudRedirectHost::NotifyAppRunning(oldTracked, false); + if (newTracked != 0) + CloudRedirectHost::NotifyAppRunning(newTracked, true); + if (newTracked != 0) { LOG_RICHPRESENCE_INFO("Tracking topmost appid {}", newTracked); if (BuildInject(newTracked)) g_InjectPending = true; @@ -1153,6 +1187,13 @@ namespace { g_NeedReplaceSend = Hooks_NetPacket_UserStats::HandleSend_ClientGetUserStats(pBody, cbBody); return; + case k_EMsgClientStoreUserStats2: { // 5466 + AppId_t appId = Hooks_NetPacket_RichPresence::g_PlayingAppId; + if (appId != 0) + CloudRedirectHost::NotifyStatsStored(appId); + return; + } + default: return; } diff --git a/src/Utils/CloudRedirect/CloudRedirectHost.cpp b/src/Utils/CloudRedirect/CloudRedirectHost.cpp index 3a593f9..ee12043 100644 --- a/src/Utils/CloudRedirect/CloudRedirectHost.cpp +++ b/src/Utils/CloudRedirect/CloudRedirectHost.cpp @@ -24,18 +24,30 @@ namespace { using CR_AddApp_t = void (*)(uint32_t appId); using CR_RemoveApp_t = void (*)(uint32_t appId); using CR_IsApp_t = bool (*)(uint32_t appId); - using CR_SetApps_t = void (*)(const uint32_t* appIds, uint32_t count); - using CR_Shutdown_t = void (*)(); + using CR_SetApps_t = void (*)(const uint32_t* appIds, uint32_t count); + using CR_Shutdown_t = void (*)(); + using CR_EnableStatsSync_t = void (*)(bool, bool); + using CR_SetAccountId_t = void (*)(uint32_t); + using CR_NotifyAppRunning_t = void (*)(uint32_t, bool); + using CR_NotifyStatsStored_t = void (*)(uint32_t); + using CR_GetAchievements_t = uint32_t (*)(uint32_t, CloudRedirectHost::AchievementBlock*, uint32_t); + using CR_InstallVtableHooks_t = bool (*)(); std::mutex g_mutex; std::atomic g_active{false}; OSTPlatform::DynamicLibrary::ModuleHandle g_module = nullptr; - CR_InitCloudSave_t g_initCloudSave = nullptr; - CR_HandleCloudRpc_t g_handleCloudRpc = nullptr; - CR_SetApps_t g_setApps = nullptr; - CR_IsApp_t g_isApp = nullptr; - CR_Shutdown_t g_shutdownFn = nullptr; + CR_InitCloudSave_t g_initCloudSave = nullptr; + CR_HandleCloudRpc_t g_handleCloudRpc = nullptr; + CR_SetApps_t g_setApps = nullptr; + CR_IsApp_t g_isApp = nullptr; + CR_Shutdown_t g_shutdownFn = nullptr; + CR_EnableStatsSync_t g_enableStatsSync = nullptr; + CR_SetAccountId_t g_setAccountId = nullptr; + CR_NotifyAppRunning_t g_notifyAppRunning = nullptr; + CR_NotifyStatsStored_t g_notifyStatsStored = nullptr; + CR_GetAchievements_t g_getAchievements = nullptr; + CR_InstallVtableHooks_t g_installVtableHooks = nullptr; // Routes CloudRedirect's notifications into OpenSteamTool's log instead of // popping a MessageBox from inside Steam. @@ -112,6 +124,14 @@ void Initialize(const char* steamInstallPath) { return; } + // Optional (CR 2.2.5+) + ResolveSymbol(g_module, "CR_EnableStatsSync", g_enableStatsSync); + ResolveSymbol(g_module, "CR_SetAccountId", g_setAccountId); + ResolveSymbol(g_module, "CR_NotifyAppRunning", g_notifyAppRunning); + ResolveSymbol(g_module, "CR_NotifyStatsStored", g_notifyStatsStored); + ResolveSymbol(g_module, "CR_GetAchievements", g_getAchievements); + ResolveSymbol(g_module, "CR_InstallVtableHooks", g_installVtableHooks); + if (!g_initCloudSave(steamInstallPath, &CloudNotify)) { LOG_WARN("CloudRedirect: CR_InitCloudSave failed, disabling cloud save redirection"); g_module = nullptr; @@ -122,12 +142,25 @@ void Initialize(const char* steamInstallPath) { LOG_INFO("CloudRedirect: loaded {} and initialised cloud save redirection", libPath.string()); + if (g_enableStatsSync) { + g_enableStatsSync(true, true); + LOG_INFO("CloudRedirect: stats sync registered"); + } + // Push the current unlocked-app set without re-locking g_mutex. std::vector depots = LuaConfig::GetAllDepotIds(); std::vector appIds(depots.begin(), depots.end()); g_setApps(appIds.empty() ? nullptr : appIds.data(), static_cast(appIds.size())); LOG_INFO("CloudRedirect: registered {} redirected app(s)", appIds.size()); + + // Vtable hooks let CR handle Cloud RPCs synchronously (slot4 semantics). + if (g_installVtableHooks) { + if (g_installVtableHooks()) + LOG_INFO("CloudRedirect: vtable hooks installed"); + else + LOG_WARN("CloudRedirect: vtable hook install failed, using packet-layer path"); + } } void SyncAppSet() { @@ -158,6 +191,26 @@ bool HandleCloudRpc(const char* method, uint32_t appId, uint32_t accountId, respBuf, respMaxLen, respLen, eresult); } +void SetAccountId(uint32_t accountId) { + if (!g_active.load(std::memory_order_acquire) || !g_setAccountId) return; + g_setAccountId(accountId); +} + +void NotifyAppRunning(uint32_t appId, bool running) { + if (!g_active.load(std::memory_order_acquire) || !g_notifyAppRunning) return; + g_notifyAppRunning(appId, running); +} + +void NotifyStatsStored(uint32_t appId) { + if (!g_active.load(std::memory_order_acquire) || !g_notifyStatsStored) return; + g_notifyStatsStored(appId); +} + +uint32_t GetAchievements(uint32_t appId, AchievementBlock* out, uint32_t maxBlocks) { + if (!g_active.load(std::memory_order_acquire) || !g_getAchievements) return 0; + return g_getAchievements(appId, out, maxBlocks); +} + void Shutdown() { std::lock_guard lock(g_mutex); if (!g_active.exchange(false)) return; diff --git a/src/Utils/CloudRedirect/CloudRedirectHost.h b/src/Utils/CloudRedirect/CloudRedirectHost.h index c2e4a1c..2c3c5f0 100644 --- a/src/Utils/CloudRedirect/CloudRedirectHost.h +++ b/src/Utils/CloudRedirect/CloudRedirectHost.h @@ -34,6 +34,18 @@ namespace CloudRedirectHost { uint8_t* respBuf, uint32_t respMaxLen, uint32_t* respLen, int32_t* eresult); + void SetAccountId(uint32_t accountId); + void NotifyAppRunning(uint32_t appId, bool running); + void NotifyStatsStored(uint32_t appId); + + struct AchievementBlock { + uint32_t statId; + uint32_t bits; + uint32_t unlockTimes[32]; + }; + // Returns number of blocks written (0 if none/disabled). + uint32_t GetAchievements(uint32_t appId, AchievementBlock* out, uint32_t maxBlocks); + // Teardown. Called from DLL_PROCESS_DETACH. void Shutdown(); From fd988353116240d320434224315bbe91b6f6595b Mon Sep 17 00:00:00 2001 From: Selectively11 Date: Thu, 2 Jul 2026 20:38:30 -0400 Subject: [PATCH 2/2] Do not suppress ExitSyncDone and ConflictResolution on packet-layer escape --- src/Hook/Hooks_NetPacket.cpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Hook/Hooks_NetPacket.cpp b/src/Hook/Hooks_NetPacket.cpp index 700dc8c..5fe24be 100644 --- a/src/Hook/Hooks_NetPacket.cpp +++ b/src/Hook/Hooks_NetPacket.cpp @@ -1132,7 +1132,12 @@ namespace { // Steam Cloud save redirection: hand every "Cloud.*" request to // CloudRedirect. If it answers, suppress the outbound frame (the // synthesized response is delivered from the RecvPkt hook). + // ExitSyncDone/ConflictResolution are notifications that must reach + // Steam's internal cloud state machine untouched. if (std::strncmp(targetJobName, "Cloud.", 6) == 0) { + if (std::strcmp(targetJobName, "Cloud.SignalAppExitSyncDone#1") == 0 || + std::strcmp(targetJobName, "Cloud.ClientConflictResolution#1") == 0) + return false; if (Hooks_NetPacket_Cloud::HandleSend(targetJobName, pBody, cbBody, pHdr, cbHdr)) g_SuppressSend = true; return false; // never body-replace a cloud frame