From 76e01e56557d0841f4b2ff3b270dd20a0e64e37f Mon Sep 17 00:00:00 2001 From: Selectively11 Date: Fri, 5 Jun 2026 12:05:12 -0400 Subject: [PATCH 01/24] Fix 32-bit stat overflow on btrfs for token file reads --- CMakeLists.txt | 2 +- src/platform/linux/token_store_linux.cpp | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index fa3395f6..0038313c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -149,7 +149,7 @@ else() target_link_libraries(cloud_redirect PRIVATE dl pthread) # Steam on Linux is 32-bit; use LINUX_32BIT=ON when cross-compiling on 64-bit host # Match Steam's pre-C++11 ABI - target_compile_definitions(cloud_redirect PRIVATE _GLIBCXX_USE_CXX11_ABI=0) + target_compile_definitions(cloud_redirect PRIVATE _GLIBCXX_USE_CXX11_ABI=0 _FILE_OFFSET_BITS=64) if(LINUX_32BIT) target_compile_options(cloud_redirect PRIVATE -m32) target_link_options(cloud_redirect PRIVATE -m32) diff --git a/src/platform/linux/token_store_linux.cpp b/src/platform/linux/token_store_linux.cpp index cbe3f09e..e7df3418 100644 --- a/src/platform/linux/token_store_linux.cpp +++ b/src/platform/linux/token_store_linux.cpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include // ── libsecret runtime binding (dlopen, no compile-time dependency) ────── @@ -148,7 +150,7 @@ static bool WriteTokenToSecretService(const std::string& provider, const std::st static std::string ReadTokenFileFallback(const std::string& path, std::string& outError) { struct stat st; if (stat(path.c_str(), &st) != 0) { - outError = "file does not exist"; + outError = std::string("stat failed: ") + strerror(errno); return {}; } if (!S_ISREG(st.st_mode)) { From 214796dfd82217f7ccf83d9c7bdb152998cf4873 Mon Sep 17 00:00:00 2001 From: Selectively11 Date: Fri, 5 Jun 2026 16:52:13 -0400 Subject: [PATCH 02/24] Show LUA games as non-Steam game in friends status --- src/common/autocloud_scan.cpp | 4 + src/common/autocloud_scan.h | 4 + src/common/protobuf.cpp | 7 + src/common/protobuf.h | 1 + src/platform/win/cloud_intercept.cpp | 272 ++++++++++++++++++++++++++- src/platform/win/cloud_intercept.h | 1 + src/platform/win/dllmain.cpp | 2 + 7 files changed, 290 insertions(+), 1 deletion(-) diff --git a/src/common/autocloud_scan.cpp b/src/common/autocloud_scan.cpp index 76a68b19..0b1ca472 100644 --- a/src/common/autocloud_scan.cpp +++ b/src/common/autocloud_scan.cpp @@ -1382,4 +1382,8 @@ std::unordered_map GetRootTokenDirectories( return result; } +std::string GetAppName(const std::string& steamPath, uint32_t appId) { + return GetAppNameFromAppInfo(steamPath, appId); +} + } // namespace AutoCloudScan diff --git a/src/common/autocloud_scan.h b/src/common/autocloud_scan.h index 6dbbf747..b1617147 100644 --- a/src/common/autocloud_scan.h +++ b/src/common/autocloud_scan.h @@ -51,4 +51,8 @@ std::vector GetRootOverrides( std::unordered_map GetRootTokenDirectories( const std::string& steamPath, uint32_t appId, uint32_t accountId = 0); +// Look up a game's display name from Steam's appinfo.vdf cache. +// Returns empty string if not found. +std::string GetAppName(const std::string& steamPath, uint32_t appId); + } // namespace AutoCloudScan diff --git a/src/common/protobuf.cpp b/src/common/protobuf.cpp index 2b132576..ac7dbe40 100644 --- a/src/common/protobuf.cpp +++ b/src/common/protobuf.cpp @@ -118,6 +118,13 @@ void Writer::WriteFixed64(uint32_t fieldNum, uint64_t value) { memcpy(buf_.data() + pos, &value, 8); } +void Writer::WriteFixed32(uint32_t fieldNum, uint32_t value) { + WriteTag(fieldNum, Fixed32); + size_t pos = buf_.size(); + buf_.resize(pos + 4); + memcpy(buf_.data() + pos, &value, 4); +} + void Writer::WriteBytes(uint32_t fieldNum, const uint8_t* data, size_t len) { WriteTag(fieldNum, LengthDelimited); WriteRawVarint(len); diff --git a/src/common/protobuf.h b/src/common/protobuf.h index c10a0345..9c09339f 100644 --- a/src/common/protobuf.h +++ b/src/common/protobuf.h @@ -37,6 +37,7 @@ class Writer { // For uint32 UFS fields, use ClampFileSizeToUint32 (Steam truncates mod 2^32). void WriteVarint(uint32_t fieldNum, uint64_t value); void WriteFixed64(uint32_t fieldNum, uint64_t value); + void WriteFixed32(uint32_t fieldNum, uint32_t value); void WriteBytes(uint32_t fieldNum, const uint8_t* data, size_t len); void WriteString(uint32_t fieldNum, std::string_view str); void WriteSubmessage(uint32_t fieldNum, const Writer& sub); diff --git a/src/platform/win/cloud_intercept.cpp b/src/platform/win/cloud_intercept.cpp index 69dacb68..399ef523 100644 --- a/src/platform/win/cloud_intercept.cpp +++ b/src/platform/win/cloud_intercept.cpp @@ -39,6 +39,8 @@ #include #include +namespace AutoCloudScan { std::string GetAppName(const std::string& steamPath, uint32_t appId); } + namespace CloudIntercept { static void ShutdownImpl(); @@ -66,6 +68,18 @@ static constexpr uintptr_t RVA_DEPOT_MANIFEST_CNT = 0x1C3870; // g_nDepotManif static constexpr uint32_t EMSG_CLIENT_PICSPRODUCTINFO = 8903; +// CMsgClientGamesPlayed EMsg variants +static constexpr uint32_t EMSG_CLIENT_GAMES_PLAYED = 742; +static constexpr uint32_t EMSG_CLIENT_GAMES_PLAYED_NO_DATABLOB = 715; +static constexpr uint32_t EMSG_CLIENT_GAMES_PLAYED_WITH_DATABLOB = 5410; + +// CMsgClientGamesPlayed protobuf field numbers +static constexpr uint32_t GP_FIELD_GAMES_PLAYED = 1; // repeated GamePlayed (length-delimited) +// CMsgClientGamesPlayed.GamePlayed field numbers +static constexpr uint32_t GP_FIELD_GAME_ID = 2; // fixed64 +static constexpr uint32_t GP_FIELD_GAME_EXTRA_INFO = 7; // string +static constexpr uint32_t GP_FIELD_OWNER_ID = 12; // uint32 + // steamclient64.dll RVAs for manifest pinning inline detour // IDA image base: 0x138000000 // sub_1384C4040 = CUserAppManager::BuildDepotDependency @@ -77,9 +91,16 @@ static constexpr uint32_t EMSG_CLIENT_PICSPRODUCTINFO = 8903; // Depot vectors: *(QWORD*)vec = array base, *(int*)(vec+16) = count // Each entry is 32 bytes: {uint32 depotId, uint32 appId, uint64 manifestId, ...} static constexpr uintptr_t SC_RVA_BUILD_DEPOT_DEPENDENCY = 0x4AC910; - static constexpr size_t SC_BDD_STOLEN_BYTES = 14; // first 14 bytes of prologue +// CProtoBufMsg::BAsyncSend(uint32_t connectionHandle) +// Hooks this to inject game_extra_info into CMsgClientGamesPlayed before serialization. +static constexpr uintptr_t SC_RVA_BASYNC_SEND = 0xCF0DF0; +static constexpr size_t SC_BAS_STOLEN_BYTES = 15; // 5+5+1+4 bytes of prologue +// CProtoBufMsg layout offsets +static constexpr uint32_t CPROTOBUFMSG_OFF_EMSG = 0x20; // uint32_t EMsg | PROTO_FLAG +static constexpr uint32_t CPROTOBUFMSG_OFF_BODY = 0x30; // protobuf body object* + // steamclient64.dll RVAs for CCMInterface discovery // IDA image base: 0x138000000 // qword_1397A70E8 = global CSteamEngine* pointer @@ -4302,7 +4323,126 @@ void InstallReleaseStateNop() { // Stub -- release-state patching removed from public builds. } +// ── BAsyncSend inline detour (GamesPlayed rewriting) ─────────────────── +// +// Intercepts CProtoBufMsg::BAsyncSend before serialization. +// If the message is CMsgClientGamesPlayed, we serialize the body, inject +// game_extra_info for namespace apps, and write the modified body back +// into the live protobuf object before the original function serializes +// and sends it. + +using BAsyncSendFn = uint8_t(__fastcall*)(void* pMsg, uint32_t connHandle); + +static uint8_t* g_basOrigAddr = nullptr; // original BAsyncSend address +static uint8_t g_basTrampoline[64] = {}; // trampoline: stolen prologue + jmp back +static BAsyncSendFn g_basOriginal = nullptr; // trampoline as callable + +// Forward declaration - defined later in file +static std::vector RewriteGamesPlayed(const uint8_t* body, uint32_t bodyLen); + +// Rewrite the GamesPlayed body in-place on the live protobuf object. +static void RewriteGamesPlayedBody(void* bodyObj) { + auto bodyBytes = SerializeBodyToBytes(bodyObj); + if (bodyBytes.empty()) return; + auto newBody = RewriteGamesPlayed(bodyBytes.data(), (uint32_t)bodyBytes.size()); + if (newBody.empty()) return; + + ParseBytesToBody(bodyObj, newBody.data(), newBody.size()); +} + +static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { + if (HasNamespaceApps() && pMsg && g_serializeToArray && g_parseFromArray) { + uint32_t emsgRaw = *(uint32_t*)((uintptr_t)pMsg + CPROTOBUFMSG_OFF_EMSG); + uint32_t emsg = emsgRaw & EMSG_MASK; + + if (emsg == EMSG_CLIENT_GAMES_PLAYED || + emsg == EMSG_CLIENT_GAMES_PLAYED_NO_DATABLOB || + emsg == EMSG_CLIENT_GAMES_PLAYED_WITH_DATABLOB) { + + void* bodyObj = *(void**)((uintptr_t)pMsg + CPROTOBUFMSG_OFF_BODY); + if (bodyObj) { + RewriteGamesPlayedBody(bodyObj); + } + } + } + + return g_basOriginal(pMsg, connHandle); +} + +void InstallGamesPlayedHook() { + if (!HasNamespaceApps()) return; + + HMODULE hSteamClient = GetModuleHandleA("steamclient64.dll"); + if (!hSteamClient) { + LOG("[GamesPlayed] steamclient64.dll not loaded"); + return; + } + + uintptr_t scBase = reinterpret_cast(hSteamClient); + g_basOrigAddr = reinterpret_cast(scBase + SC_RVA_BASYNC_SEND); + + LOG("[GamesPlayed] BAsyncSend at %p (sc+0x%X)", g_basOrigAddr, SC_RVA_BASYNC_SEND); + + // Verify prologue matches expected bytes + static const uint8_t expectedPrologue[SC_BAS_STOLEN_BYTES] = { + 0x48, 0x89, 0x5C, 0x24, 0x10, // mov [rsp+10h], rbx + 0x48, 0x89, 0x74, 0x24, 0x18, // mov [rsp+18h], rsi + 0x57, // push rdi + 0x48, 0x83, 0xEC, 0x50 // sub rsp, 50h + }; + + if (memcmp(g_basOrigAddr, expectedPrologue, SC_BAS_STOLEN_BYTES) != 0) { + LOG("[GamesPlayed] Prologue mismatch at BAsyncSend -- skipping hook"); + g_basOrigAddr = nullptr; + return; + } + + // Build trampoline: stolen bytes + jmp back to original+15 + DWORD oldTrampolineProt; + VirtualProtect(g_basTrampoline, sizeof(g_basTrampoline), PAGE_EXECUTE_READWRITE, &oldTrampolineProt); + + memcpy(g_basTrampoline, g_basOrigAddr, SC_BAS_STOLEN_BYTES); + uint8_t* jumpBack = g_basTrampoline + SC_BAS_STOLEN_BYTES; + // jmp [rip+0]; <8-byte addr> + jumpBack[0] = 0xFF; + jumpBack[1] = 0x25; + jumpBack[2] = 0x00; + jumpBack[3] = 0x00; + jumpBack[4] = 0x00; + jumpBack[5] = 0x00; + uintptr_t returnAddr = reinterpret_cast(g_basOrigAddr) + SC_BAS_STOLEN_BYTES; + memcpy(jumpBack + 6, &returnAddr, 8); + + g_basOriginal = reinterpret_cast(reinterpret_cast(g_basTrampoline)); + + // Patch original function with jmp to our hook + DWORD oldProt; + if (!VirtualProtect(g_basOrigAddr, SC_BAS_STOLEN_BYTES, PAGE_EXECUTE_READWRITE, &oldProt)) { + LOG("[GamesPlayed] VirtualProtect failed (%u)", GetLastError()); + g_basOrigAddr = nullptr; + return; + } + + // Build detour: jmp [rip+0]; <8-byte hookAddr>; nop (15 bytes = 14+1) + uint8_t detour[SC_BAS_STOLEN_BYTES]; + detour[0] = 0xFF; + detour[1] = 0x25; + detour[2] = 0x00; + detour[3] = 0x00; + detour[4] = 0x00; + detour[5] = 0x00; + uintptr_t hookAddr = reinterpret_cast(&BAsyncSendHook); + memcpy(detour + 6, &hookAddr, 8); + detour[14] = 0x90; // nop for the 15th byte + memcpy(g_basOrigAddr, detour, SC_BAS_STOLEN_BYTES); + + FlushInstructionCache(GetCurrentProcess(), g_basOrigAddr, SC_BAS_STOLEN_BYTES); + VirtualProtect(g_basOrigAddr, SC_BAS_STOLEN_BYTES, oldProt, &oldProt); + + LOG("[GamesPlayed] Inline detour installed at %p -> BAsyncSendHook %p", + g_basOrigAddr, (void*)hookAddr); +} void SetSendPktAddr(void* recvPktGlobalAddr) { if (!recvPktGlobalAddr) { @@ -4314,6 +4454,121 @@ void SetSendPktAddr(void* recvPktGlobalAddr) { LOG("[NS] payload_base=%p", (void*)g_payloadBase); } +// ── GamesPlayed rewriting ────────────────────────────────────────────── +// +// When Steam sends CMsgClientGamesPlayed (EMsg 742/715/5410), each +// games_played entry carries a game_id. For LUA-unlocked (namespace) +// apps the user has no server-side license, so friends see nothing or a +// raw appId. Setting game_extra_info on those entries makes Steam show +// "Playing non-Steam game: " instead. + +static std::mutex g_gameNameCacheMtx; +static std::unordered_map<uint32_t, std::string> g_gameNameCache; + +static const std::string& LookupGameName(uint32_t appId) { + std::lock_guard<std::mutex> lock(g_gameNameCacheMtx); + auto it = g_gameNameCache.find(appId); + if (it != g_gameNameCache.end()) return it->second; + + std::string name = AutoCloudScan::GetAppName(g_steamPath, appId); + if (name.empty()) + name = "App " + std::to_string(appId); + auto [ins, _] = g_gameNameCache.emplace(appId, std::move(name)); + return ins->second; +} + +// Rewrite CMsgClientGamesPlayed body: for each games_played entry whose +// game_id is a namespace app and has no game_extra_info, inject the title. +// Returns an empty vector if no changes were made (caller should send the +// original packet unmodified). +static std::vector<uint8_t> RewriteGamesPlayed(const uint8_t* body, uint32_t bodyLen) { + auto outerFields = PB::Parse(body, bodyLen); + bool needsRewrite = false; + + // First pass: check if any entry needs rewriting + for (const auto& f : outerFields) { + if (f.fieldNum != GP_FIELD_GAMES_PLAYED || f.wireType != PB::LengthDelimited) + continue; + auto inner = PB::Parse(f.data, f.dataLen); + // Extract game_id (fixed64, field 2) + const auto* gameIdField = PB::FindField(inner, GP_FIELD_GAME_ID); + if (!gameIdField) continue; + uint32_t appId = (uint32_t)(gameIdField->varintVal & 0x00FFFFFF); // low 24 bits = appId in CGameID + if (appId == 0) continue; + if (!IsNamespaceApp(appId)) continue; + // Check if game_extra_info is already set + auto existing = PB::GetString(inner, GP_FIELD_GAME_EXTRA_INFO); + if (!existing.empty()) continue; + needsRewrite = true; + break; + } + + if (!needsRewrite) return {}; + + // Second pass: rebuild body with game_extra_info injected + PB::Writer newBody; + for (const auto& f : outerFields) { + if (f.fieldNum != GP_FIELD_GAMES_PLAYED || f.wireType != PB::LengthDelimited) { + // Copy non-games_played fields as-is + if (f.wireType == PB::Varint) + newBody.WriteVarint(f.fieldNum, f.varintVal); + else if (f.wireType == PB::Fixed64) + newBody.WriteFixed64(f.fieldNum, f.varintVal); + else if (f.wireType == PB::LengthDelimited) + newBody.WriteBytes(f.fieldNum, f.data, f.dataLen); + else if (f.wireType == PB::Fixed32) + newBody.WriteFixed32(f.fieldNum, (uint32_t)f.varintVal); + continue; + } + + auto inner = PB::Parse(f.data, f.dataLen); + const auto* gameIdField = PB::FindField(inner, GP_FIELD_GAME_ID); + uint32_t appId = gameIdField ? (uint32_t)(gameIdField->varintVal & 0x00FFFFFF) : 0; + bool isNs = appId > 0 && IsNamespaceApp(appId); + auto existingInfo = PB::GetString(inner, GP_FIELD_GAME_EXTRA_INFO); + + if (isNs && existingInfo.empty()) { + // Rebuild this GamePlayed entry as a non-Steam game shortcut. + // The friends server shows "Playing non-Steam game: <title>" + // when game_id has CGameID type 2 (shortcut). + const std::string& name = LookupGameName(appId); + + // Build a shortcut-style CGameID: type=2, appId=0, modId=hash + // CGameID layout: bits 0-23 = appId, bits 24-31 = type, bits 32-63 = modId + // Hash the game name to produce a stable modId (like Steam does for shortcuts) + uint32_t modId = 0; + for (char c : name) modId = modId * 31 + (uint8_t)c; + modId |= 0x80000000; // set high bit like Steam shortcut hashes + uint64_t shortcutGameId = ((uint64_t)modId << 32) | (2ULL << 24); // type=2, appId=0 + + PB::Writer sub; + for (const auto& sf : inner) { + if (sf.fieldNum == GP_FIELD_GAME_ID) continue; // replace with shortcut + if (sf.fieldNum == GP_FIELD_GAME_EXTRA_INFO) continue; // replace with title + if (sf.fieldNum == GP_FIELD_OWNER_ID) continue; // clear + if (sf.wireType == PB::Varint) + sub.WriteVarint(sf.fieldNum, sf.varintVal); + else if (sf.wireType == PB::Fixed64) + sub.WriteFixed64(sf.fieldNum, sf.varintVal); + else if (sf.wireType == PB::LengthDelimited) + sub.WriteBytes(sf.fieldNum, sf.data, sf.dataLen); + else if (sf.wireType == PB::Fixed32) + sub.WriteFixed32(sf.fieldNum, (uint32_t)sf.varintVal); + } + sub.WriteFixed64(GP_FIELD_GAME_ID, shortcutGameId); + sub.WriteString(GP_FIELD_GAME_EXTRA_INFO, name); + sub.WriteVarint(GP_FIELD_OWNER_ID, 0); + newBody.WriteSubmessage(GP_FIELD_GAMES_PLAYED, sub); + LOG("[GamesPlayed] Injected game_extra_info for app %u: \"%s\"", appId, name.c_str()); + } else { + // Copy unmodified + newBody.WriteBytes(f.fieldNum, f.data, f.dataLen); + } + } + + return {newBody.Data().begin(), newBody.Data().end()}; +} + // OnSendPkt — vtable hook handles namespace Cloud RPCs; this is the fallback path. bool OnSendPkt(void* thisptr, const uint8_t* data, uint32_t size) { @@ -4739,6 +4994,21 @@ static void ShutdownImpl() { g_bddOrigAddr = nullptr; } + // Restore BAsyncSend detour + if (g_basOrigAddr) { + HMODULE currentSC = GetModuleHandleA("steamclient64.dll"); + if (currentSC) { + DWORD oldProt; + if (VirtualProtect(g_basOrigAddr, SC_BAS_STOLEN_BYTES, PAGE_EXECUTE_READWRITE, &oldProt)) { + memcpy(g_basOrigAddr, g_basTrampoline, SC_BAS_STOLEN_BYTES); + FlushInstructionCache(GetCurrentProcess(), g_basOrigAddr, SC_BAS_STOLEN_BYTES); + VirtualProtect(g_basOrigAddr, SC_BAS_STOLEN_BYTES, oldProt, &oldProt); + } + } + g_basOriginal = nullptr; + g_basOrigAddr = nullptr; + } + // Both threads poll g_shuttingDown; 5s is generous. Stuck network I/O // detaches and is reaped by the OS. JoinThreadWithTimeout(g_luaSyncThread, 5000, "g_luaSyncThread"); diff --git a/src/platform/win/cloud_intercept.h b/src/platform/win/cloud_intercept.h index 57466905..a4801a6a 100644 --- a/src/platform/win/cloud_intercept.h +++ b/src/platform/win/cloud_intercept.h @@ -34,6 +34,7 @@ void InstallManifestPinHook(); // Stub -- release-state patching removed from public builds. void InstallReleaseStateNop(); +void InstallGamesPlayedHook(); // compute payload base and set up cave replacement buffer globals void SetSendPktAddr(void* recvPktGlobalAddr); diff --git a/src/platform/win/dllmain.cpp b/src/platform/win/dllmain.cpp index 8e5811a7..a7dc894c 100644 --- a/src/platform/win/dllmain.cpp +++ b/src/platform/win/dllmain.cpp @@ -65,6 +65,8 @@ int CloudOnSendPkt(void* thisptr, const uint8_t* data, uint32_t size, void* recv CloudIntercept::InstallReleaseStateNop(); + CloudIntercept::InstallGamesPlayedHook(); + LOG("CloudRedirect fully initialized with hooks"); } catch (const std::exception& ex) { LOG("CloudRedirect init FAILED: %s", ex.what()); From 5f95c6848c85b6c9a0091e12e05b6f3efb114777 Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:56:33 -0400 Subject: [PATCH 03/24] Add Show Game in Friends toggle for non-Steam game display --- src/platform/win/cloud_intercept.cpp | 7 +++++++ ui/Pages/SettingsPage.xaml | 16 ++++++++++++++++ ui/Pages/SettingsPage.xaml.cs | 23 +++++++++++++++-------- ui/Resources/Strings.resx | 6 ++++++ 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/platform/win/cloud_intercept.cpp b/src/platform/win/cloud_intercept.cpp index 399ef523..e00b1a44 100644 --- a/src/platform/win/cloud_intercept.cpp +++ b/src/platform/win/cloud_intercept.cpp @@ -321,6 +321,7 @@ static std::atomic<bool> g_cloudRedirectEnabled{true}; // Config lives in Steam folder (per-system), NOT AppData (per-user). static std::atomic<bool> g_manifestPinsEnabled{false}; static std::atomic<bool> g_autoComment{true}; // when true, ignore lua setManifestid lines +static std::atomic<bool> g_showNonSteamGame{true}; // show LUA games as "Playing non-Steam game" in friends static std::unordered_set<uint32_t> g_pinnedApps; // per-app overrides: always respect lua pins for these apps static std::unordered_map<uint32_t, std::unordered_map<uint32_t, uint64_t>> g_manifestPins; // appId -> {depotId -> manifestId} @@ -3766,6 +3767,8 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa g_manifestPinsEnabled = pinCfg["manifest_pinning"].boolean(); if (pinCfg["auto_comment"].type == Json::Type::Bool) g_autoComment = pinCfg["auto_comment"].boolean(); + if (pinCfg["show_non_steam_game"].type == Json::Type::Bool) + g_showNonSteamGame = pinCfg["show_non_steam_game"].boolean(); size_t totalPins = 0; @@ -4372,6 +4375,10 @@ static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { void InstallGamesPlayedHook() { if (!HasNamespaceApps()) return; + if (!g_showNonSteamGame.load(std::memory_order_relaxed)) { + LOG("[GamesPlayed] Disabled by config (show_non_steam_game=false)"); + return; + } HMODULE hSteamClient = GetModuleHandleA("steamclient64.dll"); if (!hSteamClient) { diff --git a/ui/Pages/SettingsPage.xaml b/ui/Pages/SettingsPage.xaml index 467fd0a5..2753ae62 100644 --- a/ui/Pages/SettingsPage.xaml +++ b/ui/Pages/SettingsPage.xaml @@ -103,6 +103,22 @@ Unchecked="SyncToggle_Changed" /> </ui:CardControl> + <ui:CardControl Margin="0,0,0,8"> + <ui:CardControl.Header> + <StackPanel> + <TextBlock Text="{res:Loc Settings_ShowNonSteamGame}" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <TextBlock Text="{res:Loc Settings_ShowNonSteamGameHint}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" /> + </StackPanel> + </ui:CardControl.Header> + <ui:ToggleSwitch x:Name="ShowNonSteamGameToggle" + Checked="SyncToggle_Changed" + Unchecked="SyncToggle_Changed" /> + </ui:CardControl> + <ui:CardControl Margin="0,0,0,8"> <ui:CardControl.Header> <StackPanel> diff --git a/ui/Pages/SettingsPage.xaml.cs b/ui/Pages/SettingsPage.xaml.cs index 1b5ad06a..8e832b97 100644 --- a/ui/Pages/SettingsPage.xaml.cs +++ b/ui/Pages/SettingsPage.xaml.cs @@ -60,6 +60,7 @@ private sealed record SettingsSnapshot( bool? SyncPlaytime, bool? SyncLuas, bool? AutoUpdateDll, + bool? ShowNonSteamGame, bool? ParentalIgnorePlaytime, bool? ParentalBypassPlaytime); @@ -75,11 +76,11 @@ private async Task LoadSettingsAsync() var lang = ReadLanguageSetting(); var mode = Services.SteamDetector.ReadModeSetting(); - bool? a = null, p = null, l = null, u = null, pip = null, pbp = null; + bool? a = null, p = null, l = null, u = null, nsg = null, pip = null, pbp = null; if (mode == "cloud_redirect") - ReadSyncTogglesInto(ref a, ref p, ref l, ref u, ref pip, ref pbp); + ReadSyncTogglesInto(ref a, ref p, ref l, ref u, ref nsg, ref pip, ref pbp); - return new SettingsSnapshot(lang, mode, a, p, l, u, pip, pbp); + return new SettingsSnapshot(lang, mode, a, p, l, u, nsg, pip, pbp); }); ApplySettingsSnapshot(snapshot); @@ -94,12 +95,12 @@ private void ApplySettingsSnapshot(SettingsSnapshot snap) { SyncSection.Visibility = Visibility.Visible; ApplySyncToggles(snap.SyncAchievements, snap.SyncPlaytime, snap.SyncLuas, snap.AutoUpdateDll, - snap.ParentalIgnorePlaytime, snap.ParentalBypassPlaytime); + snap.ShowNonSteamGame, snap.ParentalIgnorePlaytime, snap.ParentalBypassPlaytime); } else { SyncSection.Visibility = Visibility.Collapsed; - ApplySyncToggles(false, false, false, false, + ApplySyncToggles(false, false, false, false, false, snap.ParentalIgnorePlaytime, snap.ParentalBypassPlaytime); } } @@ -130,7 +131,7 @@ private void ApplyLanguageSelector(string saved) } private void ApplySyncToggles(bool? achievements, bool? playtime, bool? luas, bool? autoUpdateDll, - bool? parentalIgnorePlaytime, bool? parentalBypassPlaytime) + bool? showNonSteamGame, bool? parentalIgnorePlaytime, bool? parentalBypassPlaytime) { _syncLoading = true; try @@ -139,6 +140,7 @@ private void ApplySyncToggles(bool? achievements, bool? playtime, bool? luas, bo if (playtime == true) SyncPlaytimeToggle.IsChecked = true; if (luas == true) SyncLuasToggle.IsChecked = true; if (autoUpdateDll == true) AutoUpdateDllToggle.IsChecked = true; + if (showNonSteamGame == true) ShowNonSteamGameToggle.IsChecked = true; if (parentalIgnorePlaytime == true) ParentalIgnorePlaytimeToggle.IsChecked = true; if (parentalBypassPlaytime == true) ParentalBypassPlaytimeToggle.IsChecked = true; } @@ -154,7 +156,7 @@ private void ApplySyncToggles(bool? achievements, bool? playtime, bool? luas, bo /// path never opens config.json synchronously. /// </summary> private static void ReadSyncTogglesInto(ref bool? achievements, ref bool? playtime, ref bool? luas, ref bool? autoUpdateDll, - ref bool? parentalIgnorePlaytime, ref bool? parentalBypassPlaytime) + ref bool? showNonSteamGame, ref bool? parentalIgnorePlaytime, ref bool? parentalBypassPlaytime) { try { @@ -175,6 +177,10 @@ private static void ReadSyncTogglesInto(ref bool? achievements, ref bool? playti autoUpdateDll = u.ValueKind == JsonValueKind.True; else autoUpdateDll = true; // default on when key absent + if (root.TryGetProperty("show_non_steam_game", out var nsg)) + showNonSteamGame = nsg.ValueKind == JsonValueKind.True; + else + showNonSteamGame = true; // default on when key absent if (root.TryGetProperty("parental_ignore_playtime", out var pip) && pip.ValueKind == JsonValueKind.True) parentalIgnorePlaytime = true; if (root.TryGetProperty("parental_bypass_playtime", out var pbp) && pbp.ValueKind == JsonValueKind.True) @@ -344,13 +350,14 @@ private void SaveSyncToggles() var path = GetConfigPath(); Services.ConfigHelper.SaveConfig(path, new[] { "sync_achievements", "sync_playtime", "sync_luas", "auto_update_dll", - "parental_ignore_playtime", "parental_bypass_playtime" }, + "show_non_steam_game", "parental_ignore_playtime", "parental_bypass_playtime" }, writer => { writer.WriteBoolean("sync_achievements", SyncAchievementsToggle.IsChecked == true); writer.WriteBoolean("sync_playtime", SyncPlaytimeToggle.IsChecked == true); writer.WriteBoolean("sync_luas", SyncLuasToggle.IsChecked == true); writer.WriteBoolean("auto_update_dll", AutoUpdateDllToggle.IsChecked == true); + writer.WriteBoolean("show_non_steam_game", ShowNonSteamGameToggle.IsChecked == true); writer.WriteBoolean("parental_ignore_playtime", ParentalIgnorePlaytimeToggle.IsChecked == true); writer.WriteBoolean("parental_bypass_playtime", ParentalBypassPlaytimeToggle.IsChecked == true); }); diff --git a/ui/Resources/Strings.resx b/ui/Resources/Strings.resx index c695380d..63f7ee6f 100644 --- a/ui/Resources/Strings.resx +++ b/ui/Resources/Strings.resx @@ -1617,6 +1617,12 @@ Are you sure?</value> <data name="Settings_AutoUpdateDllHint" xml:space="preserve"> <value>Automatically check for and install newer versions of the CloudRedirect DLL when Steam launches. The update takes effect on the next Steam restart.</value> </data> + <data name="Settings_ShowNonSteamGame" xml:space="preserve"> + <value>Show Game in Friends</value> + </data> + <data name="Settings_ShowNonSteamGameHint" xml:space="preserve"> + <value>When playing a game unlocked by a Lua, show it as "Playing non-Steam game" in your friends list instead of appearing offline. Takes effect on next game launch.</value> + </data> <!-- ═══════════════════════════════════════════════════════════════════ --> <!-- Cloud Redirect Toggle --> From be45eb6289c6d8cb0eca779ef8ba9bdd96a9d651 Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:11:20 -0400 Subject: [PATCH 04/24] Respect Mark as Private for games in friends now-playing spoof --- src/platform/win/cloud_intercept.cpp | 74 +++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/src/platform/win/cloud_intercept.cpp b/src/platform/win/cloud_intercept.cpp index e00b1a44..986bee83 100644 --- a/src/platform/win/cloud_intercept.cpp +++ b/src/platform/win/cloud_intercept.cpp @@ -382,6 +382,69 @@ static bool HasNamespaceApps() { return !g_namespaceApps.empty(); } +uint32_t GetAccountId(); // defined later + +// "Mark as private" support: Steam stores per-user private appIds as a JSON array +// under PrivateApps_<accountId> in localconfig.vdf. We honor it so the friends +// "now playing" spoof does not reveal games the user has hidden. Cached briefly +// so we don't re-read the file on every GamesPlayed broadcast. +static std::mutex g_privateAppsMutex; +static std::unordered_set<uint32_t> g_privateApps; +static std::chrono::steady_clock::time_point g_privateAppsLoaded{}; + +static void RefreshPrivateAppsLocked() { + g_privateApps.clear(); + uint32_t accountId = GetAccountId(); + if (!accountId || g_steamPath.empty()) return; + + std::string vdfPath = g_steamPath + "userdata\\" + std::to_string(accountId) + + "\\config\\localconfig.vdf"; + auto vdfPathWide = FileUtil::Utf8ToPath(vdfPath).wstring(); + HANDLE hFile = CreateFileW(vdfPathWide.c_str(), GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (hFile == INVALID_HANDLE_VALUE) return; + + std::string content; + DWORD fileSize = GetFileSize(hFile, nullptr); + if (fileSize != INVALID_FILE_SIZE && fileSize > 0) { + content.resize(fileSize); + DWORD bytesRead = 0; + ReadFile(hFile, (LPVOID)content.data(), fileSize, &bytesRead, nullptr); + content.resize(bytesRead); + } + CloseHandle(hFile); + + // Find: "PrivateApps_<accountId>" "[480,2499870,...]" + std::string key = "\"PrivateApps_" + std::to_string(accountId) + "\""; + size_t k = content.find(key); + if (k == std::string::npos) return; + size_t lb = content.find('[', k); + size_t rb = (lb == std::string::npos) ? std::string::npos : content.find(']', lb); + if (lb == std::string::npos || rb == std::string::npos) return; + + // Parse comma-separated appIds inside the brackets. + size_t i = lb + 1; + while (i < rb) { + while (i < rb && !isdigit((unsigned char)content[i])) ++i; + if (i >= rb) break; + uint32_t id = (uint32_t)strtoul(content.c_str() + i, nullptr, 10); + if (id) g_privateApps.insert(id); + while (i < rb && isdigit((unsigned char)content[i])) ++i; + } +} + +bool IsPrivateApp(uint32_t appId) { + std::lock_guard<std::mutex> lock(g_privateAppsMutex); + auto now = std::chrono::steady_clock::now(); + if (g_privateAppsLoaded.time_since_epoch().count() == 0 || + now - g_privateAppsLoaded > std::chrono::seconds(5)) { + RefreshPrivateAppsLocked(); + g_privateAppsLoaded = now; + } + return g_privateApps.count(appId) > 0; +} + void AddNamespaceApp(uint32_t appId) { std::lock_guard<std::mutex> lock(g_namespaceAppsMutex); g_namespaceApps.insert(appId); @@ -4503,6 +4566,7 @@ static std::vector<uint8_t> RewriteGamesPlayed(const uint8_t* body, uint32_t bod uint32_t appId = (uint32_t)(gameIdField->varintVal & 0x00FFFFFF); // low 24 bits = appId in CGameID if (appId == 0) continue; if (!IsNamespaceApp(appId)) continue; + if (IsPrivateApp(appId)) continue; // respect "mark as private" // Check if game_extra_info is already set auto existing = PB::GetString(inner, GP_FIELD_GAME_EXTRA_INFO); if (!existing.empty()) continue; @@ -4534,10 +4598,16 @@ static std::vector<uint8_t> RewriteGamesPlayed(const uint8_t* body, uint32_t bod bool isNs = appId > 0 && IsNamespaceApp(appId); auto existingInfo = PB::GetString(inner, GP_FIELD_GAME_EXTRA_INFO); - if (isNs && existingInfo.empty()) { + if (isNs && existingInfo.empty() && !IsPrivateApp(appId)) { // Rebuild this GamePlayed entry as a non-Steam game shortcut. // The friends server shows "Playing non-Steam game: <title>" // when game_id has CGameID type 2 (shortcut). + // + // NOTE: a non-Steam shortcut has no per-app privacy on the friends + // server, so we must enforce the user's "mark as private" choice + // ourselves via IsPrivateApp() (reads PrivateApps_<accountId> from + // localconfig.vdf). If the app is private we fall through and copy + // the entry unmodified, leaving the game hidden as the user intends. const std::string& name = LookupGameName(appId); // Build a shortcut-style CGameID: type=2, appId=0, modId=hash @@ -4568,6 +4638,8 @@ static std::vector<uint8_t> RewriteGamesPlayed(const uint8_t* body, uint32_t bod newBody.WriteSubmessage(GP_FIELD_GAMES_PLAYED, sub); LOG("[GamesPlayed] Injected game_extra_info for app %u: \"%s\"", appId, name.c_str()); } else { + if (isNs && existingInfo.empty() && IsPrivateApp(appId)) + LOG("[GamesPlayed] App %u is marked private -- not injecting (respecting privacy)", appId); // Copy unmodified newBody.WriteBytes(f.fieldNum, f.data, f.dataLen); } From 2f9238b4659d0b70ce65bdc3d6a0773fd251205b Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Sun, 7 Jun 2026 00:36:25 -0400 Subject: [PATCH 05/24] 2.2.0 experimental 1 --- .gitattributes | 2 + .gitignore | 7 + CMakeLists.txt | 8 +- Version.props | 2 +- cli-dotnet/Program.cs | 14 + flatpak/build.sh | 11 +- src/common/autocloud_bootstrap.cpp | 851 --------------- src/common/autocloud_bootstrap.h | 36 - src/common/autocloud_scan.cpp | 41 +- src/common/autocloud_scan.h | 9 + src/common/autocloud_util.h | 2 +- src/common/cloud_storage.cpp | 2 +- src/common/local_storage.cpp | 1 - src/common/rpc_handlers.cpp | 556 ++++------ src/common/steam_kv_injector.cpp | 131 ++- src/common/steam_kv_injector.h | 4 + src/platform/linux/init.cpp | 3 +- src/platform/win/cloud760_tool.cpp | 351 +++++++ ui/CloudRedirect.csproj | 12 + ui/Converters/UrlToImageSourceConverter.cs | 11 + ui/MainWindow.xaml | 2 +- ui/Pages/AppsPage.xaml | 98 +- ui/Pages/AppsPage.xaml.cs | 122 ++- ui/Pages/CleanupPage.xaml | 172 --- ui/Pages/CleanupPage.xaml.cs | 1096 -------------------- ui/Pages/Cloud760Page.xaml | 180 ++++ ui/Pages/Cloud760Page.xaml.cs | 281 +++++ ui/Pages/ManifestPinningPage.xaml | 133 +-- ui/Pages/ManifestPinningPage.xaml.cs | 125 ++- ui/Services/EmbeddedCloud760.cs | 60 ++ ui/Services/Steam760Cloud.cs | 211 ++++ ui/Services/SteamConsole.cs | 19 + 32 files changed, 1828 insertions(+), 2725 deletions(-) create mode 100644 .gitattributes delete mode 100644 src/common/autocloud_bootstrap.cpp delete mode 100644 src/common/autocloud_bootstrap.h create mode 100644 src/platform/win/cloud760_tool.cpp delete mode 100644 ui/Pages/CleanupPage.xaml delete mode 100644 ui/Pages/CleanupPage.xaml.cs create mode 100644 ui/Pages/Cloud760Page.xaml create mode 100644 ui/Pages/Cloud760Page.xaml.cs create mode 100644 ui/Services/EmbeddedCloud760.cs create mode 100644 ui/Services/Steam760Cloud.cs create mode 100644 ui/Services/SteamConsole.cs diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..f9cf85db --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts must use LF endings or they break when run on Linux (bash\r). +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index 7f879170..ee60c1b3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,14 @@ ui/obj/ ui-linux/build/ flatpak/.flatpak-builder/ flatpak/build-dir/ +flatpak/repo/ flatpak/cloud_redirect.so build.ps1 ui/Resources/cloud_redirect_cli.exe ui/Resources/payloads/ +tests/ +src/testutil/ +docs/ +tools/ +flatpak/release.sh +ui/native/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 0038313c..0f1d248c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -64,7 +64,6 @@ set(COMMON_SOURCES src/common/cli.cpp src/common/cloud_provider_base.cpp src/common/autocloud_scan.cpp - src/common/autocloud_bootstrap.cpp src/common/steam_kv_injector.cpp src/common/parental_bypass.cpp src/common/metadata_sync.cpp @@ -144,6 +143,13 @@ if(WIN32) target_link_libraries(cloud_redirect_cli PRIVATE Shell32 Ole32) # Ensure DLL is built before CLI add_dependencies(cloud_redirect_cli cloud_redirect) + + # Standalone Steam Cloud file manager for a single AppID (default 760). + # Self-contained: resolves the steam_api64.dll flat exports at runtime, so it + # needs no Steamworks SDK headers/libs. steam_api64.dll must sit next to it. + add_executable(cloud760_tool + src/platform/win/cloud760_tool.cpp + ) else() target_include_directories(cloud_redirect PRIVATE src/platform/linux) target_link_libraries(cloud_redirect PRIVATE dl pthread) diff --git a/Version.props b/Version.props index ed964b9b..83a2c271 100644 --- a/Version.props +++ b/Version.props @@ -1,7 +1,7 @@ <Project> <PropertyGroup> <!-- Shared user-facing version (X.Y.Z) --> - <ReleaseVersion>2.1.5</ReleaseVersion> + <ReleaseVersion>2.2.0</ReleaseVersion> <!-- Sync engine generation - increment on breaking protocol changes --> <CoreGeneration>1.0</CoreGeneration> diff --git a/cli-dotnet/Program.cs b/cli-dotnet/Program.cs index fbfe3b5f..a444a2fc 100644 --- a/cli-dotnet/Program.cs +++ b/cli-dotnet/Program.cs @@ -150,6 +150,20 @@ static int RunStFixer() } Console.WriteLine("OK"); + // Patch SteamTools.exe so it doesn't overwrite Core.dll on startup and + // doesn't show its interactive activation menu. The UI's "run all" flow + // applies this; the CLI must too, or users get prompted to "push 1 or 2". + Console.WriteLine(); + Console.WriteLine("Patching SteamTools.exe..."); + var stResult = patcher.PatchSteamToolsExe(); + if (stResult == 1) + Console.WriteLine("OK"); + else if (stResult == 0) + Console.WriteLine("Skipped (SteamTools.exe not installed)."); + else + Console.WriteLine("WARNING: SteamTools.exe patch failed -- see messages above. " + + "STFixer patches still applied; you may be prompted by SteamTools on startup."); + // Deploy DLL Console.WriteLine(); Console.WriteLine("Deploying cloud_redirect.dll..."); diff --git a/flatpak/build.sh b/flatpak/build.sh index 6a771c3c..11fcd0ed 100644 --- a/flatpak/build.sh +++ b/flatpak/build.sh @@ -1,6 +1,13 @@ #!/bin/bash -# Build script for CloudRedirect Flatpak -# Run this on a Linux system with flatpak-builder installed +# Build script for CloudRedirect Flatpak. LOCAL TEST INSTALL ONLY. +# +# This builds and installs the flatpak into your user installation so you can +# run it locally. It does NOT export or sign a distributable repo. +# +# To publish a release to GitHub Pages, use release.sh instead. It builds, +# SIGNS the OSTree summary, guards against an unsigned repo, and pushes gh-pages. +# Publishing with this script would produce an unsigned repo that clients reject +# ("GPG verification enabled, but no summary signatures found"). set -e diff --git a/src/common/autocloud_bootstrap.cpp b/src/common/autocloud_bootstrap.cpp deleted file mode 100644 index 0432f172..00000000 --- a/src/common/autocloud_bootstrap.cpp +++ /dev/null @@ -1,851 +0,0 @@ -#include "autocloud_bootstrap.h" -#include "autocloud_scan.h" -#include "app_state.h" -#include "batch_tracker.h" -#include "cloud_intercept.h" -#include "cloud_storage.h" -#include "cloud_work_queue.h" -#include "file_util.h" -#include "local_storage.h" -#include "log.h" -#include "pending_ops_journal.h" - -#include <algorithm> -#include <atomic> -#include <chrono> -#include <condition_variable> -#include <fstream> -#include <future> -#include <mutex> -#include <unordered_map> -#include <unordered_set> -#include <vector> - -namespace AutoCloudBootstrap { - -// Internal state - -// g_importMutex > g_tokenCacheMutex > g_bootstrapMutex. Network/disk I/O runs unlocked. -static std::mutex g_tokenCacheMutex; -static std::unordered_map<uint64_t, std::unordered_map<std::string, std::string>> g_canonicalTokenCache; -static std::unordered_map<uint64_t, uint64_t> g_canonicalTokenGeneration; - -static std::mutex g_importMutex; - -static std::mutex g_bootstrapMutex; -static std::condition_variable g_bootstrapCV; -static std::unordered_set<uint64_t> g_attemptedApps; -static std::unordered_set<uint64_t> g_activeApps; -static std::vector<std::future<void>> g_futures; -static bool g_shuttingDown = false; -// Live orchestrator frames; shutdown waits on this + active.empty(). -static int g_orchestratorCount = 0; -// Cap concurrent bootstrap workers to avoid OOM on large libraries. -static constexpr int kMaxConcurrentBootstraps = 8; -static std::atomic<int> g_activeWorkerCount{0}; - -static constexpr uint64_t kMaxImportBytes = 128ULL * 1024 * 1024; - -// Helpers - -static uint64_t MakeAppKey(uint32_t accountId, uint32_t appId) { - return CloudIntercept::MakeAppAccountKey(accountId, appId); -} - -static bool LooksLikeForeignAppPollution(const std::string& filename, uint32_t appId) { - size_t pos = filename.find_first_of("/\\"); - if (pos != std::string::npos && pos >= 3 && pos <= 10) { - const std::string prefix = filename.substr(0, pos); - if (std::all_of(prefix.begin(), prefix.end(), [](unsigned char c) { return c >= '0' && c <= '9'; })) { - try { - unsigned long parsed = std::stoul(prefix); - if (parsed > 0xFFFFFFFFUL) return false; // Not a valid app ID - uint32_t embeddedAppId = static_cast<uint32_t>(parsed); - if (embeddedAppId != 0 && embeddedAppId != appId) return true; - } catch (...) {} - } - } - - size_t underscore = filename.find("_%"); - if (underscore != std::string::npos && underscore >= 3 && underscore <= 10) { - const std::string prefix = filename.substr(0, underscore); - if (std::all_of(prefix.begin(), prefix.end(), [](unsigned char c) { return c >= '0' && c <= '9'; })) { - try { - unsigned long parsed = std::stoul(prefix); - if (parsed > 0xFFFFFFFFUL) return false; // Not a valid app ID - uint32_t embeddedAppId = static_cast<uint32_t>(parsed); - if (embeddedAppId != 0 && embeddedAppId != appId) return true; - } catch (...) {} - } - } - - return false; -} - -static std::vector<uint8_t> ReadWholeFile(const std::string& path, bool& ok) { - ok = false; - std::ifstream f(FileUtil::Utf8ToPath(path), std::ios::binary | std::ios::ate); - if (!f) return {}; - auto size = f.tellg(); - if (size < 0) return {}; - if (static_cast<uint64_t>(size) > kMaxImportBytes) { - LOG("[AutoCloudImport] Skipping oversized source: %s (%llu bytes)", - path.c_str(), static_cast<unsigned long long>(size)); - return {}; - } - if (!f.seekg(0, std::ios::beg)) return {}; - std::vector<uint8_t> data(static_cast<size_t>(size)); - if (!data.empty() && !f.read(reinterpret_cast<char*>(data.data()), size)) return {}; - ok = true; - return data; -} - -// Token cache management - -static void CacheCanonicalTokens(uint32_t accountId, uint32_t appId, - const std::vector<AutoCloudScan::FileEntry>& candidates, - uint64_t generation) { - std::unordered_map<std::string, std::string> tokens; - for (const auto& fe : candidates) { - if (!fe.relativePath.empty()) tokens.emplace(fe.relativePath, fe.rootToken); - } - std::lock_guard<std::mutex> lock(g_tokenCacheMutex); - uint64_t key = MakeAppKey(accountId, appId); - if (g_canonicalTokenGeneration[key] != generation) return; - g_canonicalTokenCache[key] = std::move(tokens); -} - -static void ClearCanonicalTokens(uint32_t accountId, uint32_t appId, uint64_t generation) { - std::lock_guard<std::mutex> lock(g_tokenCacheMutex); - uint64_t key = MakeAppKey(accountId, appId); - if (g_canonicalTokenGeneration[key] != generation) return; - g_canonicalTokenCache.erase(key); -} - -static uint64_t GetTokenGeneration(uint32_t accountId, uint32_t appId) { - std::lock_guard<std::mutex> lock(g_tokenCacheMutex); - return g_canonicalTokenGeneration[MakeAppKey(accountId, appId)]; -} - -// Bootstrap lifecycle - -static bool TryBeginBootstrap(uint32_t accountId, uint32_t appId) { - uint64_t appKey = MakeAppKey(accountId, appId); - std::lock_guard<std::mutex> lock(g_bootstrapMutex); - - // Prune completed futures - for (auto it = g_futures.begin(); it != g_futures.end(); ) { - if (it->wait_for(std::chrono::seconds(0)) == std::future_status::ready) { - try { it->get(); } catch (...) {} - it = g_futures.erase(it); - } else { - ++it; - } - } - - if (g_shuttingDown || g_attemptedApps.count(appKey) || g_activeApps.count(appKey)) { - return false; - } - g_activeApps.insert(appKey); - return true; -} - -static void FinishBootstrap(uint32_t accountId, uint32_t appId, - bool markAttempted, uint64_t /*generation*/) { - uint64_t appKey = MakeAppKey(accountId, appId); - std::lock_guard<std::mutex> lock(g_bootstrapMutex); - g_activeApps.erase(appKey); - // Mark unconditionally to prevent re-import loops. - if (markAttempted) g_attemptedApps.insert(appKey); - g_bootstrapCV.notify_all(); -} - -static void WaitForBootstrapInternal(uint32_t accountId, uint32_t appId) { - uint64_t appKey = MakeAppKey(accountId, appId); - std::unique_lock<std::mutex> lock(g_bootstrapMutex); - g_bootstrapCV.wait(lock, [&] { - return !g_activeApps.count(appKey); - }); -} - -static bool IsBootstrapActiveInternal(uint32_t accountId, uint32_t appId) { - std::lock_guard<std::mutex> lock(g_bootstrapMutex); - return g_activeApps.count(MakeAppKey(accountId, appId)) != 0; -} - -static bool IsShuttingDownInternal() { - std::lock_guard<std::mutex> lock(g_bootstrapMutex); - return g_shuttingDown; -} - -// Worker implementation - -static void BootstrapWorker(uint32_t accountId, uint32_t appId, uint64_t cacheGeneration) { - struct FinishGuard { - uint32_t accountId; - uint32_t appId; - uint64_t generation; - bool markAttempted; - bool fired; - ~FinishGuard() { - if (!fired) { - LOG("[AutoCloudImport] Worker aborted via exception for app %u -- releasing bootstrap slot", appId); - FinishBootstrap(accountId, appId, markAttempted, generation); - } - } - }; - FinishGuard guard{accountId, appId, cacheGeneration, /*markAttempted=*/false, /*fired=*/false}; - auto finish = [&](bool markAttempted, uint64_t generation) { - guard.fired = true; - FinishBootstrap(accountId, appId, markAttempted, generation); - }; - - if (IsShuttingDownInternal()) { - LOG("[AutoCloudImport] Aborting bootstrap for app %u -- shutdown in progress", appId); - ClearCanonicalTokens(accountId, appId, cacheGeneration); - finish(false, cacheGeneration); - return; - } - - // Defer import while upload is pending (blobs may not be on provider). - if (PendingOpsJournal::HasPendingUpload(accountId, appId)) { - LOG("[AutoCloudImport] Pending upload exists for app %u; deferring import", appId); - finish(false, cacheGeneration); - return; - } - - AutoCloudScan::ScanResult scan; - try { - scan = AutoCloudScan::GetFileList(CloudIntercept::GetSteamPath(), accountId, appId); - } catch (const std::exception& ex) { - LOG("[AutoCloudImport] Scan failed for app %u: %s", appId, ex.what()); - ClearCanonicalTokens(accountId, appId, cacheGeneration); - finish(false, cacheGeneration); - return; - } catch (...) { - LOG("[AutoCloudImport] Scan failed for app %u", appId); - ClearCanonicalTokens(accountId, appId, cacheGeneration); - finish(false, cacheGeneration); - return; - } - - if (scan.hasRootCollision) { - LOG("[AutoCloudImport] Root collision detected for app %u; aborting bootstrap", appId); - ClearCanonicalTokens(accountId, appId, cacheGeneration); - finish(true, cacheGeneration); - return; - } - // Reject incomplete scans (resource cap or root collision). - if (!scan.complete) { - LOG("[AutoCloudImport] Scan limit hit for app %u (%zu files observed); " - "refusing partial import, preserving canonical token cache", - appId, scan.files.size()); - finish(false, cacheGeneration); - return; - } - - std::vector<AutoCloudScan::FileEntry>& candidates = scan.files; - if (candidates.empty()) { - ClearCanonicalTokens(accountId, appId, cacheGeneration); - finish(true, cacheGeneration); - return; - } - - // Check for obvious pollution (files from other apps) - size_t definitePollution = 0; - for (const auto& fe : candidates) { - if (LooksLikeForeignAppPollution(fe.relativePath, appId)) { - LOG("[AutoCloudImport] Definite pollution candidate for app %u: %s", appId, fe.relativePath.c_str()); - ++definitePollution; - } - } - if (definitePollution > 0) { - LOG("[AutoCloudImport] Aborting import for app %u: %zu obvious pollution file(s) detected", - appId, definitePollution); - ClearCanonicalTokens(accountId, appId, cacheGeneration); - finish(true, cacheGeneration); - return; - } - - // Build map of existing cached files - std::unordered_map<std::string, LocalStorage::FileEntry> existing; - for (const auto& fe : LocalStorage::GetFileList(accountId, appId)) { - existing[fe.filename] = fe; - } - - auto fileTokens = CloudStorage::LoadFileTokens(accountId, appId); - auto rootTokens = CloudStorage::LoadRootTokens(accountId, appId); - std::unordered_set<std::string> remoteBlobNames; - if (!CloudStorage::ListRemoteBlobNames(accountId, appId, remoteBlobNames)) { - LOG("[AutoCloudImport] Aborting import for app %u: could not list remote blobs", - appId); - ClearCanonicalTokens(accountId, appId, cacheGeneration); - finish(false, cacheGeneration); - return; - } - - struct PendingImport { - std::string filename; - std::string sourcePath; - uint64_t timestamp = 0; - std::string rootToken; - bool refresh = false; - std::vector<uint8_t> expectedSha; - }; - std::vector<PendingImport> pendingImports; - bool tokenMetadataChanged = false; - - for (const auto& fe : candidates) { - if (IsShuttingDownInternal()) { - LOG("[AutoCloudImport] Aborting existence checks for app %u -- shutdown in progress", appId); - ClearCanonicalTokens(accountId, appId, cacheGeneration); - finish(false, cacheGeneration); - return; - } - if (fe.relativePath.empty() || fe.fullPath.empty()) continue; - if (CloudIntercept::IsInternalMetadataFile(fe.relativePath)) continue; - - auto it = existing.find(fe.relativePath); - bool isRefresh = false; - if (it != existing.end()) { - const bool contentMatches = - (it->second.sha == fe.sha && it->second.rawSize == fe.size); - if (!contentMatches) { - // Prefer non-zero local file over zero-byte cloud stub. - const bool cloudIsZeroByteStub = (it->second.rawSize == 0 && fe.size > 0); - const bool diskIsNewer = (fe.modifiedTime > it->second.timestamp); - const bool timestampsEqual = (fe.modifiedTime == it->second.timestamp); - - if (diskIsNewer || cloudIsZeroByteStub || timestampsEqual) { - LOG("[AutoCloudImport] Refreshing app %u file %s: disk mtime %llu vs cached %llu (size %llu->%llu)%s%s", - appId, fe.relativePath.c_str(), - (unsigned long long)fe.modifiedTime, - (unsigned long long)it->second.timestamp, - (unsigned long long)it->second.rawSize, - (unsigned long long)fe.size, - cloudIsZeroByteStub ? " [cloud zero-byte stub]" : "", - timestampsEqual ? " [equal timestamps, preferring disk]" : ""); - isRefresh = true; - } else { - LOG("[AutoCloudImport] Skipping existing app %u file %s: disk mtime %llu < cached %llu", - appId, fe.relativePath.c_str(), - (unsigned long long)fe.modifiedTime, - (unsigned long long)it->second.timestamp); - continue; - } - } else { - // Identical content: reconcile root-token metadata only. - auto existingToken = fileTokens.find(fe.relativePath); - if (existingToken == fileTokens.end() || existingToken->second != fe.rootToken) { - fileTokens[fe.relativePath] = fe.rootToken; - tokenMetadataChanged = true; - LOG("[AutoCloudImport] Canonical root token for app %u file %s: '%s'", - appId, fe.relativePath.c_str(), fe.rootToken.c_str()); - } - if (!fe.rootToken.empty() && !rootTokens.count(fe.rootToken)) { - rootTokens.insert(fe.rootToken); - tokenMetadataChanged = true; - } - continue; - } - } - - if (!isRefresh) { - if (remoteBlobNames.count(fe.relativePath) > 0) { - LOG("[AutoCloudImport] Skipping app %u file %s because blob already exists in cache/cloud", - appId, fe.relativePath.c_str()); - continue; - } - } - - pendingImports.push_back({ fe.relativePath, fe.fullPath, fe.modifiedTime, fe.rootToken, isRefresh, fe.sha }); - } - - if (pendingImports.empty() && !tokenMetadataChanged) { - finish(true, cacheGeneration); - return; - } - - uint64_t publishGeneration = 0; - size_t imported = 0; - uint64_t cn = 0; - - // Import lock covers local writes only; network runs unlocked. - std::unique_lock<std::mutex> importLock(g_importMutex); - - // Bump generation atomically - bool generationStale = false; - { - std::lock_guard<std::mutex> lock(g_tokenCacheMutex); - uint64_t key = MakeAppKey(accountId, appId); - if (g_canonicalTokenGeneration[key] != cacheGeneration) { - generationStale = true; - } else { - g_canonicalTokenCache.erase(key); - publishGeneration = ++g_canonicalTokenGeneration[key]; - } - } - if (generationStale) { - finish(false, cacheGeneration); - return; - } - - // Last abort point before StoreBlob writes. - if (IsShuttingDownInternal()) { - LOG("[AutoCloudImport] Aborting pre-commit for app %u -- shutdown in progress", appId); - ClearCanonicalTokens(accountId, appId, publishGeneration); - finish(false, publishGeneration); - return; - } - - for (auto& pending : pendingImports) { - if (IsShuttingDownInternal()) { - LOG("[AutoCloudImport] Aborting mid-import for app %u -- shutdown in progress", appId); - break; - } - if (!pending.refresh && CloudStorage::HasLocalBlob(accountId, appId, pending.filename)) { - LOG("[AutoCloudImport] Skipping app %u file %s because blob appeared before commit", - appId, pending.filename.c_str()); - continue; - } - - bool readOk = false; - auto data = ReadWholeFile(pending.sourcePath, readOk); - if (!readOk) { - LOG("[AutoCloudImport] Failed to read source before commit for app %u: %s", - appId, pending.sourcePath.c_str()); - continue; - } - - const uint8_t* ptr = data.empty() ? nullptr : data.data(); - if (pending.expectedSha.empty()) { - LOG("[AutoCloudImport] Skipping app %u file %s: no SHA from scan", - appId, pending.filename.c_str()); - continue; - } - if (LocalStorage::SHA1(ptr, data.size()) != pending.expectedSha) { - LOG("[AutoCloudImport] Skipping app %u file %s: source content changed between scan and commit", - appId, pending.filename.c_str()); - continue; - } - - if (!CloudStorage::StoreBlob(accountId, appId, pending.filename, ptr, data.size())) { - LOG("[AutoCloudImport] Failed to cache app %u file %s", appId, pending.filename.c_str()); - continue; - } - - // Restore original timestamp to cached file. - LocalStorage::SetFileTimestamp(accountId, appId, pending.filename, pending.timestamp); - - // Update manifest with original file metadata from scan. - if (!CloudStorage::UpdateManifestEntry(accountId, appId, pending.filename, - pending.expectedSha, pending.timestamp, data.size())) { - LOG("[AutoCloudImport] Manifest update FAILED for app %u file %s", - appId, pending.filename.c_str()); - LocalStorage::DeleteFile(accountId, appId, pending.filename); - continue; - } - - auto existingToken = fileTokens.find(pending.filename); - if (existingToken == fileTokens.end() || existingToken->second != pending.rootToken) { - fileTokens[pending.filename] = pending.rootToken; - tokenMetadataChanged = true; - LOG("[AutoCloudImport] Canonical root token for app %u file %s: '%s'", - appId, pending.filename.c_str(), pending.rootToken.c_str()); - } - if (!pending.rootToken.empty() && !rootTokens.count(pending.rootToken)) { - rootTokens.insert(pending.rootToken); - tokenMetadataChanged = true; - } - ++imported; - LOG("[AutoCloudImport] %s app %u file %s", - pending.refresh ? "Refreshed" : "Imported", - appId, pending.filename.c_str()); - } - - if (imported == 0 && !tokenMetadataChanged) { - finish(true, publishGeneration); - return; - } - - if (!rootTokens.empty() && !CloudStorage::SaveRootTokens(accountId, appId, rootTokens)) { - LOG("[AutoCloudImport] root_token.dat local persist FAILED app %u -- next restart will load stale set", appId); - } - if ((!fileTokens.empty() || tokenMetadataChanged) && - !CloudStorage::SaveFileTokens(accountId, appId, fileTokens)) { - LOG("[AutoCloudImport] file_tokens.dat local persist FAILED app %u -- next restart will load stale set", appId); - } - - importLock.unlock(); - - if (GetTokenGeneration(accountId, appId) != publishGeneration) { - finish(false, publishGeneration); - return; - } - - // Drain blob uploads before publishing state -- don't advertise un-uploaded blobs. - if (!CloudWorkQueue::DrainQueueForApp(accountId, appId)) { - LOG("[AutoCloudImport] Blob drain FAILED for app %u; aborting commit", appId); - ClearCanonicalTokens(accountId, appId, publishGeneration); - finish(false, publishGeneration); - return; - } - - // Abort if batch in progress -- CompleteBatch owns CN/state publish. - if (CloudIntercept::BatchTracker_ActiveId(accountId, appId) != 0) { - LOG("[AutoCloudImport] Active batch detected for app %u; deferring import", appId); - ClearCanonicalTokens(accountId, appId, publishGeneration); - finish(false, publishGeneration); - return; - } - - // Sync mutex: serialize CN increment + state publish. - auto syncMtx = CloudStorage::AcquireAppSyncMutex(accountId, appId); - std::lock_guard<std::mutex> syncLock(*syncMtx); - - uint64_t oldCN = LocalStorage::GetChangeNumber(accountId, appId); - cn = LocalStorage::IncrementChangeNumber(accountId, appId); - if (cn <= oldCN) { - LOG("[AutoCloudImport] CN increment FAILED for app %u; aborting commit", appId); - ClearCanonicalTokens(accountId, appId, publishGeneration); - finish(false, publishGeneration); - return; - } - // Publish unified state (CN + manifest atomically) - { - CloudStorage::CloudAppState state; - state.cn = cn; - auto localManifest = CloudStorage::LoadLocalManifest(accountId, appId); - for (const auto& [name, me] : localManifest) { - CloudStorage::FileEntry fe; - fe.sha = me.sha; - fe.timestamp = me.timestamp; - fe.size = me.size; - state.files[name] = std::move(fe); - } - CloudStorage::PublishCloudState(accountId, appId, state); - } - - // Re-check generation; concurrent invalidation bumps it. - if (GetTokenGeneration(accountId, appId) != publishGeneration) { - finish(false, publishGeneration); - return; - } - - CacheCanonicalTokens(accountId, appId, candidates, publishGeneration); - LOG("[AutoCloudImport] Imported %zu AutoCloud file(s), updatedTokens=%u for app %u, CN=%llu", - imported, tokenMetadataChanged ? 1 : 0, appId, cn); - finish(true, publishGeneration); -} - -// Public API - -void Bootstrap(uint32_t accountId, uint32_t appId, bool wait) { - uint64_t cacheGeneration = GetTokenGeneration(accountId, appId); - if (!TryBeginBootstrap(accountId, appId)) { - if (wait) WaitForBootstrapInternal(accountId, appId); - return; - } - - if (wait) { - BootstrapWorker(accountId, appId, cacheGeneration); - return; - } - - if (IsShuttingDownInternal()) { - FinishBootstrap(accountId, appId, /*markAttempted=*/false, cacheGeneration); - return; - } - - // Claim slot before spawn so shutdown waits on post-spawn ops. - { - std::lock_guard<std::mutex> lock(g_bootstrapMutex); - ++g_orchestratorCount; - } - struct OrchestratorGuard { - ~OrchestratorGuard() { - std::lock_guard<std::mutex> lock(g_bootstrapMutex); - --g_orchestratorCount; - g_bootstrapCV.notify_all(); - } - } orchestratorGuard; - - // Cap concurrent bootstrap workers to avoid OOM on large libraries. - if (g_activeWorkerCount.load(std::memory_order_relaxed) >= kMaxConcurrentBootstraps) { - LOG("[AutoCloudImport] Bootstrap deferred for app %u: %d/%d workers active", - appId, g_activeWorkerCount.load(), kMaxConcurrentBootstraps); - FinishBootstrap(accountId, appId, /*markAttempted=*/false, cacheGeneration); - return; - } - - // Spawn unlocked; thread creation can block 100s of ms on Windows. - std::future<void> future; - try { - g_activeWorkerCount.fetch_add(1, std::memory_order_relaxed); - future = std::async(std::launch::async, [accountId, appId, cacheGeneration]() { - BootstrapWorker(accountId, appId, cacheGeneration); - g_activeWorkerCount.fetch_sub(1, std::memory_order_relaxed); - }); - } catch (...) { - g_activeWorkerCount.fetch_sub(1, std::memory_order_relaxed); - LOG("[AutoCloudImport] Failed to spawn bootstrap worker for app %u", appId); - FinishBootstrap(accountId, appId, /*markAttempted=*/false, cacheGeneration); - return; - } - - // Stash for shutdown join. - { - std::unique_lock<std::mutex> lock(g_bootstrapMutex); - if (!g_shuttingDown) { - g_futures.push_back(std::move(future)); - return; - } - } - try { - future.wait(); - } catch (...) {} -} - -void WaitFor(uint32_t accountId, uint32_t appId) { - WaitForBootstrapInternal(accountId, appId); -} - -bool IsActive(uint32_t accountId, uint32_t appId) { - return IsBootstrapActiveInternal(accountId, appId); -} - -std::string CanonicalizeToken(uint32_t accountId, uint32_t appId, - const std::string& cleanName, - const std::string& fallbackToken) { - if (cleanName.empty()) return fallbackToken; - - std::lock_guard<std::mutex> lock(g_tokenCacheMutex); - auto appIt = g_canonicalTokenCache.find(MakeAppKey(accountId, appId)); - if (appIt != g_canonicalTokenCache.end()) { - auto tokenIt = appIt->second.find(cleanName); - if (tokenIt != appIt->second.end()) { - if (tokenIt->second != fallbackToken) { - LOG("[NS-TOK] Canonicalized token for account %u app %u file %s: %s -> %s", - accountId, appId, cleanName.c_str(), fallbackToken.c_str(), tokenIt->second.c_str()); - } - return tokenIt->second; - } - } - return fallbackToken; -} - -uint64_t GetCacheGeneration(uint32_t accountId, uint32_t appId) { - return GetTokenGeneration(accountId, appId); -} - -std::unordered_map<std::string, std::string> GetCachedTokens(uint32_t accountId, uint32_t appId) { - std::lock_guard<std::mutex> lock(g_tokenCacheMutex); - auto it = g_canonicalTokenCache.find(MakeAppKey(accountId, appId)); - if (it != g_canonicalTokenCache.end()) { - return it->second; - } - return {}; -} - -void InvalidateCache(uint32_t accountId, uint32_t appId) { - std::lock_guard<std::mutex> importLock(g_importMutex); - uint64_t key = MakeAppKey(accountId, appId); - { - std::lock_guard<std::mutex> lock(g_tokenCacheMutex); - g_canonicalTokenCache.erase(key); - ++g_canonicalTokenGeneration[key]; - } - // Do NOT reset g_attemptedApps -- Steam imports once per process; resetting causes re-import loops. -} - -void ResetAttempted(uint32_t accountId, uint32_t appId) { - std::lock_guard<std::mutex> lock(g_bootstrapMutex); - g_attemptedApps.erase(MakeAppKey(accountId, appId)); -} - -int RestoreBlobsToGameFolder(uint32_t accountId, uint32_t appId, - const std::string& steamPath, - const std::unordered_map<std::string, CloudStorage::FileEntry>* cloudFiles) { - auto fileTokens = CloudStorage::LoadFileTokens(accountId, appId); - if (fileTokens.empty()) { - LOG("[AutoCloudRestore] app %u: no file tokens, skipping restore", appId); - return 0; - } - - auto rootDirs = AutoCloudScan::GetRootTokenDirectories(steamPath, appId, accountId); - if (rootDirs.empty()) { - LOG("[AutoCloudRestore] app %u: no root directories resolved", appId); - return 0; - } - - auto files = LocalStorage::GetFileList(accountId, appId); - if (files.empty()) { - LOG("[AutoCloudRestore] app %u: no blobs in local storage", appId); - return 0; - } - - // Build list of files that need restoring (local checks only, no I/O). - struct RestoreJob { - std::string filename; - std::string targetPath; - uint64_t timestamp; - uint64_t rawSize; - std::string expectedShaHex; // from cloud state; empty if not available - }; - std::vector<RestoreJob> jobs; - - for (const auto& fe : files) { - if (fe.deleted) continue; - if (CloudIntercept::IsInternalMetadataFile(fe.filename)) continue; - - auto tokenIt = fileTokens.find(fe.filename); - if (tokenIt == fileTokens.end() || tokenIt->second.empty()) continue; - - auto dirIt = rootDirs.find(tokenIt->second); - if (dirIt == rootDirs.end() || dirIt->second.empty()) { - LOG("[AutoCloudRestore] app %u file %s: unknown root token '%s'", - appId, fe.filename.c_str(), tokenIt->second.c_str()); - continue; - } - - std::string targetPath = dirIt->second + fe.filename; - for (auto& c : targetPath) { - if (c == '/') { -#ifdef _WIN32 - c = '\\'; -#endif - } - } - - if (!FileUtil::IsPathWithin(dirIt->second, targetPath)) { - LOG("[AutoCloudRestore] app %u file %s: path traversal blocked (root=%s)", - appId, fe.filename.c_str(), dirIt->second.c_str()); - continue; - } - - std::error_code ec; - auto targetFsPath = FileUtil::Utf8ToPath(targetPath); - bool exists = std::filesystem::exists(targetFsPath, ec); - - if (exists && !ec) { - auto diskTime = std::filesystem::last_write_time(targetFsPath, ec); - if (!ec) { - auto diskSeconds = AutoCloudUtil::FileTimeToUnixSeconds(diskTime); - std::error_code sizeEc; - auto diskSize = std::filesystem::file_size(targetFsPath, sizeEc); - if (diskSeconds >= fe.timestamp && !sizeEc && diskSize > 0) { - continue; - } - } - } - - std::string shaHex; - if (cloudFiles) { - auto cit = cloudFiles->find(fe.filename); - if (cit != cloudFiles->end() && !cit->second.sha.empty()) { - const auto& sha = cit->second.sha; - static const char kHex[] = "0123456789abcdef"; - shaHex.reserve(sha.size() * 2); - for (uint8_t b : sha) { - shaHex += kHex[b >> 4]; - shaHex += kHex[b & 0xf]; - } - } - } - jobs.push_back({fe.filename, std::move(targetPath), fe.timestamp, fe.rawSize, std::move(shaHex)}); - } - - if (jobs.empty()) return 0; - - // Fetch blobs in parallel (up to 8 concurrent), then write sequentially. - constexpr size_t kMaxParallel = 8; - std::vector<std::vector<uint8_t>> blobResults(jobs.size()); - size_t totalJobs = jobs.size(); - - for (size_t base = 0; base < totalJobs; base += kMaxParallel) { - size_t batchEnd = (std::min)(base + kMaxParallel, totalJobs); - std::vector<std::future<std::vector<uint8_t>>> futures; - futures.reserve(batchEnd - base); - - for (size_t i = base; i < batchEnd; ++i) { - uint32_t acct = accountId; - uint32_t app = appId; - const std::string& fname = jobs[i].filename; - const std::string& fsha = jobs[i].expectedShaHex; - futures.push_back(std::async(std::launch::async, - [acct, app, &fname, &fsha]() { - return CloudStorage::RetrieveBlob(acct, app, fname, nullptr, fsha); - })); - } - - for (size_t i = 0; i < futures.size(); ++i) { - blobResults[base + i] = futures[i].get(); - } - } - - // Write to disk sequentially (filesystem ops are fast, atomicity matters). - int restored = 0; - for (size_t i = 0; i < totalJobs; ++i) { - auto& job = jobs[i]; - auto& blobData = blobResults[i]; - - if (blobData.empty() && job.rawSize > 0) { - LOG("[AutoCloudRestore] app %u file %s: blob unavailable (local+cloud)", - appId, job.filename.c_str()); - continue; - } - - auto targetFsPath = FileUtil::Utf8ToPath(job.targetPath); - auto parentDir = targetFsPath.parent_path(); - if (!parentDir.empty()) { - std::error_code ec; - std::filesystem::create_directories(parentDir, ec); - if (ec) { - LOG("[AutoCloudRestore] app %u file %s: failed to create directory %s: %s", - appId, job.filename.c_str(), - FileUtil::PathToUtf8(parentDir).c_str(), ec.message().c_str()); - continue; - } - } - - if (!FileUtil::AtomicWriteBinary(job.targetPath, blobData.data(), blobData.size())) { - LOG("[AutoCloudRestore] app %u file %s: failed to write: %s", - appId, job.filename.c_str(), job.targetPath.c_str()); - continue; - } - - if (job.timestamp > 0) { - std::error_code ec; - auto targetTime = AutoCloudUtil::UnixSecondsToFileTime(job.timestamp); - std::filesystem::last_write_time(targetFsPath, targetTime, ec); - } - - restored++; - LOG("[AutoCloudRestore] app %u: restored %s -> %s (%zu bytes, ts=%llu)", - appId, job.filename.c_str(), job.targetPath.c_str(), - blobData.size(), (unsigned long long)job.timestamp); - } - - if (restored > 0) { - LOG("[AutoCloudRestore] app %u: restored %d file(s) to game folder", appId, restored); - } - return restored; -} - -void Shutdown() { - std::vector<std::future<void>> futures; - { - std::lock_guard<std::mutex> lock(g_bootstrapMutex); - g_shuttingDown = true; - futures.swap(g_futures); - } - for (auto& future : futures) { - try { future.get(); } catch (...) {} - } - std::unique_lock<std::mutex> lock(g_bootstrapMutex); - g_bootstrapCV.wait(lock, [] { - return g_activeApps.empty() && g_orchestratorCount == 0; - }); -} - -} // namespace AutoCloudBootstrap diff --git a/src/common/autocloud_bootstrap.h b/src/common/autocloud_bootstrap.h deleted file mode 100644 index 4d2c184a..00000000 --- a/src/common/autocloud_bootstrap.h +++ /dev/null @@ -1,36 +0,0 @@ -#pragma once -// AutoCloud bootstrap - imports pre-existing save files into cloud storage -// on first launch per app. - -#include "app_state.h" -#include <string> -#include <cstdint> -#include <unordered_map> - -namespace AutoCloudBootstrap { - -void Bootstrap(uint32_t accountId, uint32_t appId, bool wait = false); - -void WaitFor(uint32_t accountId, uint32_t appId); - -bool IsActive(uint32_t accountId, uint32_t appId); - -std::string CanonicalizeToken(uint32_t accountId, uint32_t appId, - const std::string& cleanName, - const std::string& fallbackToken); - -uint64_t GetCacheGeneration(uint32_t accountId, uint32_t appId); - -std::unordered_map<std::string, std::string> GetCachedTokens(uint32_t accountId, uint32_t appId); - -void InvalidateCache(uint32_t accountId, uint32_t appId); - -void ResetAttempted(uint32_t accountId, uint32_t appId); - -int RestoreBlobsToGameFolder(uint32_t accountId, uint32_t appId, - const std::string& steamPath, - const std::unordered_map<std::string, CloudStorage::FileEntry>* cloudFiles = nullptr); - -void Shutdown(); - -} // namespace AutoCloudBootstrap diff --git a/src/common/autocloud_scan.cpp b/src/common/autocloud_scan.cpp index 0b1ca472..a872ae21 100644 --- a/src/common/autocloud_scan.cpp +++ b/src/common/autocloud_scan.cpp @@ -46,7 +46,6 @@ using AutoCloudUtil::IsSafeRelativePath; using AutoCloudUtil::IsLinuxOS; using AutoCloudUtil::kMaxAppInfoBytes; using AutoCloudUtil::kMaxAppInfoStrings; -using AutoCloudUtil::kMaxAutoCloudCandidateBytes; using AutoCloudUtil::kMaxAutoCloudScanFiles; using AutoCloudUtil::kMaxAutoCloudScanMillis; using AutoCloudUtil::NormalizeSlashes; @@ -577,23 +576,27 @@ static std::vector<AutoCloudRuleNative> LoadAutoCloudRules(const std::string& st // SHA1 for files -// Whole-file SHA1; bounded by kMaxAutoCloudCandidateBytes. -static std::vector<uint8_t> SHA1File(const std::string& path) { +// Read a whole file and SHA1 it in one pass, returning the bytes in outBytes so +// the caller can reuse them without a second read. Returns empty on error. +static std::vector<uint8_t> ReadAndHashFile(const std::string& path, + std::vector<uint8_t>& outBytes) { + outBytes.clear(); std::ifstream f(FileUtil::Utf8ToPath(path), std::ios::binary); if (!f) return {}; - // Read entire file and hash it f.seekg(0, std::ios::end); auto size = f.tellg(); - if (size < 0 || static_cast<uint64_t>(size) > kMaxAutoCloudCandidateBytes) { + if (size < 0) { return {}; } f.seekg(0); std::vector<uint8_t> buf(static_cast<size_t>(size)); - if (!f.read(reinterpret_cast<char*>(buf.data()), size)) { + if (!buf.empty() && !f.read(reinterpret_cast<char*>(buf.data()), size)) { return {}; // Empty vector signals error } - return FileUtil::SHA1(buf.data(), buf.size()); + auto sha = FileUtil::SHA1(buf.data(), buf.size()); + outBytes = std::move(buf); + return sha; } } // anonymous namespace @@ -632,6 +635,11 @@ ScanResult GetFileList(const std::string& steamPath, std::filesystem::path appUserdataDir = FileUtil::Utf8ToPath(steamPath) / "userdata" / std::to_string(accountId) / std::to_string(appId); + // Retain hashed bytes so the bootstrap commit can avoid re-reading; bounded + // by a total budget, beyond which the commit re-reads from disk. + constexpr uint64_t kMaxRetainedContentBytes = 512ULL * 1024 * 1024; + uint64_t retainedContentBytes = 0; + auto addFile = [&](const std::filesystem::directory_entry& fileEntry, const std::string& cloudPath, const std::string& sourcePath, @@ -643,13 +651,9 @@ ScanResult GetFileList(const std::string& steamPath, std::error_code ec; uint64_t rawSize = (uint64_t)fileEntry.file_size(ec); if (ec) return; - if (rawSize > kMaxAutoCloudCandidateBytes) { - LOG("GetAutoCloudFileList: skipping oversized app %u candidate %s (%llu bytes)", - appId, sourcePath.c_str(), (unsigned long long)rawSize); - return; - } - auto sha = SHA1File(FileUtil::PathToUtf8(fileEntry.path())); + std::vector<uint8_t> bytes; + auto sha = ReadAndHashFile(FileUtil::PathToUtf8(fileEntry.path()), bytes); if (sha.empty()) { LOG("GetAutoCloudFileList: skipping app %u file %s (SHA1 read error)", appId, sourcePath.c_str()); @@ -667,6 +671,10 @@ ScanResult GetFileList(const std::string& steamPath, fe.rootToken = rootToken; fe.rootId = rootId; fe.sha = std::move(sha); + if (retainedContentBytes + bytes.size() <= kMaxRetainedContentBytes) { + retainedContentBytes += bytes.size(); + fe.content = std::move(bytes); + } outResult.files.push_back(std::move(fe)); }; @@ -1386,4 +1394,11 @@ std::string GetAppName(const std::string& steamPath, uint32_t appId) { return GetAppNameFromAppInfo(steamPath, appId); } +#ifdef CLOUDREDIRECT_TESTING +std::vector<uint8_t> TestReadAndHashFile(const std::string& path, + std::vector<uint8_t>& outBytes) { + return ReadAndHashFile(path, outBytes); +} +#endif + } // namespace AutoCloudScan diff --git a/src/common/autocloud_scan.h b/src/common/autocloud_scan.h index b1617147..cdb116b0 100644 --- a/src/common/autocloud_scan.h +++ b/src/common/autocloud_scan.h @@ -20,6 +20,7 @@ struct FileEntry { std::vector<uint8_t> sha; // SHA1 hash (20 bytes) std::string rootToken; // Cloud root token (e.g., "%WinAppDataLocal%") uint32_t rootId = 0; // Steam ERemoteStorageFileRoot enum value + std::vector<uint8_t> content; // bytes read while hashing; empty if not retained }; struct ScanResult { @@ -55,4 +56,12 @@ std::unordered_map<std::string, std::string> GetRootTokenDirectories( // Returns empty string if not found. std::string GetAppName(const std::string& steamPath, uint32_t appId); +#ifdef CLOUDREDIRECT_TESTING +// Test seam: read a file once, returning its SHA1 and (in outBytes) the exact +// bytes read. Underpins the scan->commit race fix (bytes are captured during +// hashing so the commit never re-reads). Returns empty SHA on error. +std::vector<uint8_t> TestReadAndHashFile(const std::string& path, + std::vector<uint8_t>& outBytes); +#endif + } // namespace AutoCloudScan diff --git a/src/common/autocloud_util.h b/src/common/autocloud_util.h index d99eccf4..13419157 100644 --- a/src/common/autocloud_util.h +++ b/src/common/autocloud_util.h @@ -34,7 +34,7 @@ static constexpr uintmax_t kMaxAppInfoBytes = 512ULL * 1024 * 1024; static constexpr uint32_t kMaxAppInfoStrings = 200000; static constexpr size_t kMaxAutoCloudScanFiles = 20000; static constexpr int kMaxAutoCloudScanMillis = 5000; -static constexpr uint64_t kMaxAutoCloudCandidateBytes = 128ULL * 1024 * 1024; +// No per-file size cap; storage is bounded by the app's cloud quota. // Wildcard matching caps against exponential backtracking. static constexpr size_t kMaxWildcardPatternLen = 1024; diff --git a/src/common/cloud_storage.cpp b/src/common/cloud_storage.cpp index 72acf81d..db345cec 100644 --- a/src/common/cloud_storage.cpp +++ b/src/common/cloud_storage.cpp @@ -66,7 +66,7 @@ struct BlobIndex { static std::unordered_map<uint64_t, BlobIndex> g_blobIndex; // key = (accountId<<32)|appId // Serializes token persistence (root_token.dat, file_tokens.dat) across -// concurrent callers (rpc_handlers batch operations, AutoCloudBootstrap). +// concurrent callers (rpc_handlers batch operations). // Per-(account,app) sync mutex registry (Steam-parity). Non-reentrant: SyncFromCloudInner-reachable callers go direct. static std::mutex g_syncMutexRegistryMutex; static std::unordered_map<uint64_t, std::shared_ptr<std::mutex>> g_syncMutexRegistry; diff --git a/src/common/local_storage.cpp b/src/common/local_storage.cpp index 32ae5874..96a15d80 100644 --- a/src/common/local_storage.cpp +++ b/src/common/local_storage.cpp @@ -62,7 +62,6 @@ using AutoCloudUtil::GetKnownFolderPathString; using AutoCloudUtil::IsSafeRelativePath; using AutoCloudUtil::kMaxAppInfoBytes; using AutoCloudUtil::kMaxAppInfoStrings; -using AutoCloudUtil::kMaxAutoCloudCandidateBytes; using AutoCloudUtil::kMaxAutoCloudScanFiles; using AutoCloudUtil::kMaxAutoCloudScanMillis; using AutoCloudUtil::NormalizeSlashes; diff --git a/src/common/rpc_handlers.cpp b/src/common/rpc_handlers.cpp index 381daf41..b5ae742a 100644 --- a/src/common/rpc_handlers.cpp +++ b/src/common/rpc_handlers.cpp @@ -1,6 +1,5 @@ #include "rpc_handlers.h" #include "metadata_sync.h" -#include "autocloud_bootstrap.h" #include "autocloud_scan.h" #include "autocloud_util.h" #include "batch_tracker.h" @@ -143,7 +142,6 @@ static void SetRpcCrashContext(const char* phase, const char* method, uint32_t a // Shutdown void ShutdownRpcHandlers() { - AutoCloudBootstrap::Shutdown(); } static uint64_t ParsePlaytimeField(const Json::Value& value) { @@ -211,124 +209,80 @@ static std::mutex g_batchCanonicalTokensMutex; // Serializes token load-merge-save cycles. static std::mutex g_tokenCaptureMutex; -// Forward declaration for Linux SetCloudSyncState. static bool EnsureVdfSectionPath(std::string& vdfContent, const char* const* sections, size_t sectionCount); -#ifdef _WIN32 -// Write cloud sync icon state to the registry key Steam reads at startup. -static void SetCloudSyncState(uint32_t appId, const char* state) { - char subkey[128]; - snprintf(subkey, sizeof(subkey), - "Software\\Valve\\Steam\\Apps\\%u\\cloud", appId); - HKEY hk = nullptr; - if (RegCreateKeyExA(HKEY_CURRENT_USER, subkey, 0, nullptr, - 0, KEY_SET_VALUE, nullptr, &hk, nullptr) == ERROR_SUCCESS) { - RegSetValueExA(hk, "last_sync_state", 0, REG_SZ, - reinterpret_cast<const BYTE*>(state), - static_cast<DWORD>(strlen(state) + 1)); - RegCloseKey(hk); - } -} -void FlushPendingSyncStates() {} // Windows registry writes are immediate -#else -// Write cloud sync icon state to registry.vdf (Linux). -static std::mutex g_registryVdfMutex; -static std::unordered_map<uint32_t, std::string> g_pendingSyncStates; - -// Applies all entries from |states| into registry.vdf atomically. -// Caller must hold g_registryVdfMutex. -static void WriteRegistryVdfSyncStates( - const std::string& vdfPath, - const std::unordered_map<uint32_t, std::string>& states) { - if (states.empty()) return; +// Cloud sync-icon state (last_sync_state) was never wired up: SetCloudSyncState +// had no callers, so the icon writer is dead code. Retained as a no-op to keep +// the OnUnload contract; the value is only read by Steam's UI, never by us. +void FlushPendingSyncStates() {} - std::string vdfContent; - { - std::ifstream f(vdfPath); - if (!f) return; - vdfContent = std::string(std::istreambuf_iterator<char>(f), {}); - } - if (vdfContent.empty()) return; - - for (auto& [appId, state] : states) { - std::string appIdStr = std::to_string(appId); - const char* sections[] = { - "Registry", "HKCU", "Software", "Valve", "Steam", "Apps", - appIdStr.c_str(), "cloud" - }; - constexpr size_t kSectionCount = 8; - - bool updated = false; - VdfUtil::ForEachFieldInSection(vdfContent, sections, kSectionCount, - [&](const VdfUtil::FieldInfo& fi) -> bool { - if (fi.key == "last_sync_state") { - vdfContent.replace(fi.valStart, fi.valEnd - fi.valStart, state); - updated = true; - return false; - } - return true; - }); +// Namespace apps may lack PICS data (ufs.quota/maxnumfiles default to 0, +// causing over-quota eviction). Inject cached PICS values or fallback. +static constexpr uint64_t kFallbackQuotaBytes = 1073741824ULL; // 1 GB +static constexpr uint32_t kFallbackMaxFiles = 10000; - if (!updated) { - if (!EnsureVdfSectionPath(vdfContent, sections, kSectionCount)) - continue; - size_t sectionStart = 0, sectionEnd = 0; - if (!VdfUtil::FindVdfSectionRange(vdfContent, sections, kSectionCount, - sectionStart, sectionEnd)) - continue; - std::string indent = "\t\t\t\t\t\t\t\t\t"; - size_t lineStart = vdfContent.rfind('\n', sectionEnd); - if (lineStart != std::string::npos) { - ++lineStart; - size_t indentEnd = lineStart; - while (indentEnd < vdfContent.size() && - (vdfContent[indentEnd] == '\t' || vdfContent[indentEnd] == ' ')) - ++indentEnd; - indent.assign(vdfContent.data() + lineStart, indentEnd - lineStart); - indent.push_back('\t'); - } - std::string insertion = indent + "\"last_sync_state\"\t\t\"" + state + "\"\n"; - vdfContent.insert(sectionEnd, insertion); - } +// Cache each app's ORIGINAL (dev/PICS) ufs quota the first time we observe it, +// before any rule-multiplier scaling, so the scaling stays idempotent across the +// many EnsureAppQuotaInjected calls per session (otherwise re-reading the live +// KV would compound the multiplier each call). +struct OriginalQuota { uint64_t quotaBytes; uint32_t maxNumFiles; }; +static std::unordered_map<uint32_t, OriginalQuota> g_originalQuota; +static std::mutex g_originalQuotaMutex; + +// Resolve the dev's original ufs quota for an app. On first sight, seeds the +// cache from the live KV (which has not yet been scaled this session). Returns +// the cached original via in/out params. +static void GetOriginalQuota(uint32_t appId, uint64_t& quotaBytes, + uint32_t& maxNumFiles) { + std::lock_guard<std::mutex> lock(g_originalQuotaMutex); + auto it = g_originalQuota.find(appId); + if (it == g_originalQuota.end()) { + g_originalQuota[appId] = OriginalQuota{quotaBytes, maxNumFiles}; + return; } - - FileUtil::AtomicWriteText(vdfPath, vdfContent); -} - -static void SetCloudSyncState(uint32_t appId, const char* state) { - std::string steamPath = GetSteamPath(); - if (steamPath.empty()) return; - - std::string vdfPath = steamPath + "registry.vdf"; - std::lock_guard<std::mutex> lock(g_registryVdfMutex); - - g_pendingSyncStates[appId] = state; - - std::unordered_map<uint32_t, std::string> single{{appId, state}}; - WriteRegistryVdfSyncStates(vdfPath, single); + quotaBytes = it->second.quotaBytes; + maxNumFiles = it->second.maxNumFiles; } -// Flush all tracked sync states to registry.vdf (called from OnUnload). -void FlushPendingSyncStates() { - std::string steamPath = GetSteamPath(); +// When an app has >1 savefiles rule, Steam's AC-exit disk walk counts each file +// once per rule. Scale the live ufs budget by the rule count so colliding rules +// (rootoverrides resolving to the same path) can't trip a false "over quota" +// that deletes all cloud files. Idempotent: derives from the cached original. +static void EnsureQuotaSurvivesRuleMultiplier(uint32_t appId, + uint64_t liveQuota, + uint32_t liveFiles) { + std::string steamPath = CloudIntercept::GetSteamPath(); if (steamPath.empty()) return; - - std::string vdfPath = steamPath + "registry.vdf"; - std::lock_guard<std::mutex> lock(g_registryVdfMutex); - - if (g_pendingSyncStates.empty()) return; - LOG("[SyncState] Flushing %zu pending sync states to registry.vdf", - g_pendingSyncStates.size()); - WriteRegistryVdfSyncStates(vdfPath, g_pendingSyncStates); + size_t ruleCount = 0; + try { + ruleCount = AutoCloudScan::GetRules(steamPath, appId).size(); + } catch (...) { return; } + if (ruleCount <= 1) return; // single rule -> no per-rule double-count + + uint64_t origQuota = liveQuota; + uint32_t origFiles = liveFiles; + GetOriginalQuota(appId, origQuota, origFiles); + + // Effective budget must cover original_per_file_budget * ruleCount instances, + // PLUS headroom: Steam's YldOnAppExit budget loop also subtracts apireserve* + // and decrements the file budget by (1 + siblingCount) per file, so an exact + // fileCount*ruleCount budget still trips. Add a full extra rule-multiple of + // slack so the colliding duplicates never reach the cutoff. + uint64_t targetFiles = static_cast<uint64_t>(origFiles) * (ruleCount + 1) + 16; + uint64_t targetQuota = origQuota * (ruleCount + 1); + if (targetFiles <= liveFiles && targetQuota <= liveQuota) { + return; // already sufficient (idempotent no-op) + } + SteamKvInjector::SetAppQuota(appId, targetQuota, + static_cast<uint32_t>(targetFiles)); + LOG("[NS] EnsureQuotaSurvivesRuleMultiplier app=%u: %zu rules -> set budget " + "files=%u->%llu quota=%llu->%llu (original files=%u quota=%llu)", + appId, ruleCount, liveFiles, (unsigned long long)targetFiles, + (unsigned long long)liveQuota, (unsigned long long)targetQuota, + origFiles, (unsigned long long)origQuota); } -#endif - -// Namespace apps may lack PICS data (ufs.quota/maxnumfiles default to 0, -// causing over-quota eviction). Inject cached PICS values or fallback. -static constexpr uint64_t kFallbackQuotaBytes = 1073741824ULL; // 1 GB -static constexpr uint32_t kFallbackMaxFiles = 10000; static bool EnsureAppQuotaInjected(uint32_t accountId, uint32_t appId, CloudStorage::CloudAppState* cloudState) { @@ -342,19 +296,35 @@ static bool EnsureAppQuotaInjected(uint32_t accountId, uint32_t appId, bool readOk = SteamKvInjector::ReadAppQuota(appId, existingQuota, existingFiles); if (readOk && existingQuota > 0 && existingFiles > 0) { + // The live KV may already carry our rule-multiplier scaling from an + // earlier call this session. Resolve the dev's ORIGINAL value (cached on + // first sight) so we never cache/propagate a scaled budget. + uint64_t origQuota = existingQuota; + uint32_t origFiles = existingFiles; + GetOriginalQuota(appId, origQuota, origFiles); + if (cloudState && - (cloudState->quota.quotaBytes != existingQuota || - cloudState->quota.maxNumFiles != existingFiles)) { - cloudState->quota.quotaBytes = existingQuota; - cloudState->quota.maxNumFiles = existingFiles; + (cloudState->quota.quotaBytes != origQuota || + cloudState->quota.maxNumFiles != origFiles)) { + cloudState->quota.quotaBytes = origQuota; + cloudState->quota.maxNumFiles = origFiles; cloudState->quota.fetchedAtUnix = static_cast<uint64_t>(time(nullptr)); cloudState->quota.lastSeenBuildId = cloudState->appBuildId; LOG("[NS] EnsureAppQuotaInjected app=%u: caching PICS quota=%llu files=%u (publish deferred to next batch)", - appId, (unsigned long long)existingQuota, existingFiles); + appId, (unsigned long long)origQuota, origFiles); // Quota persisted on next CompleteBatch; async publish risks overwriting newer state. } LOG("[NS] EnsureAppQuotaInjected app=%u: Steam has quota=%llu files=%u", appId, (unsigned long long)existingQuota, existingFiles); + // Mixed-root collision guard: when an app has >1 savefiles rule, Steam's + // exit disk-walk counts each file once PER RULE. Apps whose rules resolve + // to the same path on this OS (via rootoverrides) get fileCount*ruleCount + // instances counted against maxnumfiles -> spurious "over quota" eviction + // that DELETES all cloud files (e.g. app 1583520: 2 rules, maxnumfiles=5, + // 5 files -> 10 instances > 5 -> wipe). Scale the live budget by the rule + // count so the dev's effective per-file budget is preserved. Idempotent: + // computed from the cached ORIGINAL value, then set (not multiplied). + EnsureQuotaSurvivesRuleMultiplier(appId, existingQuota, existingFiles); return true; } @@ -377,23 +347,6 @@ static bool EnsureAppQuotaInjected(uint32_t accountId, uint32_t appId, return SteamKvInjector::InjectAppQuota(appId, injectQuota, injectFiles); } -// Track (account, app) pairs that received a full manifest this session. -static std::unordered_set<uint64_t> g_fullManifestSentApps; -static std::mutex g_fullManifestSentMutex; - -// Per-(account, app) cached CN and buildId for the fast repeat-call path. -static std::unordered_map<uint64_t, uint64_t> g_cachedCloudCN; -static std::unordered_map<uint64_t, uint64_t> g_cachedAppBuildIdHwm; - -static uint64_t GetCachedCloudCN(uint32_t accountId, uint32_t appId) { - auto it = g_cachedCloudCN.find(MakeAppAccountKey(accountId, appId)); - return (it != g_cachedCloudCN.end()) ? it->second : 0; -} -static uint64_t GetCachedAppBuildIdHwm(uint32_t accountId, uint32_t appId) { - auto it = g_cachedAppBuildIdHwm.find(MakeAppAccountKey(accountId, appId)); - return (it != g_cachedAppBuildIdHwm.end()) ? it->second : 0; -} - // Inject savefiles rules so AC exit-sync builds a valid file-root tree. // Without this, namespace apps get all files deleted ("no longer matches patterns"). static std::unordered_set<uint32_t> g_saveFilesInjected; @@ -472,22 +425,12 @@ static void PrepareBatchCanonicalTokens(uint32_t accountId, uint32_t appId) { if (g_batchCanonicalTokens.find(key) != g_batchCanonicalTokens.end()) return; } - // Try bootstrap cache first - std::unordered_map<std::string, std::string> tokens = AutoCloudBootstrap::GetCachedTokens(accountId, appId); - - // Fall back to disk - if (tokens.empty()) { - tokens = CloudStorage::LoadFileTokens(accountId, appId); - } - - // If still empty and bootstrap active, wait for it - if (tokens.empty()) { - if (AutoCloudBootstrap::IsActive(accountId, appId)) { - AutoCloudBootstrap::WaitFor(accountId, appId); - tokens = AutoCloudBootstrap::GetCachedTokens(accountId, appId); - } - if (tokens.empty()) return; - } + // File->root-token mappings are persisted to disk by the upload path; load + // them directly. (Previously also checked the AutoCloud bootstrap's in-memory + // cache, but that component is gone -- disk is the single source of truth.) + std::unordered_map<std::string, std::string> tokens = + CloudStorage::LoadFileTokens(accountId, appId); + if (tokens.empty()) return; std::lock_guard<std::mutex> lock(g_batchCanonicalTokensMutex); g_batchCanonicalTokens.emplace(key, std::move(tokens)); @@ -518,14 +461,6 @@ static std::string CanonicalizeUploadRootToken(uint32_t accountId, uint32_t appI } } - // Fall back to bootstrap module's live cache - if (!foundCanonical) { - canonical = AutoCloudBootstrap::CanonicalizeToken(accountId, appId, cleanName, fallbackToken); - if (canonical != fallbackToken) { - foundCanonical = true; - } - } - if (!foundCanonical) return fallbackToken; if (canonical != fallbackToken) { LOG("[NS-TOK] Canonicalized upload token for account %u app %u file %s: %s -> %s", @@ -670,9 +605,6 @@ static void InvalidateTokenCaches(uint32_t accountId, uint32_t appId) { } // Keep manifest/CN cache -- metadata restore doesn't change save CN, and clearing // mid-session would break exit-sync's is_only_delta=1 path. - - // Invalidate bootstrap module's cache (also resets attempted flag) - AutoCloudBootstrap::InvalidateCache(accountId, appId); } static bool MergeStatsFile(uint32_t appId, uint32_t accountId, @@ -1138,54 +1070,16 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB EnsureAppQuotaInjected(accountId, appId, nullptr); EnsureSaveFilesInjected(appId); - // Fast path: probe CN if full manifest already sent this session. - { - const uint64_t cacheKey = MakeAppAccountKey(accountId, appId); - bool repeatCall = false; - uint64_t cachedCN = 0; - uint64_t cachedBuildId = 0; - { - std::lock_guard<std::mutex> lock(g_fullManifestSentMutex); - if (g_fullManifestSentApps.count(cacheKey) > 0) { - repeatCall = true; - cachedCN = GetCachedCloudCN(accountId, appId); - cachedBuildId = GetCachedAppBuildIdHwm(accountId, appId); - } - } - if (repeatCall) { - // CN probe; on change, invalidate cache and fall through to full fetch. - bool probeNeeded = CloudStorage::IsCloudActive() && cachedCN > 0; - uint64_t remoteCN = probeNeeded - ? CloudStorage::FetchCloudCN(accountId, appId) : cachedCN; - if (probeNeeded && remoteCN == 0) { - // Probe failed; invalidate cache and fall through to full fetch. - LOG("[NS-CL] GetAppFileChangelist app=%u: CN probe failed, invalidating cache", - appId); - std::lock_guard<std::mutex> lock(g_fullManifestSentMutex); - g_fullManifestSentApps.erase(cacheKey); - g_cachedCloudCN.erase(cacheKey); - g_cachedAppBuildIdHwm.erase(cacheKey); - // Fall through to full fetch below - } else if (remoteCN != cachedCN) { - LOG("[NS-CL] GetAppFileChangelist app=%u: remote CN=%llu differs from cached CN=%llu, invalidating cache", - appId, remoteCN, cachedCN); - std::lock_guard<std::mutex> lock(g_fullManifestSentMutex); - g_fullManifestSentApps.erase(cacheKey); - g_cachedCloudCN.erase(cacheKey); - g_cachedAppBuildIdHwm.erase(cacheKey); - // Fall through to full fetch below - } else { - LOG("[NS-CL] GetAppFileChangelist app=%u: repeat call, CN unchanged (%llu), returning cached empty delta", - appId, cachedCN); - PB::Writer body; - body.WriteVarint(1, cachedCN); - body.WriteVarint(3, 1); // is_only_delta = 1 - body.WriteString(5, GetMachineName()); - body.WriteVarint(6, cachedBuildId); - return body; - } - } - } + // No client-side changelist fast-path. Real Steam (sub_13852FDC0 in + // steamclient64) ALWAYS calls Cloud.GetAppFileChangelist and decides "already + // synced" purely from the response's current_change_number vs its own CN. A + // CloudRedirect-side "repeat call, empty delta" short-circuit has no Steam + // equivalent and is unsafe: it asserts the client is synced without verifying + // the client still holds the files on disk. If the client's disk diverged + // (file deleted/missing), the empty delta makes Steam treat the missing files + // as locally deleted and wipe the cloud copy. Always serve authoritative state + // and let Steam's own CN comparison + reconciliation run, exactly like a real + // cloud server. // Track whether we fetched fresh manifest from cloud this call CloudStorage::Manifest cloudManifest; @@ -1195,6 +1089,7 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB uint64_t appBuildIdHwm = 0; CloudStorage::CloudAppState fetchedState; // retained for quota caching bool haveFetchedState = false; + bool cloudStateNotFound = false; // true ONLY on genuine NotFound (not fetch error) if (CloudStorage::IsCloudActive()) { SetRpcCrashContext("GetChangelist:fetch-cloud", "Cloud.GetAppFileChangelist#1", appId); @@ -1226,6 +1121,7 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB LOG("[NS-CL] GetAppFileChangelist app=%u: cloud state CN=%llu (%zu files)", appId, cloudCN, cloudManifest.size()); } else if (stateResult.status == CloudStorage::StateFetchStatus::NotFound) { + cloudStateNotFound = true; LOG("[NS-CL] GetAppFileChangelist app=%u: no cloud state (new app), using local", appId); } else { @@ -1257,52 +1153,83 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB appId, cloudCN, cloudManifest.size()); } - // Async AutoCloud bootstrap; set is_only_delta=1 if active. - SetRpcCrashContext("GetChangelist:bootstrap", "Cloud.GetAppFileChangelist#1", appId); - AutoCloudBootstrap::Bootstrap(accountId, appId, /*wait=*/false); - bool bootstrapActive = AutoCloudBootstrap::IsActive(accountId, appId); - - if (CloudStorage::IsCloudActive() && cloudCN == 0 && !bootstrapActive) { - SetRpcCrashContext("GetChangelist:promote-local", "Cloud.GetAppFileChangelist#1", appId); - uint64_t localCN = LocalStorage::GetChangeNumber(accountId, appId); - if (localCN > 0) { - CloudStorage::Manifest fullManifest = - CloudStorage::BuildManifestFromLocalBlobs(accountId, appId); - size_t nonReserved = 0; - for (const auto& [name, entry] : fullManifest) { - if (!IsReservedBlobFilename(name)) ++nonReserved; + // Reconciliation safety net: if our local blob store contains non-reserved + // files the REAL cloud manifest is missing, publish a MERGED manifest at a + // non-rewinding CN. Native upload (CompleteBatch) is the primary publisher; + // this catches the case where blobs exist locally but cloud state was lost + // and no new upload has happened yet. Publishes at strictly-above-cloud CN so + // it can never rewind (issue #53 was caused by the old AutoCloud bootstrap + // publishing a stale CN; that component has been removed entirely). + // Only reconcile when we KNOW the real cloud contents: either a successful + // fetch (haveFetchedState) or a definitive NotFound (empty cloud). NEVER on a + // transient fetch failure -- treating that as "empty" could republish over + // good cloud state during a network blip. + if (CloudStorage::IsCloudActive() && (haveFetchedState || cloudStateNotFound)) { + SetRpcCrashContext("GetChangelist:reconcile-local", "Cloud.GetAppFileChangelist#1", appId); + CloudStorage::Manifest localBlobs = + CloudStorage::BuildManifestFromLocalBlobs(accountId, appId); + + // Compare against the REAL cloud manifest only. cloudManifest/cloudCN may + // have been pre-filled from the local-fallback path above (when no cloud + // state exists), which would mask missing files. cloudFileEntries is + // populated ONLY by an actual cloud fetch (haveFetchedState), so use it as + // the authoritative "what's really in the cloud" set. NotFound -> empty. + static const std::unordered_map<std::string, CloudStorage::FileEntry> kEmptyCloudFiles; + const auto& realCloudFiles = haveFetchedState ? cloudFileEntries : kEmptyCloudFiles; + + size_t missingFromCloud = 0; + for (const auto& [name, entry] : localBlobs) { + if (IsReservedBlobFilename(name)) continue; + if (realCloudFiles.find(name) == realCloudFiles.end()) ++missingFromCloud; + } + + if (missingFromCloud > 0) { + // Merge: start from real cloud files, add/refresh from local blobs. + CloudStorage::CloudAppState mergedState; + for (const auto& [name, fe] : realCloudFiles) { + if (IsReservedBlobFilename(name)) continue; + mergedState.files[name] = fe; } - if (nonReserved > 0) { - LOG("[NS-CL] No cloud CN for app %u, publishing %zu local files at CN=%llu", - appId, nonReserved, localCN); - CloudStorage::CloudAppState bootstrapState; - bootstrapState.cn = localCN; - for (const auto& [name, me] : fullManifest) { - if (IsReservedBlobFilename(name)) continue; - CloudStorage::FileEntry fe; - fe.sha = me.sha; - fe.timestamp = me.timestamp; - fe.size = me.size; - bootstrapState.files[name] = std::move(fe); - } - auto statePtr = std::make_shared<CloudStorage::CloudAppState>(std::move(bootstrapState)); - uint32_t asyncAcct = accountId; - uint32_t asyncApp = appId; - std::thread([statePtr, asyncAcct, asyncApp] { - CloudStorage::InflightSyncScope guard; - if (!guard.entered) return; - auto syncMtx = CloudStorage::AcquireAppSyncMutex(asyncAcct, asyncApp); - std::lock_guard<std::mutex> lock(*syncMtx); - auto existing = CloudStorage::FetchCloudState(asyncAcct, asyncApp); - if (existing.status == CloudStorage::StateFetchStatus::Ok && - existing.state.cn >= statePtr->cn) { - LOG("[NS-CL] Bootstrap publish aborted for app %u: cloud CN %llu >= bootstrap CN %llu", - asyncApp, existing.state.cn, statePtr->cn); - return; - } - CloudStorage::PublishCloudState(asyncAcct, asyncApp, *statePtr); - }).detach(); + for (const auto& [name, me] : localBlobs) { + if (IsReservedBlobFilename(name)) continue; + CloudStorage::FileEntry fe; + fe.sha = me.sha; + fe.timestamp = me.timestamp; + fe.size = me.size; + mergedState.files[name] = std::move(fe); } + uint64_t baseCN = cloudCN > LocalStorage::GetChangeNumber(accountId, appId) + ? cloudCN + : LocalStorage::GetChangeNumber(accountId, appId); + mergedState.cn = baseCN + 1; // strictly above any seen CN -> never rewinds + mergedState.appBuildId = appBuildIdHwm; + + LOG("[NS-CL] Reconcile app %u: %zu local file(s) missing from cloud; " + "publishing merged manifest (%zu files) at CN=%llu", + appId, missingFromCloud, mergedState.files.size(), + (unsigned long long)mergedState.cn); + + auto statePtr = std::make_shared<CloudStorage::CloudAppState>(std::move(mergedState)); + uint32_t asyncAcct = accountId; + uint32_t asyncApp = appId; + std::thread([statePtr, asyncAcct, asyncApp] { + CloudStorage::InflightSyncScope guard; + if (!guard.entered) return; + auto syncMtx = CloudStorage::AcquireAppSyncMutex(asyncAcct, asyncApp); + std::lock_guard<std::mutex> lock(*syncMtx); + // Re-fetch under lock; recompute CN above the freshest cloud CN so + // a concurrent native publish can't be rewound. + auto existing = CloudStorage::FetchCloudState(asyncAcct, asyncApp); + if (existing.status == CloudStorage::StateFetchStatus::Ok) { + if (existing.state.cn >= statePtr->cn) { + statePtr->cn = existing.state.cn + 1; + } + // Don't clobber an active session lock. + statePtr->session = existing.state.session; + } + CloudStorage::PublishCloudState(asyncAcct, asyncApp, *statePtr); + LocalStorage::SetChangeNumber(asyncAcct, asyncApp, statePtr->cn); + }).detach(); } } @@ -1340,85 +1267,47 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB LOG("[NS-CL] GetAppFileChangelist app=%u delta clientCN=%llu serverCN=%llu (%zu changed)", appId, clientChangeNumber, cloudCN, files.size()); } else { - // No delta. First call: full manifest (populates root tokens). - // Subsequent calls: empty delta (avoids stale SHA/timestamp conflicts). - const uint64_t cacheKey = MakeAppAccountKey(accountId, appId); - bool alreadySentFull; - { - std::lock_guard<std::mutex> lock(g_fullManifestSentMutex); - alreadySentFull = g_fullManifestSentApps.count(cacheKey) > 0; - } - + // No per-CN delta to compute. Serve the authoritative full manifest on + // EVERY call -- like a real cloud server. (Previously this short-circuited + // to an empty delta on repeat calls within a session, which lied that the + // client was synced and let Steam wipe the cloud copy when local files had + // diverged.) Use cloud timestamps so repeated compares see a stable + // remotetime and don't manufacture false conflicts. serverChangeNumber = cloudCN; + responseIsDelta = false; - if (alreadySentFull) { - // Subsequent call: empty delta. - responseIsDelta = true; - LOG("[NS-CL] GetAppFileChangelist app=%u: already sent full manifest this session, returning empty delta at CN=%llu", - appId, cloudCN); - } else { - // First call: full manifest. Use cloud timestamp so subsequent - // compares see the same remotetime (avoids false conflicts). - responseIsDelta = false; - - for (const auto& [filename, entry] : cloudManifest) { - if (IsReservedBlobFilename(filename)) continue; - LocalStorage::FileEntry fe; - fe.filename = filename; - fe.sha = entry.sha; - fe.timestamp = entry.timestamp; - fe.rawSize = entry.size; - fe.deleted = false; - files.push_back(std::move(fe)); - } - - { - std::lock_guard<std::mutex> lock(g_fullManifestSentMutex); - g_fullManifestSentApps.insert(cacheKey); - g_cachedCloudCN[cacheKey] = cloudCN; - g_cachedAppBuildIdHwm[cacheKey] = appBuildIdHwm; - } - LOG("[NS-CL] GetAppFileChangelist app=%u: returning full manifest (%zu files) at CN=%llu (clientCN=%llu)", - appId, files.size(), cloudCN, clientChangeNumber); + for (const auto& [filename, entry] : cloudManifest) { + if (IsReservedBlobFilename(filename)) continue; + LocalStorage::FileEntry fe; + fe.filename = filename; + fe.sha = entry.sha; + fe.timestamp = entry.timestamp; + fe.rawSize = entry.size; + fe.deleted = false; + files.push_back(std::move(fe)); } + + LOG("[NS-CL] GetAppFileChangelist app=%u: returning full manifest (%zu files) at CN=%llu (clientCN=%llu)", + appId, files.size(), serverChangeNumber, clientChangeNumber); } } else { // No cloud manifest -- serve local files as delta (don't trigger reconcile-deletes) SetRpcCrashContext("GetChangelist:local-files", "Cloud.GetAppFileChangelist#1", appId); - if (bootstrapActive) { - LOG("[NS-CL] GetAppFileChangelist app=%u: bootstrap active, returning empty list to avoid UI freeze", appId); - files.clear(); - serverChangeNumber = 0; - // responseIsDelta stays true so Steam does not reconcile-delete. - } else { - files = LocalStorage::GetFileList(accountId, appId); - serverChangeNumber = LocalStorage::GetChangeNumber(accountId, appId); - // cloud active but fetch failed -> delta (don't delete unverified); cloud inactive -> authoritative - responseIsDelta = CloudStorage::IsCloudActive(); - - files.erase(std::remove_if(files.begin(), files.end(), - [](const LocalStorage::FileEntry& fe) { - return IsReservedBlobFilename(fe.filename); - }), files.end()); - } + files = LocalStorage::GetFileList(accountId, appId); + serverChangeNumber = LocalStorage::GetChangeNumber(accountId, appId); + // cloud active but fetch failed -> delta (don't delete unverified); cloud inactive -> authoritative + responseIsDelta = CloudStorage::IsCloudActive(); + + files.erase(std::remove_if(files.begin(), files.end(), + [](const LocalStorage::FileEntry& fe) { + return IsReservedBlobFilename(fe.filename); + }), files.end()); } LOG("[NS-CL] GetAppFileChangelist app=%u clientCN=%llu serverCN=%llu files=%zu", appId, clientChangeNumber, serverChangeNumber, files.size()); - // Cache result so repeat calls (CM reconnects) skip cloud I/O entirely. - // Don't cache placeholder bootstrap response (CN==0, empty files). - if (serverChangeNumber > 0 || !files.empty()) { - const uint64_t cacheKey = MakeAppAccountKey(accountId, appId); - std::lock_guard<std::mutex> lock(g_fullManifestSentMutex); - if (g_fullManifestSentApps.count(cacheKey) == 0) { - g_fullManifestSentApps.insert(cacheKey); - g_cachedCloudCN[cacheKey] = serverChangeNumber; - g_cachedAppBuildIdHwm[cacheKey] = appBuildIdHwm; - } - } - // build path_prefix table and file entries std::unordered_map<std::string, uint32_t> prefixMap; std::vector<std::string> prefixList; @@ -1434,14 +1323,12 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB rootTokens = it->second; } } - // Disk-only fallback (no cloud download yet); skip cache-write if worker straddles the read. + // Disk-only fallback (no cloud download yet). Tokens are persisted by the + // upload path; safe to cache directly (no async ingest worker to straddle). if (rootTokens.empty()) { SetRpcCrashContext("GetChangelist:root-token-disk", "Cloud.GetAppFileChangelist#1", appId); - bool bootstrapActiveBefore = AutoCloudBootstrap::IsActive(accountId, appId); rootTokens = LocalMetadataStore::LoadRootTokens(accountId, appId); - bool bootstrapActiveAfter = AutoCloudBootstrap::IsActive(accountId, appId); - bool bootstrapTouchedLoad = bootstrapActiveBefore || bootstrapActiveAfter; - if (!rootTokens.empty() && !bootstrapTouchedLoad) { + if (!rootTokens.empty()) { std::lock_guard<std::mutex> lock(g_rootTokenMutex); auto it = g_appRootTokens.find(appKey); if (it != g_appRootTokens.end()) { @@ -1449,9 +1336,6 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB } else { g_appRootTokens[appKey] = rootTokens; } - } else if (bootstrapTouchedLoad && !rootTokens.empty()) { - LOG("[NS-CL] Skipping root-token cache-write for account %u app %u " - "-- bootstrap worker was active during disk read", accountId, appId); } } // No disk tokens: skip cloud download for apps with no UFS rules. @@ -1496,22 +1380,13 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB } if (needsDiskLoad) { SetRpcCrashContext("GetChangelist:file-token-disk", "Cloud.GetAppFileChangelist#1", appId); - bool bootstrapActiveBefore = AutoCloudBootstrap::IsActive(accountId, appId); auto loaded = appHasUfsRules ? CloudStorage::LoadFileTokens(accountId, appId) : LocalMetadataStore::LoadFileTokens(accountId, appId); - bool bootstrapActiveAfter = AutoCloudBootstrap::IsActive(accountId, appId); - bool bootstrapTouchedLoad = bootstrapActiveBefore || bootstrapActiveAfter; std::lock_guard<std::mutex> lock(g_fileTokensMutex); auto it = g_fileTokens.find(appKey); if (it != g_fileTokens.end()) { fileTokenSnapshot = it->second; - } else if (bootstrapTouchedLoad) { - if (!loaded.empty()) { - fileTokenSnapshot = loaded; - } - LOG("[NS-CL] Skipping file-token cache-write for account %u app %u " - "-- bootstrap worker was active during disk read", accountId, appId); } else if (!loaded.empty()) { g_fileTokens[appKey] = std::move(loaded); LOG("[NS-CL] Loaded %zu file-token mappings for account %u app %u", @@ -1592,6 +1467,9 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB body.WriteVarint(1, serverChangeNumber); // current_change_number body.WriteVarint(3, responseIsDelta ? 1u : 0u); // is_only_delta + LOG("[NS-CL-WIRE] app=%u response: current_change_number=%llu is_only_delta=%u nfiles=%zu", + appId, (unsigned long long)serverChangeNumber, responseIsDelta ? 1u : 0u, prepared.size()); + // file entries (field 2, repeated) for (auto& pf : prepared) { PB::Writer fileSub; @@ -1615,6 +1493,12 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB fileSub.WriteVarint(7, pf.prefixIdx); // path_prefix_index fileSub.WriteVarint(8, 0); // machine_name_index body.WriteSubmessage(2, fileSub); + + LOG("[NS-CL-WIRE] file=%s deleted=%d persist_state=%u platforms=0x%X sha_len=%zu ts=%llu size=%llu prefix=%u%s", + pf.entry->filename.c_str(), pf.entry->deleted ? 1 : 0, persistState, platforms, + pf.entry->sha.size(), (unsigned long long)pf.entry->timestamp, + (unsigned long long)pf.entry->rawSize, pf.prefixIdx, + (cfeIt != cloudFileEntries.end()) ? " [from-cloud]" : " [no-cloud-entry]"); } // path_prefixes (field 4, repeated) @@ -2014,10 +1898,6 @@ RpcResult HandleLaunchIntent(uint32_t appId, const std::vector<PB::Field>& reqBo }).detach(); } - // Launch intent should not block on per-file bootstrap existence checks. - // GetAppFileChangelist already tolerates bootstrap running in the background. - AutoCloudBootstrap::Bootstrap(accountId, appId, /*wait=*/false); - PendingOpsJournal::Entry currentSession; currentSession.machineName = GetMachineName(); currentSession.timeLastUpdated = static_cast<uint32_t>(time(nullptr)); @@ -2179,8 +2059,6 @@ RpcResult HandleQuotaUsage(uint32_t appId, const std::vector<PB::Field>& reqBody return body; } - AutoCloudBootstrap::Bootstrap(accountId, appId); - // count from manifest, not blob cache -- blobs may be orphaned/stale auto manifest = CloudStorage::LoadLocalManifest(accountId, appId); size_t fileCount = 0; diff --git a/src/common/steam_kv_injector.cpp b/src/common/steam_kv_injector.cpp index 70575055..ce637014 100644 --- a/src/common/steam_kv_injector.cpp +++ b/src/common/steam_kv_injector.cpp @@ -18,6 +18,17 @@ namespace SteamKvInjector { +// Plausibility bounds for UFS quota; reject pointer-sized garbage reads. +static constexpr uint64_t kMaxPlausibleQuotaBytes = 1024ULL * 1024 * 1024 * 1024; // 1 TiB +static constexpr uint64_t kMaxPlausibleMaxFiles = 10ULL * 1000 * 1000; // 10M + +static bool QuotaValueLooksValid(uint64_t quota, uint64_t files) { + if (quota == 0 || files == 0) return false; + if (quota > kMaxPlausibleQuotaBytes) return false; + if (files > kMaxPlausibleMaxFiles) return false; + return true; +} + #ifdef _WIN32 // steamclient64.dll RVAs (IDA image base: 0x138000000) @@ -41,7 +52,7 @@ static constexpr uintptr_t SC_RVA_GET_APP_INFO = 0x49D920; static constexpr uintptr_t SC_RVA_GET_SECTION = 0x49FC50; // CAppInfoCache::ReadAppConfigUint64(cache, appId, sectionId, keyName, defaultVal) -static constexpr uintptr_t SC_RVA_READ_CONFIG_U64 = 0x49E930; +static constexpr uintptr_t SC_RVA_READ_CONFIG_U64 = 0x49E990; // BlockOnInit -- calls CThread::Join off-engine-thread, crashes/deadlocks. Do not call. // Cache is already loaded before our RPC handlers run. @@ -161,10 +172,21 @@ bool ReadAppQuota(uint32_t appId, uint64_t& outQuotaBytes, uint32_t& outMaxNumFi void* cache = GetCachePtr(); if (!cache) return false; - // ReadAppConfigUint64 returns 0 on any miss; non-zero means PICS populated it - outQuotaBytes = g_r.readConfigU64(cache, appId, kSectionUfs, "quota", 0); + uint64_t quota = g_r.readConfigU64(cache, appId, kSectionUfs, "quota", 0); uint64_t files = g_r.readConfigU64(cache, appId, kSectionUfs, "maxnumfiles", 0); - outMaxNumFiles = (files > 0 && files <= UINT32_MAX) ? static_cast<uint32_t>(files) : 0; + + if (!QuotaValueLooksValid(quota, files)) { + if (quota != 0 || files != 0) { + LOG("[KvInjector] ReadAppQuota app=%u: implausible quota=%llu files=%llu", + appId, (unsigned long long)quota, (unsigned long long)files); + } + outQuotaBytes = 0; + outMaxNumFiles = 0; + return true; + } + + outQuotaBytes = quota; + outMaxNumFiles = static_cast<uint32_t>(files); return true; } @@ -221,22 +243,27 @@ bool InjectAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles) { return false; } - // Avoid clobbering Steam's own PICS-sourced values: check existing - // values via the same Steam-wrapper used everywhere else. + // Preserve Steam's PICS values only if plausible; overwrite garbage. uint64_t existingQuota = g_r.readConfigU64(cache, appId, kSectionUfs, "quota", 0); uint64_t existingFiles = g_r.readConfigU64(cache, appId, kSectionUfs, "maxnumfiles", 0); + bool existingValid = QuotaValueLooksValid(existingQuota, existingFiles); bool wroteQuota = false; bool wroteFiles = false; - if (existingQuota == 0) { + bool quotaNeedsWrite = (existingQuota == 0) || + (existingQuota > kMaxPlausibleQuotaBytes) || !existingValid; + bool filesNeedsWrite = (existingFiles == 0) || + (existingFiles > kMaxPlausibleMaxFiles) || !existingValid; + + if (quotaNeedsWrite) { void* quotaKv = g_r.kvFindKey(ufs, "quota", 1, nullptr); if (quotaKv) { g_r.kvSetUint64(quotaKv, quotaBytes); wroteQuota = true; } } - if (existingFiles == 0) { + if (filesNeedsWrite) { void* filesKv = g_r.kvFindKey(ufs, "maxnumfiles", 1, nullptr); if (filesKv) { g_r.kvSetInt(filesKv, static_cast<int>(maxNumFiles)); @@ -258,6 +285,33 @@ bool InjectAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles) { return true; } +// Idempotently SET (not multiply) the live ufs quota/maxnumfiles to the given +// values, capped to plausible maxima. Used by the mixed-root rule-multiplier +// guard. Safe to call repeatedly with the same target. +bool SetAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles) { + if (!g_ready.load(std::memory_order_acquire)) return false; + if (quotaBytes == 0 || maxNumFiles == 0) return false; + + void* cache = GetCachePtr(); + if (!cache) return false; + void* appInfo = g_r.getAppInfo(cache, appId); + if (!appInfo) return false; + void* ufs = g_r.getSection(appInfo, kSectionUfs); + if (!ufs) return false; + + uint64_t newQuota = quotaBytes; + uint64_t newFiles = maxNumFiles; + if (newQuota > kMaxPlausibleQuotaBytes) newQuota = kMaxPlausibleQuotaBytes; + if (newFiles > kMaxPlausibleMaxFiles) newFiles = kMaxPlausibleMaxFiles; + + bool ok = false; + void* filesKv = g_r.kvFindKey(ufs, "maxnumfiles", 1, nullptr); + if (filesKv) { g_r.kvSetInt(filesKv, static_cast<int>(newFiles)); ok = true; } + void* quotaKv = g_r.kvFindKey(ufs, "quota", 1, nullptr); + if (quotaKv) { g_r.kvSetUint64(quotaKv, newQuota); ok = true; } + return ok; +} + bool InjectSaveFiles(uint32_t appId, const std::vector<SaveFileRule>& rules) { if (!g_ready.load(std::memory_order_acquire)) return false; if (rules.empty()) return false; @@ -737,9 +791,21 @@ bool ReadAppQuota(uint32_t appId, uint64_t& outQuotaBytes, uint32_t& outMaxNumFi void* cache = GetCachePtr(); if (!cache) return false; - outQuotaBytes = g_r.readConfigU64(cache, appId, kSectionUfs, "quota", 0); + uint64_t quota = g_r.readConfigU64(cache, appId, kSectionUfs, "quota", 0); uint64_t files = g_r.readConfigU64(cache, appId, kSectionUfs, "maxnumfiles", 0); - outMaxNumFiles = (files > 0 && files <= UINT32_MAX) ? static_cast<uint32_t>(files) : 0; + + if (!QuotaValueLooksValid(quota, files)) { + if (quota != 0 || files != 0) { + LOG("[KvInjector] ReadAppQuota app=%u: implausible quota=%llu files=%llu", + appId, (unsigned long long)quota, (unsigned long long)files); + } + outQuotaBytes = 0; + outMaxNumFiles = 0; + return true; + } + + outQuotaBytes = quota; + outMaxNumFiles = static_cast<uint32_t>(files); return true; } @@ -817,15 +883,20 @@ bool InjectAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles) { return false; } - // Use the same Steam wrapper to read existing values; if non-zero Steam's - // own PICS data is already in place and we should not clobber it. + // Preserve Steam's PICS values only if plausible; overwrite garbage. uint64_t existingQuota = g_r.readConfigU64(cache, appId, kSectionUfs, "quota", 0); uint64_t existingFiles = g_r.readConfigU64(cache, appId, kSectionUfs, "maxnumfiles", 0); + bool existingValid = QuotaValueLooksValid(existingQuota, existingFiles); bool wroteQuota = false; bool wroteFiles = false; - if (existingQuota == 0) { + bool quotaNeedsWrite = (existingQuota == 0) || + (existingQuota > kMaxPlausibleQuotaBytes) || !existingValid; + bool filesNeedsWrite = (existingFiles == 0) || + (existingFiles > kMaxPlausibleMaxFiles) || !existingValid; + + if (quotaNeedsWrite) { void* quotaKv = g_r.kvFindKey(ufs, "quota", 1, nullptr); if (quotaKv) { uint32_t lo = static_cast<uint32_t>(quotaBytes & 0xFFFFFFFFu); @@ -834,7 +905,7 @@ bool InjectAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles) { wroteQuota = true; } } - if (existingFiles == 0) { + if (filesNeedsWrite) { void* filesKv = g_r.kvFindKey(ufs, "maxnumfiles", 1, nullptr); if (filesKv) { g_r.kvSetInt32(filesKv, static_cast<int32_t>(maxNumFiles)); @@ -856,6 +927,38 @@ bool InjectAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles) { return true; } +// Idempotently SET (not multiply) the live ufs quota/maxnumfiles to the given +// values, capped to plausible maxima. Used by the mixed-root rule-multiplier +// guard. Safe to call repeatedly with the same target. +bool SetAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles) { + if (!g_ready.load(std::memory_order_acquire)) return false; + if (quotaBytes == 0 || maxNumFiles == 0) return false; + + void* cache = GetCachePtr(); + if (!cache) return false; + void* appInfo = ResolveAppInfo(cache, appId); + if (!appInfo) return false; + void* ufs = g_r.getSection(appInfo, kSectionUfs); + if (!ufs) return false; + + uint64_t newQuota = quotaBytes; + uint64_t newFiles = maxNumFiles; + if (newQuota > kMaxPlausibleQuotaBytes) newQuota = kMaxPlausibleQuotaBytes; + if (newFiles > kMaxPlausibleMaxFiles) newFiles = kMaxPlausibleMaxFiles; + + bool ok = false; + void* filesKv = g_r.kvFindKey(ufs, "maxnumfiles", 1, nullptr); + if (filesKv) { g_r.kvSetInt32(filesKv, static_cast<int32_t>(newFiles)); ok = true; } + void* quotaKv = g_r.kvFindKey(ufs, "quota", 1, nullptr); + if (quotaKv) { + g_r.kvSetUint64(quotaKv, + static_cast<uint32_t>(newQuota & 0xFFFFFFFFu), + static_cast<uint32_t>((newQuota >> 32) & 0xFFFFFFFFu)); + ok = true; + } + return ok; +} + bool InjectSaveFiles(uint32_t appId, const std::vector<SaveFileRule>& rules) { if (!g_ready.load(std::memory_order_acquire)) return false; if (rules.empty()) return false; diff --git a/src/common/steam_kv_injector.h b/src/common/steam_kv_injector.h index 351b3f8d..2c1d6be7 100644 --- a/src/common/steam_kv_injector.h +++ b/src/common/steam_kv_injector.h @@ -24,6 +24,10 @@ bool TriggerPicsAndWait(uint32_t appId, // Write quota/maxnumfiles into KV. Won't clobber existing non-zero values. bool InjectAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles); +// Idempotently SET the live ufs quota/maxnumfiles (capped to plausible maxima). +// Used by the mixed-root rule-multiplier guard; safe to call repeatedly. +bool SetAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles); + // A single AutoCloud save-file rule for KV injection. struct SaveFileRule { std::string root; // e.g. "WinAppDataLocal" diff --git a/src/platform/linux/init.cpp b/src/platform/linux/init.cpp index 9aceaa31..80148c73 100644 --- a/src/platform/linux/init.cpp +++ b/src/platform/linux/init.cpp @@ -463,8 +463,7 @@ static void OnUnload() } } - // Write final sync icon states to registry.vdf (last-write-wins after - // Steam's PosixRegistryManager has done its final in-memory flush). + // No-op (sync-icon state writer was never wired up); kept for contract. CloudIntercept::FlushPendingSyncStates(); // Shut down cloud storage (signals workers, drains queue with timeout) diff --git a/src/platform/win/cloud760_tool.cpp b/src/platform/win/cloud760_tool.cpp new file mode 100644 index 00000000..8d946464 --- /dev/null +++ b/src/platform/win/cloud760_tool.cpp @@ -0,0 +1,351 @@ +// cloud760_tool.exe - view/delete Steam Cloud files for a single AppID (default 760). +// +// Sets SteamAppId, inits the Steamworks API as that AppID, and uses +// ISteamRemoteStorage to list/delete cloud files. Mainly for AppID 760/480, the +// shared namespaces SteamTools dumps saves into. +// +// Flat C exports are resolved from a bundled 32-bit steam_api.dll at runtime +// (Steamworks.NET 5.0.0 era, SDK ~1.39), so this builds as x86 to match. + +#define WIN32_LEAN_AND_MEAN +#include <Windows.h> +#include <cstdio> +#include <cstdint> +#include <cstring> +#include <cstdlib> +#include <string> +#include <vector> + +// ── Flat Steamworks API typedefs (subset we use) ─────────────────────── +// Signatures match steam_api_flat.h from the Steamworks SDK. +typedef int32_t HSteamUser; +typedef int32_t HSteamPipe; +using ISteamRemoteStorage = void; + +typedef bool (__cdecl* SteamAPI_Init_t)(); +typedef void (__cdecl* SteamAPI_Shutdown_t)(); +typedef HSteamUser (__cdecl* SteamAPI_GetHSteamUser_t)(); +typedef HSteamPipe (__cdecl* SteamAPI_GetHSteamPipe_t)(); +// Old-style interface acquisition (matches the bundled steam_api.dll, which +// predates SteamInternal_FindOrCreateUserInterface): +// SteamClient() -> ISteamClient* ; then GetISteamRemoteStorage(client, user, pipe, ver). +typedef void* (__cdecl* SteamClient_t)(); +typedef void* (__cdecl* SteamAPI_ISteamClient_GetISteamRemoteStorage_t)(void* client, HSteamUser, HSteamPipe, const char*); + +typedef int32_t (__cdecl* RS_GetFileCount_t)(ISteamRemoteStorage*); +typedef const char* (__cdecl* RS_GetFileNameAndSize_t)(ISteamRemoteStorage*, int32_t, int32_t*); +typedef int32_t (__cdecl* RS_GetFileSize_t)(ISteamRemoteStorage*, const char*); +typedef int64_t (__cdecl* RS_GetFileTimestamp_t)(ISteamRemoteStorage*, const char*); +typedef bool (__cdecl* RS_FileExists_t)(ISteamRemoteStorage*, const char*); +typedef bool (__cdecl* RS_FilePersisted_t)(ISteamRemoteStorage*, const char*); +typedef bool (__cdecl* RS_FileDelete_t)(ISteamRemoteStorage*, const char*); +typedef bool (__cdecl* RS_FileForget_t)(ISteamRemoteStorage*, const char*); +typedef bool (__cdecl* RS_IsCloudEnabledForAccount_t)(ISteamRemoteStorage*); +typedef bool (__cdecl* RS_IsCloudEnabledForApp_t)(ISteamRemoteStorage*); +typedef bool (__cdecl* RS_GetQuota_t)(ISteamRemoteStorage*, uint64_t*, uint64_t*); + +static const char* REMOTESTORAGE_VERSION = "STEAMREMOTESTORAGE_INTERFACE_VERSION014"; + +struct SteamApi { + HMODULE mod = nullptr; + SteamAPI_Init_t Init = nullptr; + SteamAPI_Shutdown_t Shutdown = nullptr; + SteamAPI_GetHSteamUser_t GetHSteamUser = nullptr; + SteamAPI_GetHSteamPipe_t GetHSteamPipe = nullptr; + SteamClient_t SteamClient = nullptr; + SteamAPI_ISteamClient_GetISteamRemoteStorage_t GetISteamRemoteStorage = nullptr; + + RS_GetFileCount_t GetFileCount = nullptr; + RS_GetFileNameAndSize_t GetFileNameAndSize = nullptr; + RS_GetFileSize_t GetFileSize = nullptr; + RS_GetFileTimestamp_t GetFileTimestamp = nullptr; + RS_FileExists_t FileExists = nullptr; + RS_FilePersisted_t FilePersisted = nullptr; + RS_FileDelete_t FileDelete = nullptr; + RS_FileForget_t FileForget = nullptr; + RS_IsCloudEnabledForAccount_t IsCloudEnabledForAccount = nullptr; + RS_IsCloudEnabledForApp_t IsCloudEnabledForApp = nullptr; + RS_GetQuota_t GetQuota = nullptr; + + ISteamRemoteStorage* rs = nullptr; +}; + +template <typename T> +static bool resolve(HMODULE m, const char* name, T& out) { + out = reinterpret_cast<T>(GetProcAddress(m, name)); + if (!out) { + fprintf(stderr, "Error: missing export %s in steam_api.dll\n", name); + return false; + } + return true; +} + +// Load the bundled steam_api.dll. Exactly like SteamCloudFileManagerLite: the +// DLL ships next to the exe, so we just LoadLibrary it by name (LoadLibrary +// resolves relative to the exe's directory first). No installed game required, +// no game-folder search -- that was the source of the "could not find" errors. +static HMODULE LoadSteamApiDll() { + // Pin the search to our own directory so we always load the bundled copy and + // never a stray steam_api.dll from PATH / the working directory. + char exeDir[MAX_PATH] = {}; + DWORD n = GetModuleFileNameA(nullptr, exeDir, sizeof(exeDir)); + if (n > 0 && n < sizeof(exeDir)) { + char* slash = strrchr(exeDir, '\\'); + if (slash) { + *(slash + 1) = '\0'; + std::string full = std::string(exeDir) + "steam_api.dll"; + HMODULE m = LoadLibraryA(full.c_str()); + if (m) return m; + } + } + // Fallback: default search order (cwd / PATH). + return LoadLibraryA("steam_api.dll"); +} + +static bool LoadSteamApi(SteamApi& api) { + api.mod = LoadSteamApiDll(); + if (!api.mod) { + fprintf(stderr, + "Error: could not load steam_api.dll.\n" + "It should ship next to this tool.\n"); + return false; + } + + bool ok = true; + ok &= resolve(api.mod, "SteamAPI_Init", api.Init); + ok &= resolve(api.mod, "SteamAPI_Shutdown", api.Shutdown); + ok &= resolve(api.mod, "SteamAPI_GetHSteamUser", api.GetHSteamUser); + ok &= resolve(api.mod, "SteamAPI_GetHSteamPipe", api.GetHSteamPipe); + ok &= resolve(api.mod, "SteamClient", api.SteamClient); + ok &= resolve(api.mod, "SteamAPI_ISteamClient_GetISteamRemoteStorage", api.GetISteamRemoteStorage); + + ok &= resolve(api.mod, "SteamAPI_ISteamRemoteStorage_GetFileCount", api.GetFileCount); + ok &= resolve(api.mod, "SteamAPI_ISteamRemoteStorage_GetFileNameAndSize", api.GetFileNameAndSize); + ok &= resolve(api.mod, "SteamAPI_ISteamRemoteStorage_GetFileSize", api.GetFileSize); + ok &= resolve(api.mod, "SteamAPI_ISteamRemoteStorage_GetFileTimestamp", api.GetFileTimestamp); + ok &= resolve(api.mod, "SteamAPI_ISteamRemoteStorage_FileExists", api.FileExists); + ok &= resolve(api.mod, "SteamAPI_ISteamRemoteStorage_FilePersisted", api.FilePersisted); + ok &= resolve(api.mod, "SteamAPI_ISteamRemoteStorage_FileDelete", api.FileDelete); + ok &= resolve(api.mod, "SteamAPI_ISteamRemoteStorage_FileForget", api.FileForget); + ok &= resolve(api.mod, "SteamAPI_ISteamRemoteStorage_IsCloudEnabledForAccount", api.IsCloudEnabledForAccount); + ok &= resolve(api.mod, "SteamAPI_ISteamRemoteStorage_IsCloudEnabledForApp", api.IsCloudEnabledForApp); + ok &= resolve(api.mod, "SteamAPI_ISteamRemoteStorage_GetQuota", api.GetQuota); + return ok; +} + +// Connect to the running Steam client as `appId`. Mirrors RemoteStorage.cs. +static bool Connect(SteamApi& api, uint32_t appId) { + char appIdStr[16]; + snprintf(appIdStr, sizeof(appIdStr), "%u", appId); + SetEnvironmentVariableA("SteamAppId", appIdStr); + SetEnvironmentVariableA("SteamAppID", appIdStr); // both casings, like the reference tool + SetEnvironmentVariableA("SteamGameId", appIdStr); + + bool init = api.Init(); + if (!init) { + // Fallback: steam_appid.txt in the working directory. + FILE* f = nullptr; + if (fopen_s(&f, "steam_appid.txt", "wb") == 0 && f) { + fwrite(appIdStr, 1, strlen(appIdStr), f); + fclose(f); + init = api.Init(); + DeleteFileA("steam_appid.txt"); + } + } + if (!init) { + fprintf(stderr, + "Error: SteamAPI_Init failed for AppID %u.\n" + "Make sure Steam is running and you are logged in.\n", appId); + return false; + } + + // Old-style interface acquisition, matching the bundled steam_api.dll and + // Steamworks.NET 5.0.0: get the global ISteamClient, then ask it for the + // RemoteStorage interface bound to our user+pipe. + void* client = api.SteamClient(); + if (!client) { + fprintf(stderr, "Error: SteamClient() returned null.\n"); + return false; + } + HSteamUser hUser = api.GetHSteamUser(); + HSteamPipe hPipe = api.GetHSteamPipe(); + api.rs = api.GetISteamRemoteStorage(client, hUser, hPipe, REMOTESTORAGE_VERSION); + if (!api.rs) { + fprintf(stderr, "Error: could not obtain ISteamRemoteStorage (%s).\n", REMOTESTORAGE_VERSION); + return false; + } + return true; +} + +// When porcelain is on, every command emits stable tab-separated lines that the +// UI parses (instead of the human-friendly tables). Schema: +// QUOTA<TAB>total<TAB>used +// CLOUD<TAB>account(0/1)<TAB>app(0/1) +// FILE<TAB>name<TAB>size<TAB>persisted(0/1) +// DEL<TAB>name<TAB>OK|FAIL +// COUNT<TAB>n +// All porcelain lines go to stdout; errors still go to stderr. +static void PrintCloudStatus(SteamApi& api, uint32_t appId, bool porcelain) { + bool acct = api.IsCloudEnabledForAccount(api.rs); + bool app = api.IsCloudEnabledForApp(api.rs); + uint64_t total = 0, avail = 0; + bool haveQuota = api.GetQuota(api.rs, &total, &avail); + + if (porcelain) { + printf("CLOUD\t%d\t%d\n", acct ? 1 : 0, app ? 1 : 0); + if (haveQuota) + printf("QUOTA\t%llu\t%llu\n", + (unsigned long long)total, + (unsigned long long)(total - avail)); + return; + } + + printf("AppID %u cloud(account)=%s cloud(app)=%s\n", + appId, acct ? "on" : "off", app ? "on" : "off"); + if (haveQuota) { + printf("Quota: %llu / %llu bytes used (%.1f%%)\n", + (unsigned long long)(total - avail), (unsigned long long)total, + total ? (double)(total - avail) * 100.0 / (double)total : 0.0); + } +} + +static int CmdList(SteamApi& api, uint32_t appId, bool porcelain) { + PrintCloudStatus(api, appId, porcelain); + int count = api.GetFileCount(api.rs); + if (porcelain) { + printf("COUNT\t%d\n", count); + for (int i = 0; i < count; ++i) { + int32_t size = 0; + const char* name = api.GetFileNameAndSize(api.rs, i, &size); + if (!name) name = ""; + bool persisted = api.FilePersisted(api.rs, name); + printf("FILE\t%s\t%d\t%d\n", name, size, persisted ? 1 : 0); + } + return 0; + } + printf("\n%d cloud file(s) for AppID %u:\n", count, appId); + printf("%-50s %12s %s\n", "NAME", "SIZE", "PERSISTED"); + for (int i = 0; i < count; ++i) { + int32_t size = 0; + const char* name = api.GetFileNameAndSize(api.rs, i, &size); + if (!name) name = "(null)"; + bool persisted = api.FilePersisted(api.rs, name); + printf("%-50s %12d %s\n", name, size, persisted ? "yes" : "no"); + } + return 0; +} + +static int CmdQuota(SteamApi& api, uint32_t appId, bool porcelain) { + PrintCloudStatus(api, appId, porcelain); + return 0; +} + +static int CmdDelete(SteamApi& api, uint32_t appId, const std::vector<std::string>& names, bool porcelain) { + int failures = 0; + for (const auto& n : names) { + bool ok = api.FileDelete(api.rs, n.c_str()); + // FileForget stops it from re-syncing back from any local cache. + api.FileForget(api.rs, n.c_str()); + if (porcelain) + printf("DEL\t%s\t%s\n", n.c_str(), ok ? "OK" : "FAIL"); + else + printf("delete %-50s %s\n", n.c_str(), ok ? "OK" : "FAILED"); + if (!ok) ++failures; + } + if (!porcelain) + printf("\nDeleted %zu file(s), %d failure(s) for AppID %u.\n", + names.size() - failures, failures, appId); + return failures ? 1 : 0; +} + +static int CmdDeleteAll(SteamApi& api, uint32_t appId, bool assumeYes, bool porcelain) { + int count = api.GetFileCount(api.rs); + std::vector<std::string> names; + for (int i = 0; i < count; ++i) { + int32_t size = 0; + const char* name = api.GetFileNameAndSize(api.rs, i, &size); + if (name) names.emplace_back(name); + } + + if (names.empty()) { + if (!porcelain) printf("No cloud files for AppID %u; nothing to delete.\n", appId); + return 0; + } + + if (!porcelain) { + printf("About to delete %zu file(s) from AppID %u cloud:\n", names.size(), appId); + for (const auto& n : names) printf(" %s\n", n.c_str()); + + if (!assumeYes) { + printf("\nType 'yes' to confirm: "); + char line[16] = {}; + if (!fgets(line, sizeof(line), stdin) || + (strncmp(line, "yes", 3) != 0)) { + printf("Aborted.\n"); + return 1; + } + } + } + return CmdDelete(api, appId, names, porcelain); +} + +static void Usage() { + fprintf(stderr, + "cloud760_tool - view/delete Steam Cloud files for an AppID (default 760)\n\n" + "Usage:\n" + " cloud760_tool list [appid]\n" + " cloud760_tool quota [appid]\n" + " cloud760_tool delete [appid] <file> [<file> ...]\n" + " cloud760_tool delete-all [appid] [--yes]\n\n" + "Global: --porcelain emit tab-separated machine output for the UI.\n" + "Steam must be running and logged in. steam_api.dll must be next to this exe.\n"); +} + +// Parse an optional leading appid token; default 760. Returns remaining args. +static uint32_t ParseAppId(std::vector<std::string>& args, uint32_t def) { + if (!args.empty()) { + char* end = nullptr; + unsigned long v = strtoul(args[0].c_str(), &end, 10); + if (end && *end == '\0' && v > 0) { + args.erase(args.begin()); + return (uint32_t)v; + } + } + return def; +} + +int main(int argc, char** argv) { + if (argc < 2) { Usage(); return 2; } + + std::string cmd = argv[1]; + std::vector<std::string> rest; + bool assumeYes = false; + bool porcelain = false; + for (int i = 2; i < argc; ++i) { + if (strcmp(argv[i], "--yes") == 0 || strcmp(argv[i], "-y") == 0) + assumeYes = true; + else if (strcmp(argv[i], "--porcelain") == 0) + porcelain = true; + else + rest.emplace_back(argv[i]); + } + + uint32_t appId = ParseAppId(rest, 760); + + SteamApi api; + if (!LoadSteamApi(api)) return 1; + if (!Connect(api, appId)) { api.Shutdown(); return 1; } + + int rc = 2; + if (cmd == "list") rc = CmdList(api, appId, porcelain); + else if (cmd == "quota") rc = CmdQuota(api, appId, porcelain); + else if (cmd == "delete") { + if (rest.empty()) { fprintf(stderr, "Error: 'delete' needs at least one filename.\n"); rc = 2; } + else rc = CmdDelete(api, appId, rest, porcelain); + } + else if (cmd == "delete-all") rc = CmdDeleteAll(api, appId, assumeYes, porcelain); + else { Usage(); rc = 2; } + + api.Shutdown(); + return rc; +} diff --git a/ui/CloudRedirect.csproj b/ui/CloudRedirect.csproj index 255082ca..90c33f75 100644 --- a/ui/CloudRedirect.csproj +++ b/ui/CloudRedirect.csproj @@ -65,4 +65,16 @@ </EmbeddedResource> </ItemGroup> + <!-- 32-bit Steam Cloud manager tool + its steam_api.dll, EMBEDDED into the + single-file exe so we ship one file only. They are extracted side by side + to a temp dir on demand (see EmbeddedCloud760) and the 64-bit UI spawns the + 32-bit tool from there. This is the same steam_api.dll that + SteamCloudFileManagerLite ships. --> + <ItemGroup> + <None Remove="native\cloud760_tool.exe" /> + <None Remove="native\steam_api.dll" /> + <EmbeddedResource Include="native\cloud760_tool.exe" LogicalName="cloud760_tool.exe" /> + <EmbeddedResource Include="native\steam_api.dll" LogicalName="steam_api.dll" /> + </ItemGroup> + </Project> diff --git a/ui/Converters/UrlToImageSourceConverter.cs b/ui/Converters/UrlToImageSourceConverter.cs index 40072bf0..51d3ebef 100644 --- a/ui/Converters/UrlToImageSourceConverter.cs +++ b/ui/Converters/UrlToImageSourceConverter.cs @@ -86,11 +86,22 @@ public sealed class UrlToImageSourceConverter : IValueConverter { var bitmap = new BitmapImage(); bitmap.BeginInit(); + + // Decode to roughly display size instead of full resolution. App header + // art is ~460x215 on the CDN but shown at 92x43; decoding the full image + // for every card is the main source of list stutter. ConverterParameter + // overrides the target width; default 184 (2x the 92px slot for crispness + // on high-DPI). Only applied to file:// (fully-decoded) images. + int decodeWidth = 184; + if (parameter is string ps && int.TryParse(ps, out var pw) && pw > 0) + decodeWidth = pw; + if (uri.IsFile) { // Local cache file: decode now, release the handle, freeze so // eviction + atomic File.Move(overwrite: true) aren't blocked. bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.DecodePixelWidth = decodeWidth; } // HTTP: leave CacheOption at Default so the download streams in // the background. Intentionally NOT frozen -- an unfinished diff --git a/ui/MainWindow.xaml b/ui/MainWindow.xaml index 11825393..e2efe863 100644 --- a/ui/MainWindow.xaml +++ b/ui/MainWindow.xaml @@ -106,7 +106,7 @@ <ui:NavigationViewItem x:Name="NavCleanup" Icon="{ui:SymbolIcon Delete24}" Content="{res:Loc Nav_Cleanup}" - TargetPageType="{x:Type pages:CleanupPage}" /> + TargetPageType="{x:Type pages:Cloud760Page}" /> <ui:NavigationViewItem Content="{res:Loc Nav_ManifestPinning}" Icon="{ui:SymbolIcon Pin24}" TargetPageType="{x:Type pages:ManifestPinningPage}" /> diff --git a/ui/Pages/AppsPage.xaml b/ui/Pages/AppsPage.xaml index bf8bc7ec..d0ffaea9 100644 --- a/ui/Pages/AppsPage.xaml +++ b/ui/Pages/AppsPage.xaml @@ -3,29 +3,53 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" xmlns:res="clr-namespace:CloudRedirect.Resources" - xmlns:conv="clr-namespace:CloudRedirect.Converters" - ScrollViewer.CanContentScroll="False"> + xmlns:conv="clr-namespace:CloudRedirect.Converters"> <Page.Resources> <BooleanToVisibilityConverter x:Key="BoolToVis" /> <conv:UrlToImageSourceConverter x:Key="UrlToImageSource" /> + + <!-- Card list items: no ListBox selection/hover chrome, stretch wide. --> + <Style x:Key="CardItemStyle" TargetType="ListBoxItem"> + <Setter Property="HorizontalContentAlignment" Value="Stretch" /> + <Setter Property="Padding" Value="0" /> + <Setter Property="Margin" Value="0" /> + <Setter Property="Background" Value="Transparent" /> + <Setter Property="BorderThickness" Value="0" /> + <Setter Property="Focusable" Value="False" /> + <Setter Property="Template"> + <Setter.Value> + <ControlTemplate TargetType="ListBoxItem"> + <ContentPresenter /> + </ControlTemplate> + </Setter.Value> + </Setter> + </Style> </Page.Resources> - <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="24"> - <StackPanel> + <!-- Header + action bar fixed on top; a pixel-virtualizing ListBox fills the + rest and owns the only scrollbar. Pixel scroll keeps the mouse wheel + working over cards while still UI-virtualizing (only visible cards are + realized / images decoded), which kills the navigate-to-Apps lag. --> + <DockPanel Margin="24" MaxWidth="800" HorizontalAlignment="Left"> + + <TextBlock DockPanel.Dock="Top" + Text="{res:Loc Apps_Title}" + FontSize="28" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,0,0,8" /> - <TextBlock Text="{res:Loc Apps_Title}" - FontSize="28" FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" - Margin="0,0,0,8" /> + <TextBlock DockPanel.Dock="Top" + Text="{res:Loc Apps_Description}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + Margin="0,0,0,16" /> - <TextBlock Text="{res:Loc Apps_Description}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - Margin="0,0,0,16" /> + <!-- app-list mode and restore mode overlay here, one visible --> + <Grid> - <StackPanel x:Name="AppListPanel"> + <DockPanel x:Name="AppListPanel"> - <WrapPanel x:Name="ActionBar" Margin="0,0,0,16"> + <WrapPanel x:Name="ActionBar" DockPanel.Dock="Top" Margin="0,0,0,16"> <ui:Button x:Name="RestoreSavesButton" Content="{res:Loc Apps_RestoreSaves}" Icon="{ui:SymbolIcon ArrowUndo24}" @@ -47,8 +71,17 @@ VerticalAlignment="Center" /> </WrapPanel> - <ItemsControl x:Name="AppList"> - <ItemsControl.ItemTemplate> + <ListBox x:Name="AppList" + BorderThickness="0" + Background="Transparent" + Padding="0" + ItemContainerStyle="{StaticResource CardItemStyle}" + ScrollViewer.HorizontalScrollBarVisibility="Disabled" + ScrollViewer.CanContentScroll="True" + VirtualizingPanel.IsVirtualizing="True" + VirtualizingPanel.VirtualizationMode="Recycling" + VirtualizingPanel.ScrollUnit="Pixel"> + <ListBox.ItemTemplate> <DataTemplate> <StackPanel Margin="0,0,0,8"> <ui:CardControl> @@ -108,7 +141,7 @@ <StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center"> - <ui:Button Content="{res:Loc Apps_ReviewOrphans}" + <ui:Button Content="{Binding OrphansToggleText}" Appearance="Caution" VerticalAlignment="Center" Click="OrphansToggle_Click" @@ -137,7 +170,7 @@ Prune button pokes past the card's right edge and, when the page's vertical scrollbar appears, past the viewport. --> - <StackPanel Visibility="Collapsed" + <StackPanel Visibility="{Binding OrphansDetailVisibility}" Margin="16,8,16,0"> <Grid Margin="0,0,0,8"> <Grid.ColumnDefinitions> @@ -166,17 +199,17 @@ </DataTemplate> </ItemsControl.ItemTemplate> </ItemsControl> - </StackPanel> - </StackPanel> - </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> + </StackPanel> + </StackPanel> + </DataTemplate> + </ListBox.ItemTemplate> + </ListBox> - </StackPanel> + </DockPanel> - <StackPanel x:Name="RestorePanel" Visibility="Collapsed"> + <DockPanel x:Name="RestorePanel" Visibility="Collapsed"> - <WrapPanel Margin="0,0,0,16"> + <WrapPanel DockPanel.Dock="Top" Margin="0,0,0,16"> <ui:Button x:Name="BackButton" Content="{res:Loc Apps_Back}" Icon="{ui:SymbolIcon ArrowLeft24}" @@ -198,12 +231,14 @@ VerticalAlignment="Center" /> </WrapPanel> - <TextBlock Text="{res:Loc Apps_RestoreDescription}" + <TextBlock DockPanel.Dock="Top" + Text="{res:Loc Apps_RestoreDescription}" Foreground="{DynamicResource TextFillColorTertiaryBrush}" TextWrapping="Wrap" Margin="0,0,0,16" /> <StackPanel x:Name="RestoreLoadingPanel" + DockPanel.Dock="Top" Visibility="Collapsed" HorizontalAlignment="Center" Margin="0,40,0,40"> @@ -213,10 +248,13 @@ HorizontalAlignment="Center" /> </StackPanel> - <StackPanel x:Name="BackupListPanel" /> + <ScrollViewer VerticalScrollBarVisibility="Auto" + HorizontalScrollBarVisibility="Disabled"> + <StackPanel x:Name="BackupListPanel" /> + </ScrollViewer> - </StackPanel> + </DockPanel> - </StackPanel> - </ScrollViewer> + </Grid> + </DockPanel> </Page> diff --git a/ui/Pages/AppsPage.xaml.cs b/ui/Pages/AppsPage.xaml.cs index 10be11dd..d048802f 100644 --- a/ui/Pages/AppsPage.xaml.cs +++ b/ui/Pages/AppsPage.xaml.cs @@ -135,13 +135,15 @@ private async Task LoadAppsAsync() if (apps == null || apps.Count == 0) { - _allApps = apps; - AppList.ItemsSource = apps ?? new List<AppInfo>(); + _allApps = apps ?? new List<AppInfo>(); + _appsView = null; // force rebind to the new (empty) source + ApplyAppFilter(); return; } // Show the list immediately with app IDs while we fetch names _allApps = apps; + _appsView = null; // rebind view to the freshly loaded source ApplyAppFilter(); // Fetch names + header images from Steam store API (batch, cached) @@ -181,25 +183,35 @@ private void AppSearchBox_TextChanged(object sender, TextChangedEventArgs e) ApplyAppFilter(); } + private System.Windows.Data.ListCollectionView? _appsView; + private void ApplyAppFilter() { if (_allApps == null) return; - var query = AppSearchBox?.Text?.Trim() ?? ""; - if (string.IsNullOrEmpty(query)) + // Live-filtered CollectionView so search + scan updates refresh the + // existing virtualized containers instead of rebuilding every card + // (which re-decodes every header image and causes the stutter). + if (_appsView == null || _appsView.SourceCollection != (System.Collections.IEnumerable)_allApps) { - AppList.ItemsSource = null; - AppList.ItemsSource = _allApps; - return; + _appsView = (System.Windows.Data.ListCollectionView) + System.Windows.Data.CollectionViewSource.GetDefaultView(_allApps); + _appsView.Filter = AppFilter; + AppList.ItemsSource = _appsView; } + else + { + _appsView.Refresh(); + } + } - var filtered = _allApps - .Where(a => a.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) - || a.AppId.Contains(query, StringComparison.OrdinalIgnoreCase)) - .ToList(); - - AppList.ItemsSource = null; - AppList.ItemsSource = filtered; + private bool AppFilter(object item) + { + var query = AppSearchBox?.Text?.Trim() ?? ""; + if (string.IsNullOrEmpty(query)) return true; + if (item is not AppInfo a) return false; + return a.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) + || a.AppId.Contains(query, StringComparison.OrdinalIgnoreCase); } private void RestoreSearchBox_TextChanged(object sender, TextChangedEventArgs e) @@ -444,40 +456,11 @@ await Dialog.ShowInfoAsync(S.Get("Apps_DeletedTitle"), /// <summary>Per-card Review/Collapse toggle for the orphan detail panel.</summary> private void OrphansToggle_Click(object sender, RoutedEventArgs e) { - if (sender is not System.Windows.Controls.Primitives.ButtonBase btn) return; - - // Walk up to the outer DataTemplate-root StackPanel (the one whose - // first child is the CardControl and whose second child is the - // orphans detail StackPanel). - DependencyObject? cur = btn; - StackPanel? outer = null; - while (cur != null) - { - cur = System.Windows.Media.VisualTreeHelper.GetParent(cur); - if (cur is StackPanel sp && - sp.Children.Count == 2 && - sp.Children[0] is Wpf.Ui.Controls.CardControl && - sp.Children[1] is StackPanel) - { - outer = sp; - break; - } - } - if (outer == null) return; - if (outer.Children[1] is not StackPanel detail) return; - - if (detail.Visibility == Visibility.Collapsed) - { - detail.Visibility = Visibility.Visible; - btn.SetValue(System.Windows.Controls.ContentControl.ContentProperty, - S.Get("Apps_CollapseOrphans")); - } - else - { - detail.Visibility = Visibility.Collapsed; - btn.SetValue(System.Windows.Controls.ContentControl.ContentProperty, - S.Get("Apps_ReviewOrphans")); - } + // Data-bound toggle: flipping the model updates the detail panel + // visibility and button text via bindings. Safe under virtualization + // (no visual-tree walking, which recycling would break). + if (sender is FrameworkElement { DataContext: AppInfo app }) + app.OrphansExpanded = !app.OrphansExpanded; } /// <summary> @@ -1085,7 +1068,7 @@ private class DeletionTargets } -public class AppInfo +public class AppInfo : System.ComponentModel.INotifyPropertyChanged { public string AppId { get; set; } = ""; public string AccountId { get; set; } = ""; @@ -1096,17 +1079,48 @@ public class AppInfo /// <summary>Game name from Steam store API. Falls back to "App {AppId}" if unavailable.</summary> public string DisplayName => !string.IsNullOrEmpty(Name) ? Name : S.Format("Apps_AppFallbackName", AppId); - public string Name { get; set; } = ""; + + private string _name = ""; + public string Name + { + get => _name; + set { _name = value; Notify(nameof(Name)); Notify(nameof(DisplayName)); } + } /// <summary>Header image URL (292x136) from Steam CDN, or null.</summary> - public string? HeaderUrl { get; set; } + private string? _headerUrl; + public string? HeaderUrl + { + get => _headerUrl; + set { _headerUrl = value; Notify(nameof(HeaderUrl)); } + } /// <summary>True if the last scan completed and returned orphans.</summary> - public bool HasOrphans { get; set; } + private bool _hasOrphans; + public bool HasOrphans + { + get => _hasOrphans; + set { _hasOrphans = value; Notify(nameof(HasOrphans)); } + } /// <summary>Localized summary shown in the expander header.</summary> public string OrphanSummary { get; set; } = ""; + /// <summary>Whether the orphan detail panel is expanded (data-bound so the + /// virtualized list stays correct under container recycling).</summary> + private bool _orphansExpanded; + public bool OrphansExpanded + { + get => _orphansExpanded; + set { _orphansExpanded = value; Notify(nameof(OrphansExpanded)); Notify(nameof(OrphansDetailVisibility)); Notify(nameof(OrphansToggleText)); } + } + + public System.Windows.Visibility OrphansDetailVisibility => + _orphansExpanded ? System.Windows.Visibility.Visible : System.Windows.Visibility.Collapsed; + + public string OrphansToggleText => + S.Get(_orphansExpanded ? "Apps_CollapseOrphans" : "Apps_ReviewOrphans"); + /// <summary> /// Raw filenames from the last scan. Authoritative source for prune; /// never re-scan between display and prune (TOCTOU). @@ -1115,4 +1129,8 @@ public class AppInfo /// <summary>Sanitized projection of OrphanFiles for safe display.</summary> public List<string> OrphanFilesPreview { get; set; } = new(); + + public event System.ComponentModel.PropertyChangedEventHandler? PropertyChanged; + private void Notify(string n) => + PropertyChanged?.Invoke(this, new System.ComponentModel.PropertyChangedEventArgs(n)); } diff --git a/ui/Pages/CleanupPage.xaml b/ui/Pages/CleanupPage.xaml deleted file mode 100644 index b8a956cb..00000000 --- a/ui/Pages/CleanupPage.xaml +++ /dev/null @@ -1,172 +0,0 @@ -<Page x:Class="CloudRedirect.Pages.CleanupPage" - xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" - xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" - xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" - xmlns:res="clr-namespace:CloudRedirect.Resources" - ScrollViewer.CanContentScroll="False"> - - <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="24"> - <StackPanel MaxWidth="800"> - - <TextBlock Text="{res:Loc Cleanup_Title}" - FontSize="28" FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" - Margin="0,0,0,4" /> - <TextBlock Text="{res:Loc Cleanup_Description}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" - Margin="0,0,0,24" /> - - <StackPanel x:Name="ScanCleanPanel"> - - <WrapPanel Margin="0,0,0,16"> - <ui:Button x:Name="ScanButton" - Content="{res:Loc Cleanup_ScanForContamination}" - Icon="{ui:SymbolIcon Search24}" - Appearance="Primary" - Click="ScanButton_Click" - Margin="0,0,8,0" /> - <ui:Button x:Name="RestoreButton" - Content="{res:Loc Cleanup_RestoreFromBackup}" - Icon="{ui:SymbolIcon FolderOpen24}" - Click="RestoreButton_Click" - Margin="0,0,8,0" /> - <TextBlock x:Name="ScanStatus" - Text="" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - VerticalAlignment="Center" /> - </WrapPanel> - - <Border x:Name="UndoBanner" - Visibility="Collapsed" - Background="#303080E0" - BorderBrush="#603080E0" - BorderThickness="1" - CornerRadius="8" - Padding="16" - Margin="0,0,0,16"> - <Grid> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="*" /> - <ColumnDefinition Width="Auto" /> - </Grid.ColumnDefinitions> - <StackPanel Grid.Column="0" VerticalAlignment="Center"> - <TextBlock x:Name="UndoBannerText" - Text="" - FontSize="14" FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - <TextBlock Text="{res:Loc Cleanup_UndoBannerDescription}" - FontSize="12" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" - Margin="0,2,0,0" /> - </StackPanel> - <StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0"> - <ui:Button x:Name="UndoButton" - Content="{res:Loc Cleanup_Undo}" - Icon="{ui:SymbolIcon ArrowUndo24}" - Appearance="Primary" - Click="UndoButton_Click" - Margin="0,0,8,0" /> - <ui:Button x:Name="UndoDismissButton" - Content="{res:Loc Cleanup_Dismiss}" - Click="UndoDismiss_Click" /> - </StackPanel> - </Grid> - </Border> - - <StackPanel x:Name="LoadingPanel" - Visibility="Collapsed" - HorizontalAlignment="Center" - Margin="0,40,0,40"> - <ui:ProgressRing IsIndeterminate="True" Width="48" Height="48" Margin="0,0,0,12" /> - <TextBlock Text="{res:Loc Cleanup_ScanningApps}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - HorizontalAlignment="Center" /> - </StackPanel> - - <Border x:Name="NukePanel" - Visibility="Collapsed" - Background="#30FF3030" - BorderBrush="#60FF3030" - BorderThickness="1" - CornerRadius="8" - Padding="20" - Margin="0,0,0,20"> - <StackPanel> - <TextBlock Text="{res:Loc Cleanup_DangerZone}" - FontSize="18" FontWeight="Bold" - Foreground="#FFE04040" - Margin="0,0,0,6" /> - <TextBlock x:Name="NukeDescription" - Text="" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" - Margin="0,0,0,12" /> - <ui:Button x:Name="NukeButton" - Content="{res:Loc Cleanup_CleanAllSuspectFiles}" - Icon="{ui:SymbolIcon Delete24}" - Appearance="Danger" - Click="NukeButton_Click" /> - </StackPanel> - </Border> - - <ui:TextBox x:Name="GameSearchBox" - PlaceholderText="{res:Loc Search_Placeholder}" - Width="300" - HorizontalAlignment="Left" - TextChanged="GameSearchBox_TextChanged" - Visibility="Collapsed" - Margin="0,0,0,12" /> - - <StackPanel x:Name="GameListPanel" - Visibility="Collapsed" /> - - </StackPanel> - - <StackPanel x:Name="RestorePanel" Visibility="Collapsed"> - - <WrapPanel Margin="0,0,0,16"> - <ui:Button x:Name="BackButton" - Content="{res:Loc Cleanup_Back}" - Icon="{ui:SymbolIcon ArrowUndo24}" - Click="BackToScan_Click" - Margin="0,0,8,0" /> - <ui:Button x:Name="RefreshBackupsButton" - Content="{res:Loc Cleanup_RefreshBackups}" - Icon="{ui:SymbolIcon Search24}" - Click="RefreshBackupsButton_Click" - Margin="0,0,8,0" /> - <ui:TextBox x:Name="RestoreSearchBox" - PlaceholderText="{res:Loc Search_Placeholder}" - Width="250" - TextChanged="RestoreSearchBox_TextChanged" - Margin="0,0,8,0" /> - <TextBlock x:Name="RestoreStatus" - Text="" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - VerticalAlignment="Center" /> - </WrapPanel> - - <TextBlock Text="{res:Loc Cleanup_RestoreDescription}" - Foreground="{DynamicResource TextFillColorTertiaryBrush}" - TextWrapping="Wrap" - Margin="0,0,0,16" /> - - <StackPanel x:Name="RestoreLoadingPanel" - Visibility="Collapsed" - HorizontalAlignment="Center" - Margin="0,40,0,40"> - <ui:ProgressRing IsIndeterminate="True" Width="48" Height="48" Margin="0,0,0,12" /> - <TextBlock Text="{res:Loc Cleanup_LoadingBackups}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - HorizontalAlignment="Center" /> - </StackPanel> - - <StackPanel x:Name="BackupListPanel" /> - - </StackPanel> - - </StackPanel> - </ScrollViewer> -</Page> diff --git a/ui/Pages/CleanupPage.xaml.cs b/ui/Pages/CleanupPage.xaml.cs deleted file mode 100644 index 5b514aa0..00000000 --- a/ui/Pages/CleanupPage.xaml.cs +++ /dev/null @@ -1,1096 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading.Tasks; -using System.Windows; -using System.Windows.Controls; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using CloudRedirect.Resources; -using CloudRedirect.Services; - -namespace CloudRedirect.Pages; - -internal class LastCleanupState -{ - public string Utc { get; set; } = ""; - public int FileCount { get; set; } -} - -[JsonSerializable(typeof(LastCleanupState))] -internal partial class CleanupStateJsonContext : JsonSerializerContext { } - -public partial class CleanupPage : Page -{ - private readonly SteamStoreClient _storeClient = SteamStoreClient.Shared; - private Dictionary<uint, StoreAppInfo> _storeCache = new(); - private string? _steamPath; - private List<AppScanResult>? _scanResults; - - // Filtered list used for display (retained for search to rebuild from) - private List<AppScanResult>? _displayedApps; - - // Track whether backups have been loaded for the restore tab - private bool _backupsLoaded; - private List<BackupInfo>? _backups; - - // Reuse the CloudCleanup instance from the last scan (has populated _namespaceApps, _appConfigs, etc.) - private CloudCleanup? _cleanup; - - // Track whether a scan has been performed (for back button label) - private bool _hasScanned; - - // Track the most recent cleanup for undo banner + backup highlighting - private DateTime? _lastCleanupUtc; - private int _lastCleanupFileCount; - private bool _bannerChecked; - - public CleanupPage() - { - InitializeComponent(); - Loaded += CleanupPage_Loaded; - } - - private async void CleanupPage_Loaded(object sender, RoutedEventArgs e) - { - // Restore persisted undo banner from a previous session (once) - if (_bannerChecked) return; - _bannerChecked = true; - - var steamPath = await Task.Run(() => SteamDetector.FindSteamPath()); - if (steamPath == null) return; - _steamPath = steamPath; - - var saved = LoadCleanupState(steamPath); - if (saved == null) return; - - // Verify the backup still exists on disk before showing the banner - var backups = await Task.Run(() => BackupDiscovery.ListCleanupBackups(steamPath)); - var cutoff = saved.Value.utc.AddSeconds(-5); - if (!backups.Any(b => b.Timestamp >= cutoff)) - { - ClearCleanupState(steamPath); // stale state, clean up - return; - } - - _lastCleanupUtc = saved.Value.utc; - ShowUndoBanner(saved.Value.fileCount); - } - - private void RestoreButton_Click(object sender, RoutedEventArgs e) - { - ScanCleanPanel.Visibility = Visibility.Collapsed; - RestorePanel.Visibility = Visibility.Visible; - BackButton.Content = _hasScanned ? S.Get("Cleanup_BackToScan") : S.Get("Cleanup_Back"); - - // Auto-load backups on first visit - if (!_backupsLoaded) - { - LoadBackups(); - } - } - - private void BackToScan_Click(object sender, RoutedEventArgs e) - { - RestorePanel.Visibility = Visibility.Collapsed; - ScanCleanPanel.Visibility = Visibility.Visible; - } - - private async void ScanButton_Click(object sender, RoutedEventArgs e) - { - _steamPath = await Task.Run(() => SteamDetector.FindSteamPath()); - if (_steamPath == null) - { - ScanStatus.Text = S.Get("Cleanup_SteamNotFound"); - return; - } - - ScanButton.IsEnabled = false; - ScanStatus.Text = ""; - GameListPanel.Visibility = Visibility.Collapsed; - NukePanel.Visibility = Visibility.Collapsed; - LoadingPanel.Visibility = Visibility.Visible; - - try - { - // Run the scan off the UI thread (store instance for reuse by nuke/per-app clean) - _scanResults = await Task.Run(() => - { - _cleanup = new CloudCleanup(_steamPath, _ => { }); - return _cleanup.ScanApps(); - }); - _hasScanned = true; - - // Filter to apps that have files (show all namespace apps with remote/ content) - var appsWithFiles = _scanResults - .Where(r => r.Files.Count > 0) - .OrderByDescending(r => r.PollutedCount) - .ThenByDescending(r => r.TotalBytes) - .ToList(); - - _displayedApps = appsWithFiles; - - if (appsWithFiles.Count == 0) - { - ScanStatus.Text = S.Get("Cleanup_NoNamespaceApps"); - LoadingPanel.Visibility = Visibility.Collapsed; - ScanButton.IsEnabled = true; - GameSearchBox.Visibility = Visibility.Collapsed; - return; - } - - int totalPolluted = appsWithFiles.Sum(a => a.PollutedCount); - long totalPollutedBytes = appsWithFiles.Sum(a => a.PollutedBytes); - int appsAffected = appsWithFiles.Count(a => a.PollutedCount > 0); - ScanStatus.Text = S.Format("Cleanup_ScanStatusFormat", appsWithFiles.Count, totalPolluted, appsAffected); - - // Fetch game names + images in one batch - var storeInfo = await _storeClient.GetAppInfoAsync(appsWithFiles.Select(a => a.AppId).ToList()); - foreach (var (id, info) in storeInfo) - _storeCache[id] = info; - - // Build the game list (still hidden) - BuildGameList(appsWithFiles); - - // Now reveal everything at once -- no bounce - LoadingPanel.Visibility = Visibility.Collapsed; - - // Show search box now that we have results - GameSearchBox.Text = ""; - GameSearchBox.Visibility = Visibility.Visible; - - if (totalPolluted > 0) - { - NukeDescription.Text = S.Format("Cleanup_NukeDescriptionFormat", totalPolluted, FileUtils.FormatSize(totalPollutedBytes), appsAffected); - NukePanel.Visibility = Visibility.Visible; - } - else - { - NukePanel.Visibility = Visibility.Collapsed; - } - GameListPanel.Visibility = Visibility.Visible; - } - catch (Exception ex) - { - ScanStatus.Text = S.Format("Cleanup_ScanFailed", ex.Message); - } - finally - { - LoadingPanel.Visibility = Visibility.Collapsed; - ScanButton.IsEnabled = true; - } - } - - private async void NukeButton_Click(object sender, RoutedEventArgs e) - { - if (_scanResults == null || _steamPath == null) return; - - var allSuspect = _scanResults - .SelectMany(app => app.Files - .Where(f => f.Classification != FileClassification.Legitimate && - f.Classification != FileClassification.Unknown) - .Select(f => (app, file: f))) - .ToList(); - - if (allSuspect.Count == 0) return; - - long totalBytes = allSuspect.Sum(x => x.file.SizeBytes); - int appCount = allSuspect.Select(x => x.app.AppId).Distinct().Count(); - - bool confirmed = await Dialog.ConfirmDangerAsync( - S.Get("Cleanup_ConfirmCleanAllTitle"), - S.Format("Cleanup_ConfirmCleanAllMessage", allSuspect.Count, FileUtils.FormatSize(totalBytes), appCount)); - - if (!confirmed) return; - - NukeButton.IsEnabled = false; - NukeButton.Content = S.Get("Cleanup_Cleaning"); - ScanButton.IsEnabled = false; - - try - { - int totalMoved = 0; - var cleanupStartUtc = DateTime.UtcNow; - - await Task.Run(() => - { - var cleanup = _cleanup ?? new CloudCleanup(_steamPath, _ => { }); - - // Group by account first, then by app -- each account gets its own batch/undo log - var byAccount = allSuspect.GroupBy(x => x.app.AccountId); - - foreach (var accountGroup in byAccount) - { - cleanup.BeginBatch(); - try - { - foreach (var appGroup in accountGroup.GroupBy(x => (x.app.AppId, x.app.RemoteDir))) - { - string appDir = Path.GetDirectoryName(appGroup.Key.RemoteDir)!; - var files = appGroup.Select(x => x.file).ToList(); - totalMoved += cleanup.CleanFiles(accountGroup.Key, appGroup.Key.AppId, appDir, files); - } - } - finally - { - cleanup.EndBatch(accountGroup.Key); - } - } - }); - - await Dialog.ShowInfoAsync(S.Get("Cleanup_CleanupCompleteTitle"), - S.Format("Cleanup_CleanupCompleteMessage", totalMoved)); - - // Track for undo banner + backup highlighting - _lastCleanupUtc = cleanupStartUtc; - _backupsLoaded = false; - ShowUndoBanner(totalMoved); - - // Refresh - ScanButton_Click(null!, null!); - } - catch (Exception ex) - { - await Dialog.ShowErrorAsync(S.Get("Cleanup_CleanupFailedTitle"), ex.Message); - } - finally - { - NukeButton.IsEnabled = true; - NukeButton.Content = S.Get("Cleanup_CleanAllSuspectFiles"); - ScanButton.IsEnabled = true; - } - } - - private async void RefreshBackupsButton_Click(object sender, RoutedEventArgs e) - { - _backupsLoaded = false; - await LoadBackupsAsync(); - } - - private async void LoadBackups() - { - await LoadBackupsAsync(); - } - - private async Task LoadBackupsAsync() - { - _steamPath ??= await Task.Run(() => SteamDetector.FindSteamPath()); - if (_steamPath == null) - { - RestoreStatus.Text = S.Get("Cleanup_SteamNotFound"); - return; - } - - RefreshBackupsButton.IsEnabled = false; - RestoreStatus.Text = ""; - BackupListPanel.Children.Clear(); - RestoreLoadingPanel.Visibility = Visibility.Visible; - - try - { - _backups = await Task.Run(() => BackupDiscovery.ListCleanupBackups(_steamPath)); - _backupsLoaded = true; - - if (_backups.Count == 0) - { - RestoreStatus.Text = S.Get("Cleanup_NoBackupsFound"); - RestoreLoadingPanel.Visibility = Visibility.Collapsed; - return; - } - - // Fetch game names + images for all apps across all backups - var allAppIds = _backups.SelectMany(b => b.AppIds).Distinct().ToList(); - var storeInfo = await _storeClient.GetAppInfoAsync(allAppIds); - foreach (var (id, info) in storeInfo) - _storeCache[id] = info; - - // Build the backup list - BuildBackupList(); - - RestoreStatus.Text = S.Format("Cleanup_BackupCountFormat", _backups.Count); - } - catch (Exception ex) - { - RestoreStatus.Text = S.Format("Cleanup_FailedLoadBackups", ex.Message); - } - finally - { - RestoreLoadingPanel.Visibility = Visibility.Collapsed; - RefreshBackupsButton.IsEnabled = true; - } - } - - private void BuildBackupList() - { - // If there's an active search query, apply the filter instead - var query = RestoreSearchBox?.Text?.Trim() ?? ""; - if (!string.IsNullOrEmpty(query)) - { - ApplyRestoreFilter(); - return; - } - - BackupListBuilder.Build( - BackupListPanel, - _backups, - appId => _storeCache.TryGetValue(appId, out var si) ? si : null, - FindResource, - RunBackupPreview, - RunBackupRestore, - _lastCleanupUtc); - } - - private async Task RunBackupPreview(BackupInfo backup, StackPanel detailPanel, Wpf.Ui.Controls.Button previewBtn) - { - if (detailPanel.Visibility == Visibility.Visible) - { - detailPanel.Visibility = Visibility.Collapsed; - previewBtn.Content = S.Get("Backup_Preview"); - return; - } - - previewBtn.IsEnabled = false; - previewBtn.Content = S.Get("Backup_Loading"); - - try - { - var logLines = new List<string>(); - RevertResult result = null; - - await Task.Run(() => - { - var revert = new CloudCleanupRevert(_steamPath!, RevertConflictMode.Skip, msg => logLines.Add(msg)); - result = revert.RestoreFromLog(backup.UndoLogPath, dryRun: true); - }); - - detailPanel.Children.Clear(); - - // Summary - var summary = new TextBlock - { - Text = result != null - ? S.Format("Preview_SummaryFormat", result.FilesRestored, result.FilesSkipped, result.RemotecachesRestored) - : S.Get("Preview_Failed"), - FontSize = 13, - FontWeight = FontWeights.SemiBold, - Foreground = (Brush)FindResource("TextFillColorSecondaryBrush"), - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 0, 0, 8) - }; - detailPanel.Children.Add(summary); - - // Show log output - if (logLines.Count > 0) - { - var logBorder = new Border - { - Background = (Brush)FindResource("ControlFillColorDefaultBrush"), - BorderBrush = (Brush)FindResource("ControlStrokeColorDefaultBrush"), - BorderThickness = new Thickness(1), - CornerRadius = new CornerRadius(4), - Padding = new Thickness(8), - MaxHeight = 300 - }; - var logScroll = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto }; - var logText = new TextBlock - { - Text = string.Join("\n", logLines), - FontFamily = new FontFamily("Cascadia Code,Consolas,Courier New"), - FontSize = 11, - Foreground = (Brush)FindResource("TextFillColorSecondaryBrush"), - TextWrapping = TextWrapping.Wrap - }; - logScroll.Content = logText; - logBorder.Child = logScroll; - detailPanel.Children.Add(logBorder); - } - - if (result?.Errors.Count > 0) - { - var errText = new TextBlock - { - Text = S.Format("Preview_ErrorsHeader", string.Join("\n", result.Errors)), - Foreground = new SolidColorBrush(Color.FromRgb(230, 80, 80)), - TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 8, 0, 0) - }; - detailPanel.Children.Add(errText); - } - - detailPanel.Visibility = Visibility.Visible; - previewBtn.Content = S.Get("Backup_HidePreview"); - } - catch (Exception ex) - { - await Dialog.ShowErrorAsync(S.Get("Preview_FailedTitle"), ex.Message); - previewBtn.Content = S.Get("Backup_Preview"); - } - finally - { - previewBtn.IsEnabled = true; - } - } - - private async Task RunBackupRestore(BackupInfo backup, Wpf.Ui.Controls.Button restoreBtn) - { - if (!await SteamDetector.EnsureSteamClosedAsync()) return; - - string timestampText = backup.Timestamp != DateTime.MinValue - ? backup.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") - : backup.Id; - - bool confirmed = await Dialog.ConfirmDangerAsync( - S.Get("Cleanup_RestoreFromBackupTitle"), - S.Format("Cleanup_RestoreConfirmMessage", timestampText, backup.AccountId, backup.FileCount, string.Join(", ", backup.AppIds))); - - if (!confirmed) return; - - restoreBtn.IsEnabled = false; - restoreBtn.Content = S.Get("Apps_Restoring"); - - try - { - RevertResult result = null; - await Task.Run(() => - { - var revert = new CloudCleanupRevert(_steamPath!, RevertConflictMode.Skip, _ => { }); - result = revert.RestoreFromLog(backup.UndoLogPath, dryRun: false); - }); - - if (result != null) - { - string msg = S.Format("Cleanup_RestoredFormat", result.FilesRestored, result.RemotecachesRestored); - if (result.FilesSkipped > 0) - msg += S.Format("Cleanup_SkippedFormat", result.FilesSkipped); - if (result.Errors.Count > 0) - msg += S.Format("Cleanup_ErrorsFormat", result.Errors.Count, string.Join("\n", result.Errors.Take(5))); - - await Dialog.ShowInfoAsync(S.Get("Cleanup_RestoreCompleteTitle"), msg); - } - } - catch (Exception ex) - { - await Dialog.ShowErrorAsync(S.Get("Cleanup_RestoreFailedTitle"), ex.Message); - } - finally - { - restoreBtn.IsEnabled = true; - restoreBtn.Content = S.Get("Apps_Restore"); - } - } - - private void GameSearchBox_TextChanged(object sender, TextChangedEventArgs e) - { - ApplyGameFilter(); - } - - private void ApplyGameFilter() - { - if (_displayedApps == null) return; - var query = GameSearchBox?.Text?.Trim() ?? ""; - - List<AppScanResult> filtered; - if (string.IsNullOrEmpty(query)) - { - filtered = _displayedApps; - } - else - { - filtered = _displayedApps - .Where(a => MatchesGameQuery(a, query)) - .ToList(); - } - - BuildGameList(filtered); - GameListPanel.Visibility = filtered.Count > 0 ? Visibility.Visible : Visibility.Collapsed; - } - - private bool MatchesGameQuery(AppScanResult app, string query) - { - if (app.AppId.ToString().Contains(query, StringComparison.OrdinalIgnoreCase)) - return true; - if (_storeCache.TryGetValue(app.AppId, out var si) - && !string.IsNullOrEmpty(si.Name) - && si.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) - return true; - return false; - } - - private void RestoreSearchBox_TextChanged(object sender, TextChangedEventArgs e) - { - ApplyRestoreFilter(); - } - - private void ApplyRestoreFilter() - { - if (_backups == null || !_backupsLoaded) return; - - var query = RestoreSearchBox?.Text?.Trim() ?? ""; - IReadOnlyList<BackupInfo> filtered; - - if (string.IsNullOrEmpty(query)) - { - filtered = _backups; - } - else - { - filtered = _backups - .Where(b => MatchesBackupQuery(b, query)) - .ToList(); - } - - BackupListPanel.Children.Clear(); - if (filtered.Count == 0) - { - RestoreStatus.Text = S.Get("Cleanup_NoBackupsFound"); - return; - } - - BackupListBuilder.Build( - BackupListPanel, - filtered, - appId => _storeCache.TryGetValue(appId, out var si) ? si : null, - FindResource, - RunBackupPreview, - RunBackupRestore, - _lastCleanupUtc); - - RestoreStatus.Text = S.Format("Cleanup_BackupCountFormat", filtered.Count); - } - - private bool MatchesBackupQuery(BackupInfo b, string query) - { - foreach (var id in b.AppIds) - { - if (id.ToString().Contains(query, StringComparison.OrdinalIgnoreCase)) - return true; - if (_storeCache.TryGetValue(id, out var info) - && !string.IsNullOrEmpty(info.Name) - && info.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) - return true; - } - return false; - } - - private static string GetCleanupStatePath(string steamPath) => - Path.Combine(steamPath, "cloud_redirect", "last_cleanup.json"); - - private void SaveCleanupState(string steamPath, DateTime utc, int fileCount) - { - try - { - var state = new LastCleanupState - { - Utc = utc.ToString("o"), - FileCount = fileCount - }; - var dir = Path.GetDirectoryName(GetCleanupStatePath(steamPath))!; - Directory.CreateDirectory(dir); - File.WriteAllText(GetCleanupStatePath(steamPath), - JsonSerializer.Serialize(state, CleanupStateJsonContext.Default.LastCleanupState)); - } - catch { } - } - - private void ClearCleanupState(string steamPath) - { - try { File.Delete(GetCleanupStatePath(steamPath)); } catch { } - } - - private (DateTime utc, int fileCount)? LoadCleanupState(string steamPath) - { - try - { - var path = GetCleanupStatePath(steamPath); - if (!File.Exists(path)) return null; - var json = File.ReadAllText(path); - var state = JsonSerializer.Deserialize(json, CleanupStateJsonContext.Default.LastCleanupState); - if (state == null || string.IsNullOrEmpty(state.Utc)) return null; - if (!DateTime.TryParse(state.Utc, null, System.Globalization.DateTimeStyles.RoundtripKind, out var utc)) - return null; - return (utc, state.FileCount); - } - catch { return null; } - } - - private void ShowUndoBanner(int fileCount) - { - _lastCleanupFileCount = fileCount; - UndoBannerText.Text = S.Format("Cleanup_CleanedBannerFormat", fileCount); - UndoBanner.Visibility = Visibility.Visible; - UndoButton.IsEnabled = true; - UndoButton.Content = S.Get("Cleanup_Undo"); - - // Persist so the banner survives app restart - if (_steamPath != null && _lastCleanupUtc != null) - SaveCleanupState(_steamPath, _lastCleanupUtc.Value, fileCount); - } - - private void UndoDismiss_Click(object sender, RoutedEventArgs e) - { - UndoBanner.Visibility = Visibility.Collapsed; - } - - private async void UndoButton_Click(object sender, RoutedEventArgs e) - { - if (_lastCleanupUtc == null) return; - - if (!await SteamDetector.EnsureSteamClosedAsync()) return; - - UndoButton.IsEnabled = false; - UndoButton.Content = S.Get("Apps_Restoring"); - UndoDismissButton.IsEnabled = false; - - try - { - _steamPath ??= await Task.Run(() => SteamDetector.FindSteamPath()); - if (_steamPath == null) - { - await Dialog.ShowErrorAsync(S.Get("Cleanup_UndoFailedTitle"), S.Get("Cleanup_UndoSteamNotFound")); - return; - } - - // Load backups from disk and find the one(s) created by the last cleanup - var cutoff = _lastCleanupUtc.Value.AddSeconds(-5); // small tolerance for clock skew - var allBackups = await Task.Run(() => BackupDiscovery.ListCleanupBackups(_steamPath)); - var recentBackups = allBackups - .Where(b => b.Timestamp >= cutoff) - .ToList(); - - if (recentBackups.Count == 0) - { - await Dialog.ShowErrorAsync(S.Get("Cleanup_UndoFailedTitle"), - S.Get("Cleanup_UndoNoBackupFound")); - return; - } - - int totalRestored = 0; - int totalSkipped = 0; - int totalRemotecaches = 0; - var allErrors = new List<string>(); - - await Task.Run(() => - { - foreach (var backup in recentBackups) - { - var revert = new CloudCleanupRevert(_steamPath, RevertConflictMode.Skip, _ => { }); - var result = revert.RestoreFromLog(backup.UndoLogPath, dryRun: false); - if (result != null) - { - totalRestored += result.FilesRestored; - totalSkipped += result.FilesSkipped; - totalRemotecaches += result.RemotecachesRestored; - allErrors.AddRange(result.Errors); - } - } - }); - - string msg = S.Format("Cleanup_RestoredFormat", totalRestored, totalRemotecaches); - if (totalSkipped > 0) - msg += S.Format("Cleanup_SkippedFormat", totalSkipped); - if (allErrors.Count > 0) - msg += S.Format("Cleanup_ErrorsFormat", allErrors.Count, string.Join("\n", allErrors.Take(5))); - - await Dialog.ShowInfoAsync(S.Get("Cleanup_UndoCompleteTitle"), msg); - - // Clear undo state and hide banner - _lastCleanupUtc = null; - UndoBanner.Visibility = Visibility.Collapsed; - if (_steamPath != null) ClearCleanupState(_steamPath); - - // Invalidate backups cache and re-scan - _backupsLoaded = false; - ScanButton_Click(null!, null!); - } - catch (Exception ex) - { - await Dialog.ShowErrorAsync(S.Get("Cleanup_UndoFailedTitle"), ex.Message); - } - finally - { - UndoButton.IsEnabled = true; - UndoButton.Content = S.Get("Cleanup_Undo"); - UndoDismissButton.IsEnabled = true; - } - } - - private void BuildGameList(List<AppScanResult> apps) - { - GameListPanel.Children.Clear(); - - foreach (var app in apps) - { - string gameName = _storeCache.TryGetValue(app.AppId, out var si) && !string.IsNullOrEmpty(si.Name) - ? si.Name : app.AppId.ToString(); - bool hasPollution = app.PollutedCount > 0; - - var card = new Border - { - Background = (Brush)FindResource("ControlFillColorDefaultBrush"), - CornerRadius = new CornerRadius(8), - Margin = new Thickness(0, 0, 0, 8), - Padding = new Thickness(16) - }; - - var cardContent = new StackPanel(); - card.Child = cardContent; - - // Header row: icon + name + stats + expand button - var headerRow = new Grid(); - headerRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(42) }); - headerRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - headerRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - - // Game icon from SteamStoreClient - var iconImage = new Image - { - Width = 32, - Height = 32, - Stretch = Stretch.UniformToFill, - Margin = new Thickness(0, 0, 10, 0) - }; - if (_storeCache.TryGetValue(app.AppId, out var storeInfo2) && SteamStoreClient.IsValidImageUrl(storeInfo2.HeaderUrl)) - { - try - { - var uri = new Uri(storeInfo2.HeaderUrl); - var bitmap = new BitmapImage(); - bitmap.BeginInit(); - if (uri.IsFile) - { - // OnLoad decodes immediately and releases the backing - // file handle; without it a file:// URI keeps the - // cached JPEG locked, blocking eviction and the - // File.Move(overwrite:true) that installs a refreshed - // asset after a Steam CDN hash rotation. - bitmap.CacheOption = BitmapCacheOption.OnLoad; - } - // HTTP URIs: leave CacheOption at Default so the download - // streams async rather than blocking the UI thread with a - // synchronous fetch (which was causing cold-cache renders - // to drop images silently). - bitmap.UriSource = uri; - bitmap.DecodePixelWidth = 64; - bitmap.EndInit(); - if (uri.IsFile) - bitmap.Freeze(); - iconImage.Source = bitmap; - } - catch { /* icon load failure is fine */ } - } - Grid.SetColumn(iconImage, 0); - headerRow.Children.Add(iconImage); - - // Name + stats - var nameStack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; - var nameText = new TextBlock - { - Text = gameName, - FontSize = 15, - FontWeight = FontWeights.SemiBold, - Foreground = (Brush)FindResource("TextFillColorPrimaryBrush"), - TextTrimming = TextTrimming.CharacterEllipsis - }; - nameStack.Children.Add(nameText); - - var statsWrap = new WrapPanel { Margin = new Thickness(0, 2, 0, 0) }; - statsWrap.Children.Add(MakeStatText($"AppID {app.AppId}", false)); - statsWrap.Children.Add(MakeStatText(S.Format("Cleanup_FilesCount", app.Files.Count), false)); - statsWrap.Children.Add(MakeStatText(FileUtils.FormatSize(app.TotalBytes), false)); - if (hasPollution) - { - statsWrap.Children.Add(MakeStatText(S.Format("Cleanup_Suspect", app.PollutedCount), true)); - statsWrap.Children.Add(MakeStatText(FileUtils.FormatSize(app.PollutedBytes), true)); - } - else - { - statsWrap.Children.Add(MakeStatText(S.Get("Cleanup_Clean"), false)); - } - nameStack.Children.Add(statsWrap); - - Grid.SetColumn(nameStack, 1); - headerRow.Children.Add(nameStack); - - // Expand/collapse button - var expandBtn = new Wpf.Ui.Controls.Button - { - Content = hasPollution ? S.Get("Cleanup_ReviewFiles") : S.Get("Cleanup_ViewFiles"), - Appearance = hasPollution ? Wpf.Ui.Controls.ControlAppearance.Caution : Wpf.Ui.Controls.ControlAppearance.Secondary, - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(8, 0, 0, 0) - }; - Grid.SetColumn(expandBtn, 2); - headerRow.Children.Add(expandBtn); - - cardContent.Children.Add(headerRow); - - // File detail panel (hidden by default) - var detailPanel = new StackPanel - { - Visibility = Visibility.Collapsed, - Margin = new Thickness(0, 12, 0, 0) - }; - cardContent.Children.Add(detailPanel); - - // Wire expand button - expandBtn.Click += (_, _) => - { - if (detailPanel.Visibility == Visibility.Collapsed) - { - detailPanel.Visibility = Visibility.Visible; - expandBtn.Content = S.Get("Cleanup_Collapse"); - if (detailPanel.Children.Count == 0) - BuildFileList(detailPanel, app); - } - else - { - detailPanel.Visibility = Visibility.Collapsed; - expandBtn.Content = hasPollution ? S.Get("Cleanup_ReviewFiles") : S.Get("Cleanup_ViewFiles"); - } - }; - - GameListPanel.Children.Add(card); - } - } - - private void BuildFileList(StackPanel container, AppScanResult app) - { - // Group files: suspect first, then unknown, then legitimate - var suspectLabel = S.Get("Cleanup_SuspectFiles"); - var unknownLabel = S.Get("Cleanup_Unknown"); - var groups = new[] - { - (suspectLabel, app.Files.Where(f => - f.Classification != FileClassification.Legitimate && - f.Classification != FileClassification.Unknown).ToList()), - (unknownLabel, app.Files.Where(f => f.Classification == FileClassification.Unknown).ToList()), - (S.Get("Cleanup_Legitimate"), app.Files.Where(f => f.Classification == FileClassification.Legitimate).ToList()) - }; - - // Track all checkboxes for this app's suspect files for the "clean selected" button - var checkboxes = new List<(CheckBox cb, ClassifiedFile file)>(); - - foreach (var (header, files) in groups) - { - if (files.Count == 0) continue; - - var groupHeader = new TextBlock - { - Text = S.Format("Cleanup_GroupHeaderFormat", header, files.Count), - FontSize = 13, - FontWeight = FontWeights.SemiBold, - Foreground = (Brush)FindResource("TextFillColorSecondaryBrush"), - Margin = new Thickness(0, 8, 0, 4) - }; - container.Children.Add(groupHeader); - - bool isSuspect = header == suspectLabel; - - foreach (var file in files.OrderBy(f => f.RelativePath)) - { - var fileRow = new Grid { Margin = new Thickness(0, 1, 0, 1) }; - fileRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // checkbox - fileRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // filename - fileRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // classification badge - fileRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // size - - // Checkbox (only for suspect + unknown files) - if (isSuspect || header == unknownLabel) - { - var cb = new CheckBox - { - IsChecked = isSuspect, // pre-check suspect files - VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 6, 0) - }; - Grid.SetColumn(cb, 0); - fileRow.Children.Add(cb); - checkboxes.Add((cb, file)); - } - - // Filename - var fileNameText = new TextBlock - { - Text = file.RelativePath, - FontFamily = new FontFamily("Cascadia Code,Consolas,Courier New"), - FontSize = 12, - Foreground = (Brush)FindResource("TextFillColorPrimaryBrush"), - VerticalAlignment = VerticalAlignment.Center, - TextTrimming = TextTrimming.CharacterEllipsis, - Margin = new Thickness(0, 0, 8, 0) - }; - // Tooltip with full reason - if (!string.IsNullOrEmpty(file.Reason)) - fileNameText.ToolTip = file.Reason; - Grid.SetColumn(fileNameText, 1); - fileRow.Children.Add(fileNameText); - - // Classification badge - var badge = new Border - { - CornerRadius = new CornerRadius(4), - Padding = new Thickness(6, 2, 6, 2), - Margin = new Thickness(0, 0, 8, 0), - VerticalAlignment = VerticalAlignment.Center, - Background = file.Classification switch - { - FileClassification.PollutionCrossApp => new SolidColorBrush(Color.FromRgb(180, 60, 60)), - FileClassification.PollutionAppIdDir => new SolidColorBrush(Color.FromRgb(180, 100, 40)), - FileClassification.PollutionMangled => new SolidColorBrush(Color.FromRgb(180, 100, 40)), - FileClassification.Legitimate => new SolidColorBrush(Color.FromRgb(40, 120, 60)), - _ => new SolidColorBrush(Color.FromRgb(100, 100, 100)) - } - }; - badge.Child = new TextBlock - { - Text = file.Classification switch - { - FileClassification.PollutionCrossApp => S.Get("Cleanup_Badge_CrossApp"), - FileClassification.PollutionAppIdDir => S.Get("Cleanup_Badge_WrongAppId"), - FileClassification.PollutionMangled => S.Get("Cleanup_Badge_Mangled"), - FileClassification.PollutionOrphan => S.Get("Cleanup_Badge_Orphan"), - FileClassification.Legitimate => S.Get("Cleanup_Badge_Legit"), - _ => S.Get("Cleanup_Badge_Unknown") - }, - FontSize = 11, - Foreground = Brushes.White - }; - Grid.SetColumn(badge, 2); - fileRow.Children.Add(badge); - - // File size - var sizeText = new TextBlock - { - Text = FileUtils.FormatSize(file.SizeBytes), - FontSize = 12, - Foreground = (Brush)FindResource("TextFillColorTertiaryBrush"), - VerticalAlignment = VerticalAlignment.Center, - MinWidth = 60, - TextAlignment = TextAlignment.Right - }; - Grid.SetColumn(sizeText, 3); - fileRow.Children.Add(sizeText); - - container.Children.Add(fileRow); - } - } - - // Action buttons - if (checkboxes.Count > 0) - { - var actionBar = new WrapPanel { Margin = new Thickness(0, 12, 0, 0) }; - - var selectAllBtn = new Wpf.Ui.Controls.Button - { - Content = S.Get("Cleanup_SelectAllSuspect"), - Appearance = Wpf.Ui.Controls.ControlAppearance.Secondary, - Margin = new Thickness(0, 0, 8, 0) - }; - selectAllBtn.Click += (_, _) => - { - foreach (var (cb, _) in checkboxes) - cb.IsChecked = true; - }; - actionBar.Children.Add(selectAllBtn); - - var selectNoneBtn = new Wpf.Ui.Controls.Button - { - Content = S.Get("Cleanup_DeselectAll"), - Appearance = Wpf.Ui.Controls.ControlAppearance.Secondary, - Margin = new Thickness(0, 0, 8, 0) - }; - selectNoneBtn.Click += (_, _) => - { - foreach (var (cb, _) in checkboxes) - cb.IsChecked = false; - }; - actionBar.Children.Add(selectNoneBtn); - - var cleanBtn = new Wpf.Ui.Controls.Button - { - Content = S.Get("Cleanup_CleanSelected"), - Icon = new Wpf.Ui.Controls.SymbolIcon { Symbol = Wpf.Ui.Controls.SymbolRegular.Delete24 }, - Appearance = Wpf.Ui.Controls.ControlAppearance.Danger, - Margin = new Thickness(0, 0, 0, 0) - }; - - // Capture app reference for the closure - var capturedApp = app; - var capturedCheckboxes = checkboxes; - - cleanBtn.Click += async (_, _) => - { - var selected = capturedCheckboxes - .Where(x => x.cb.IsChecked == true) - .Select(x => x.file) - .ToList(); - - if (selected.Count == 0) - { - await Dialog.ShowInfoAsync(S.Get("Cleanup_NothingSelectedTitle"), S.Get("Cleanup_NothingSelectedMessage")); - return; - } - - long totalBytes = selected.Sum(f => f.SizeBytes); - bool confirmed = await Dialog.ConfirmDangerAsync( - S.Get("Cleanup_ConfirmCleanupTitle"), - S.Format("Cleanup_ConfirmCleanupMessage", selected.Count, FileUtils.FormatSize(totalBytes), capturedApp.AccountId)); - - if (!confirmed) return; - - cleanBtn.IsEnabled = false; - cleanBtn.Content = S.Get("Cleanup_CleaningButton"); - - try - { - string appDir = Path.GetDirectoryName(capturedApp.RemoteDir)!; - var cleanupStartUtc = DateTime.UtcNow; - int moved = await Task.Run(() => - { - var cleanup = _cleanup ?? new CloudCleanup(_steamPath!, _ => { }); - return cleanup.CleanFiles(capturedApp.AccountId, capturedApp.AppId, appDir, selected); - }); - - await Dialog.ShowInfoAsync(S.Get("Cleanup_CleanupCompleteTitle"), - S.Format("Cleanup_CleanupCompleteMessage", moved)); - - // Track for undo banner + backup highlighting - _lastCleanupUtc = cleanupStartUtc; - _backupsLoaded = false; - ShowUndoBanner(moved); - - // Refresh the scan - ScanButton_Click(null!, null!); - } - catch (Exception ex) - { - await Dialog.ShowErrorAsync(S.Get("Cleanup_CleanupFailedTitle"), ex.Message); - } - finally - { - cleanBtn.IsEnabled = true; - cleanBtn.Content = S.Get("Cleanup_CleanSelected"); - } - }; - - actionBar.Children.Add(cleanBtn); - container.Children.Add(actionBar); - } - } - - private TextBlock MakeStatText(string text, bool isWarning) - { - return new TextBlock - { - Text = text, - FontSize = 12, - Foreground = isWarning - ? new SolidColorBrush(Color.FromRgb(230, 150, 50)) - : (Brush)FindResource("TextFillColorTertiaryBrush"), - Margin = new Thickness(0, 0, 12, 0) - }; - } -} diff --git a/ui/Pages/Cloud760Page.xaml b/ui/Pages/Cloud760Page.xaml new file mode 100644 index 00000000..4255006d --- /dev/null +++ b/ui/Pages/Cloud760Page.xaml @@ -0,0 +1,180 @@ +<Page x:Class="CloudRedirect.Pages.Cloud760Page" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + ScrollViewer.CanContentScroll="False"> + + <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="24"> + <StackPanel MaxWidth="800"> + + <TextBlock Text="Cleanup" + FontSize="28" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,0,0,8" /> + + <TextBlock Text="WIP: get rid of the BS SteamTools put in your 760." + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" + Margin="0,0,0,20" /> + + <!-- Connection row: primary action + live summary on the right --> + <Grid Margin="0,0,0,16"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + + <StackPanel Grid.Column="0" Orientation="Horizontal" VerticalAlignment="Center"> + <TextBlock Text="AppID" VerticalAlignment="Center" Margin="0,0,8,0" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" /> + <ui:TextBox x:Name="AppIdBox" Text="760" Width="96" + VerticalAlignment="Center" Margin="0,0,12,0" /> + <ui:Button x:Name="ConnectButton" Content="Connect" + Icon="{ui:SymbolIcon Cloud24}" Appearance="Primary" + Click="ConnectButton_Click" Margin="0,0,8,0" /> + <ui:Button x:Name="RefreshButton" Content="Refresh" + Icon="{ui:SymbolIcon ArrowSync24}" Appearance="Secondary" + IsEnabled="False" Click="RefreshButton_Click" /> + </StackPanel> + + <StackPanel Grid.Column="1" Orientation="Horizontal" + HorizontalAlignment="Right" VerticalAlignment="Center"> + <ui:ProgressRing x:Name="BusyRing" IsIndeterminate="True" + Width="20" Height="20" Visibility="Collapsed" + Margin="0,0,8,0" /> + <TextBlock x:Name="QuotaText" Text="" + VerticalAlignment="Center" + Foreground="{DynamicResource TextFillColorTertiaryBrush}" /> + </StackPanel> + </Grid> + + <!-- Empty / not-connected state: how to populate the list --> + <ui:CardControl x:Name="EmptyHintCard" Margin="0,0,0,4"> + <ui:CardControl.Header> + <StackPanel> + <TextBlock Text="No files yet" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <TextBlock Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" Margin="0,2,0,0"> + <Run Text="Connect to load your AppID 760 cloud files. If nothing shows up, open the Steam console, run " /> + <Run Text="cloud_sync_up 760" FontFamily="Consolas" /> + <Run Text=", then Refresh." /> + </TextBlock> + </StackPanel> + </ui:CardControl.Header> + <StackPanel Orientation="Horizontal"> + <ui:Button x:Name="OpenConsoleButton" Content="Open Console" + Icon="{ui:SymbolIcon Window24}" Appearance="Secondary" + Click="OpenConsoleButton_Click" Margin="0,0,8,0" /> + <ui:Button x:Name="CopyCmdButton" Content="Copy Command" + Icon="{ui:SymbolIcon Copy24}" Appearance="Secondary" + Click="CopyCmdButton_Click" /> + </StackPanel> + </ui:CardControl> + + <!-- File list + selection controls (hidden until there are files) --> + <StackPanel x:Name="ListPanel" Visibility="Collapsed"> + + <Grid Margin="0,4,0,8"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <TextBlock x:Name="SelectionText" Grid.Column="0" + VerticalAlignment="Center" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" /> + <StackPanel Grid.Column="1" Orientation="Horizontal"> + <ui:Button Content="Select All" Appearance="Transparent" + Click="SelectAll_Click" Margin="0,0,4,0" /> + <ui:Button Content="Clear" Appearance="Transparent" + Click="ClearSelection_Click" /> + </StackPanel> + </Grid> + + <ItemsControl x:Name="FileList"> + <ItemsControl.ItemTemplate> + <DataTemplate> + <Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" + BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}" + BorderThickness="1" + CornerRadius="6" + Padding="12,10" + Margin="0,0,0,6"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + + <CheckBox Grid.Column="0" + VerticalAlignment="Center" + Margin="0,0,12,0" + IsChecked="{Binding IsChecked, UpdateSourceTrigger=PropertyChanged}" /> + + <TextBlock Grid.Column="1" + Text="{Binding Name}" + VerticalAlignment="Center" + TextTrimming="CharacterEllipsis" + FontFamily="Consolas" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + + <StackPanel Grid.Column="2" Orientation="Horizontal" + VerticalAlignment="Center"> + <TextBlock Text="{Binding SizeDisplay}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + FontSize="12" Margin="0,0,16,0" /> + <TextBlock Text="{Binding TimestampDisplay}" + Foreground="{DynamicResource TextFillColorTertiaryBrush}" + FontSize="12" Width="120" TextAlignment="Right" /> + </StackPanel> + </Grid> + </Border> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + + <!-- Destructive actions, deliberately set apart from connection controls --> + <Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" + BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}" + BorderThickness="1" + CornerRadius="8" + Padding="16" + Margin="0,12,0,0"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <StackPanel Grid.Column="0" VerticalAlignment="Center"> + <TextBlock Text="Delete cloud files" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <TextBlock Text="Removing files here is permanent and cannot be undone." + FontSize="12" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + Margin="0,2,0,0" /> + </StackPanel> + <StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center"> + <ui:Button x:Name="DeleteSelectedButton" Content="Delete Selected" + Icon="{ui:SymbolIcon Delete24}" Appearance="Secondary" + IsEnabled="False" Click="DeleteSelectedButton_Click" Margin="0,0,8,0" /> + <ui:Button x:Name="DeleteAllButton" Content="Delete All" + Icon="{ui:SymbolIcon Delete24}" Appearance="Danger" + IsEnabled="False" Click="DeleteAllButton_Click" /> + </StackPanel> + </Grid> + </Border> + + </StackPanel> + + <TextBlock x:Name="StatusText" + Text="" + TextWrapping="Wrap" + Margin="0,16,0,0" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" /> + + </StackPanel> + </ScrollViewer> +</Page> diff --git a/ui/Pages/Cloud760Page.xaml.cs b/ui/Pages/Cloud760Page.xaml.cs new file mode 100644 index 00000000..31abfb4e --- /dev/null +++ b/ui/Pages/Cloud760Page.xaml.cs @@ -0,0 +1,281 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using CloudRedirect.Services; + +namespace CloudRedirect.Pages; + +public partial class Cloud760Page : Page +{ + public sealed class FileRow : INotifyPropertyChanged + { + public string Name { get; init; } = ""; + public int Size { get; init; } + public DateTime Timestamp { get; init; } + + private bool _isChecked; + public bool IsChecked + { + get => _isChecked; + set { if (_isChecked != value) { _isChecked = value; OnPropertyChanged(nameof(IsChecked)); CheckedChanged?.Invoke(); } } + } + + public string SizeDisplay => FormatSize(Size); + public string TimestampDisplay => Timestamp.Year > 1971 ? Timestamp.ToString("yyyy-MM-dd HH:mm") : ""; + + public Action? CheckedChanged; + public event PropertyChangedEventHandler? PropertyChanged; + private void OnPropertyChanged(string n) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n)); + + private static string FormatSize(long bytes) + { + string[] units = { "B", "KB", "MB", "GB" }; + double v = bytes; int u = 0; + while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; } + return u == 0 ? $"{bytes} B" : $"{v:0.##} {units[u]}"; + } + } + + private readonly ObservableCollection<FileRow> _files = new(); + private bool _busy; + private Steam760Cloud? _cloud; // live connection (kept open like the reference tool) + private uint _connectedAppId; + + public Cloud760Page() + { + InitializeComponent(); + FileList.ItemsSource = _files; + Unloaded += (_, _) => { _cloud?.Dispose(); _cloud = null; }; + UpdateListVisibility(); + } + + private uint ParseAppId() + { + if (uint.TryParse(AppIdBox.Text?.Trim(), out uint id) && id > 0) + return id; + return 760; + } + + private void SetBusy(bool busy) + { + _busy = busy; + BusyRing.Visibility = busy ? Visibility.Visible : Visibility.Collapsed; + ConnectButton.IsEnabled = !busy; + bool connected = _cloud != null && !busy; + RefreshButton.IsEnabled = connected; + AppIdBox.IsEnabled = !busy; + UpdateSelectionUi(); + } + + private void UpdateListVisibility() + { + bool hasFiles = _files.Count > 0; + ListPanel.Visibility = hasFiles ? Visibility.Visible : Visibility.Collapsed; + EmptyHintCard.Visibility = hasFiles ? Visibility.Collapsed : Visibility.Visible; + } + + private void UpdateSelectionUi() + { + int total = _files.Count; + int sel = _files.Count(f => f.IsChecked); + SelectionText.Text = sel > 0 ? $"{sel} of {total} selected" : $"{total} file{(total == 1 ? "" : "s")}"; + bool connected = _cloud != null && !_busy; + DeleteSelectedButton.IsEnabled = connected && sel > 0; + DeleteAllButton.IsEnabled = connected && total > 0; + } + + private void LoadRows(IEnumerable<Steam760Cloud.CloudFile> files) + { + _files.Clear(); + foreach (var f in files) + { + var row = new FileRow { Name = f.Name, Size = f.Size, Timestamp = f.Timestamp }; + row.CheckedChanged = UpdateSelectionUi; + _files.Add(row); + } + UpdateListVisibility(); + UpdateSelectionUi(); + } + + private void SelectAll_Click(object sender, RoutedEventArgs e) + { + foreach (var f in _files) f.IsChecked = true; + UpdateSelectionUi(); + } + + private void ClearSelection_Click(object sender, RoutedEventArgs e) + { + foreach (var f in _files) f.IsChecked = false; + UpdateSelectionUi(); + } + + private void OpenConsoleButton_Click(object sender, RoutedEventArgs e) + { + SteamConsole.OpenConsole(); + StatusText.Text = $"Steam console opened. Run 'cloud_sync_up {ParseAppId()}' there, then Connect."; + } + + private void CopyCmdButton_Click(object sender, RoutedEventArgs e) + { + try + { + Clipboard.SetText($"cloud_sync_up {ParseAppId()}"); + StatusText.Text = $"Copied 'cloud_sync_up {ParseAppId()}' to clipboard. Paste it into the Steam console."; + } + catch { /* clipboard can transiently fail; ignore */ } + } + + private async void ConnectButton_Click(object sender, RoutedEventArgs e) + { + if (_busy) return; + uint appId = ParseAppId(); + SetBusy(true); + StatusText.Text = $"Connecting to Steam Cloud as AppID {appId}..."; + _files.Clear(); + UpdateListVisibility(); + + // Tear down any prior connection (only one SteamAPI session at a time). + _cloud?.Dispose(); + _cloud = null; + + try + { + var cloud = new Steam760Cloud(); + var result = await Task.Run(() => + { + cloud.Connect(appId); + var (total, used) = cloud.GetQuota(); + var files = cloud.ListFiles(); + return (files, total, used); + }); + + _cloud = cloud; + _connectedAppId = appId; + + LoadRows(result.files); + QuotaText.Text = $"{FormatBytes(result.used)} / {FormatBytes(result.total)} used"; + StatusText.Text = _files.Count > 0 + ? $"Loaded {_files.Count} file(s) for AppID {appId}." + : $"Connected to AppID {appId}, but no cloud files were found."; + } + catch (Exception ex) + { + _cloud?.Dispose(); + _cloud = null; + QuotaText.Text = ""; + StatusText.Text = "Error: " + ex.Message; + await Dialog.ShowErrorAsync("Steam Cloud", ex.Message); + } + finally + { + SetBusy(false); + } + } + + private async void RefreshButton_Click(object sender, RoutedEventArgs e) + { + if (_busy || _cloud == null) return; + SetBusy(true); + StatusText.Text = "Refreshing..."; + try + { + var cloud = _cloud; + uint appId = _connectedAppId; + var result = await Task.Run(() => + { + var (total, used) = cloud.GetQuota(); + var files = cloud.ListFiles(); + return (files, total, used); + }); + + LoadRows(result.files); + QuotaText.Text = $"{FormatBytes(result.used)} / {FormatBytes(result.total)} used"; + StatusText.Text = $"Loaded {_files.Count} file(s) for AppID {appId}."; + } + catch (Exception ex) + { + StatusText.Text = "Error: " + ex.Message; + await Dialog.ShowErrorAsync("Steam Cloud", ex.Message); + } + finally + { + SetBusy(false); + } + } + + private async void DeleteSelectedButton_Click(object sender, RoutedEventArgs e) + { + if (_busy) return; + var targets = _files.Where(f => f.IsChecked).Select(f => f.Name).ToList(); + if (targets.Count == 0) return; + await DeleteFiles(targets, $"Delete {targets.Count} selected cloud file(s) from AppID {_connectedAppId}? This cannot be undone."); + } + + private async void DeleteAllButton_Click(object sender, RoutedEventArgs e) + { + if (_busy) return; + var targets = _files.Select(f => f.Name).ToList(); + if (targets.Count == 0) return; + bool ok = await Dialog.ConfirmDangerCountdownAsync( + "Delete ALL cloud files", + $"This will permanently delete ALL {targets.Count} cloud file(s) for AppID {_connectedAppId}. This cannot be undone.", + 3); + if (!ok) return; + await DeleteFiles(targets, null); + } + + private async Task DeleteFiles(List<string> names, string? confirmMessage) + { + if (_cloud == null) return; + if (confirmMessage != null) + { + bool ok = await Dialog.ConfirmDangerAsync("Delete cloud files", confirmMessage); + if (!ok) return; + } + + SetBusy(true); + StatusText.Text = $"Deleting {names.Count} file(s)..."; + + try + { + var cloud = _cloud; + var (deleted, failed) = await Task.Run(() => + { + int ok = 0, bad = 0; + foreach (var n in names) + { + if (cloud.DeleteFile(n)) ok++; else bad++; + } + return (ok, bad); + }); + + StatusText.Text = $"Deleted {deleted} file(s)" + (failed > 0 ? $", {failed} failed." : "."); + } + catch (Exception ex) + { + StatusText.Text = "Error: " + ex.Message; + await Dialog.ShowErrorAsync("Steam Cloud", ex.Message); + } + finally + { + SetBusy(false); + } + + // Refresh the list to reflect deletions. + if (!_busy && _cloud != null) + RefreshButton_Click(this, new RoutedEventArgs()); + } + + private static string FormatBytes(ulong bytes) + { + string[] units = { "B", "KB", "MB", "GB", "TB" }; + double v = bytes; int u = 0; + while (v >= 1024 && u < units.Length - 1) { v /= 1024; u++; } + return u == 0 ? $"{bytes} B" : $"{v:0.##} {units[u]}"; + } +} diff --git a/ui/Pages/ManifestPinningPage.xaml b/ui/Pages/ManifestPinningPage.xaml index ae0c3225..8129e856 100644 --- a/ui/Pages/ManifestPinningPage.xaml +++ b/ui/Pages/ManifestPinningPage.xaml @@ -10,79 +10,81 @@ <conv:UrlToImageSourceConverter x:Key="UrlToImageSource" /> </Page.Resources> + <!-- One natural page scrollbar (window edge). The whole page scrolls as a + single region; the per-app list is a plain ItemsControl that grows with + content rather than owning its own scrollbar. --> <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="24"> <StackPanel MaxWidth="800"> - <TextBlock Text="{res:Loc Settings_ManifestPinning}" - FontSize="28" FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" - Margin="0,0,0,4" /> + <TextBlock Text="{res:Loc Settings_ManifestPinning}" + FontSize="28" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,0,0,4" /> - <TextBlock Text="{res:Loc Settings_ManifestPinningHint}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" - Margin="0,0,0,24" /> + <TextBlock Text="{res:Loc Settings_ManifestPinningHint}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" + Margin="0,0,0,24" /> - <ui:CardControl Margin="0,0,0,8"> - <ui:CardControl.Header> - <StackPanel> - <TextBlock Text="{res:Loc Settings_ManifestPinningEnabled}" - FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - <TextBlock Text="{res:Loc Settings_ManifestPinningEnabledHint}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" /> - </StackPanel> - </ui:CardControl.Header> - <ui:ToggleSwitch x:Name="ManifestPinningToggle" - Checked="PinToggle_Changed" - Unchecked="PinToggle_Changed" /> - </ui:CardControl> + <ui:CardControl Margin="0,0,0,8"> + <ui:CardControl.Header> + <StackPanel> + <TextBlock Text="{res:Loc Settings_ManifestPinningEnabled}" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <TextBlock Text="{res:Loc Settings_ManifestPinningEnabledHint}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" /> + </StackPanel> + </ui:CardControl.Header> + <ui:ToggleSwitch x:Name="ManifestPinningToggle" + Checked="PinToggle_Changed" + Unchecked="PinToggle_Changed" /> + </ui:CardControl> - <ui:CardControl Margin="0,0,0,32"> - <ui:CardControl.Header> - <StackPanel> - <TextBlock Text="{res:Loc Settings_AutoComment}" - FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - <TextBlock Text="{res:Loc Settings_AutoCommentHint}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" /> - </StackPanel> - </ui:CardControl.Header> - <ui:ToggleSwitch x:Name="AutoCommentToggle" - Checked="PinToggle_Changed" - Unchecked="PinToggle_Changed" /> - </ui:CardControl> + <ui:CardControl Margin="0,0,0,32"> + <ui:CardControl.Header> + <StackPanel> + <TextBlock Text="{res:Loc Settings_AutoComment}" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <TextBlock Text="{res:Loc Settings_AutoCommentHint}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" /> + </StackPanel> + </ui:CardControl.Header> + <ui:ToggleSwitch x:Name="AutoCommentToggle" + Checked="PinToggle_Changed" + Unchecked="PinToggle_Changed" /> + </ui:CardControl> - <!-- Per-app lua pins --> - <TextBlock Text="{res:Loc Pin_PerAppTitle}" - FontSize="20" FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" - Margin="0,0,0,4" /> + <!-- Per-app lua pins --> + <TextBlock Text="{res:Loc Pin_PerAppTitle}" + FontSize="20" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,0,0,4" /> - <TextBlock Text="{res:Loc Pin_PerAppHint}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" - Margin="0,0,0,16" /> + <TextBlock Text="{res:Loc Pin_PerAppHint}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" + Margin="0,0,0,16" /> - <!-- Search --> - <ui:TextBox x:Name="SearchBox" - PlaceholderText="{res:Loc Search_Placeholder}" - Width="250" - HorizontalAlignment="Left" - TextChanged="SearchBox_TextChanged" - Margin="0,0,0,16" /> + <!-- Search --> + <ui:TextBox x:Name="SearchBox" + PlaceholderText="{res:Loc Search_Placeholder}" + Width="250" + HorizontalAlignment="Left" + TextChanged="SearchBox_TextChanged" + Margin="0,0,0,16" /> - <!-- Empty state --> - <TextBlock x:Name="NoPinsText" - Text="{res:Loc Pin_NoEntries}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - Margin="0,0,0,8" - Visibility="Collapsed" /> + <!-- Empty state --> + <TextBlock x:Name="NoPinsText" + Text="{res:Loc Pin_NoEntries}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + Margin="0,0,0,8" + Visibility="Collapsed" /> - <!-- App list --> - <ItemsControl x:Name="AppList"> - <ItemsControl.ItemTemplate> + <ItemsControl x:Name="AppList"> + <ItemsControl.ItemTemplate> <DataTemplate> <Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}" @@ -173,9 +175,8 @@ </StackPanel> </Border> </DataTemplate> - </ItemsControl.ItemTemplate> - </ItemsControl> - + </ItemsControl.ItemTemplate> + </ItemsControl> </StackPanel> </ScrollViewer> </Page> diff --git a/ui/Pages/ManifestPinningPage.xaml.cs b/ui/Pages/ManifestPinningPage.xaml.cs index 79f1a66a..fb984b01 100644 --- a/ui/Pages/ManifestPinningPage.xaml.cs +++ b/ui/Pages/ManifestPinningPage.xaml.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.IO; using System.Linq; using System.Text.Json; @@ -23,12 +24,29 @@ public partial class ManifestPinningPage : Page public ManifestPinningPage() { InitializeComponent(); + + // Apply the toggle state synchronously, before first render, so the + // switches never paint Off and then snap to their real state. The pin + // config is a tiny file behind a cached Steam path, so this is cheap. + _loading = true; + try + { + var (mp, ac, pinned) = ReadPinConfig(); + ManifestPinningToggle.IsChecked = mp; + AutoCommentToggle.IsChecked = ac; + _pinnedApps.Clear(); + foreach (var id in pinned) + _pinnedApps.Add(id); + } + catch { } + finally { _loading = false; } + Loaded += async (_, _) => { _loading = true; try { - await LoadInitialDataAsync(); + await LoadAppListAsync(); await ResolveAppNamesAsync(); } finally @@ -38,39 +56,16 @@ public ManifestPinningPage() }; } - /// <summary> - /// Snapshot gathered off the UI thread so Loaded never blocks on - /// JsonDocument.Parse(pin config) or the lua dir walk. - /// </summary> - private sealed record InitialDataSnapshot( - bool ManifestPinning, - bool AutoComment, - HashSet<uint> PinnedApps, - List<LuaApp> Apps); - - // M17: Move pin-config read + lua dir scan off the UI thread. - // Loaded used to call LoadConfig + ScanLuaFiles synchronously, - // which can stall if the Steam dir is on a network drive or AV - // is scanning *.lua. Gather everything in Task.Run and only - // mutate controls / collections in the dispatcher continuation. - private async Task LoadInitialDataAsync() + // M17: Move the lua dir scan off the UI thread. The walk can stall if the + // Steam dir is on a network drive or AV is scanning *.lua, so do it in + // Task.Run and only mutate collections / controls back on the dispatcher. + // (Toggle state is applied synchronously in the ctor before first render.) + private async Task LoadAppListAsync() { - var snapshot = await Task.Run(() => - { - var (mp, ac, pinned) = ReadPinConfig(); - var apps = ScanLuaFilesOffThread(); - return new InitialDataSnapshot(mp, ac, pinned, apps); - }); - - ManifestPinningToggle.IsChecked = snapshot.ManifestPinning; - AutoCommentToggle.IsChecked = snapshot.AutoComment; - - _pinnedApps.Clear(); - foreach (var id in snapshot.PinnedApps) - _pinnedApps.Add(id); + var apps = await Task.Run(ScanLuaFilesOffThread); _apps.Clear(); - _apps.AddRange(snapshot.Apps); + _apps.AddRange(apps); ApplyPinnedState(); RefreshList(); @@ -182,19 +177,35 @@ private void ApplyPinnedState() app.IsPinned = _pinnedApps.Contains(app.AppId); } + private System.Windows.Data.ListCollectionView? _appsView; + private void RefreshList() { - var query = SearchBox?.Text?.Trim() ?? ""; - var filtered = string.IsNullOrEmpty(query) - ? _apps.ToList() - : _apps.Where(a => - a.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) || - a.AppId.ToString().Contains(query, StringComparison.OrdinalIgnoreCase)) - .ToList(); + // Use a CollectionView with a live filter so search / expand toggles + // refresh the existing virtualized containers instead of tearing down + // and rebuilding every card (which re-decodes every header image). + if (_appsView == null) + { + _appsView = (System.Windows.Data.ListCollectionView) + System.Windows.Data.CollectionViewSource.GetDefaultView(_apps); + _appsView.Filter = AppFilter; + AppList.ItemsSource = _appsView; + } + else + { + _appsView.Refresh(); + } NoPinsText.Visibility = _apps.Count == 0 ? Visibility.Visible : Visibility.Collapsed; - AppList.ItemsSource = null; - AppList.ItemsSource = filtered; + } + + private bool AppFilter(object item) + { + var query = SearchBox?.Text?.Trim() ?? ""; + if (string.IsNullOrEmpty(query)) return true; + if (item is not LuaApp a) return false; + return a.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) + || a.AppId.ToString().Contains(query, StringComparison.OrdinalIgnoreCase); } private async System.Threading.Tasks.Task ResolveAppNamesAsync() @@ -297,8 +308,7 @@ await Dialog.ShowErrorAsync( private void ExpandCollapse_Click(object sender, RoutedEventArgs e) { if (sender is not FrameworkElement { Tag: LuaApp app }) return; - app.IsExpanded = !app.IsExpanded; - RefreshList(); + app.IsExpanded = !app.IsExpanded; // INPC updates the card in place } private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) @@ -375,13 +385,33 @@ private void SaveConfig() FileUtils.AtomicWriteAllText(path, json); } - internal class LuaApp + internal class LuaApp : INotifyPropertyChanged { public uint AppId { get; set; } - public string Name { get; set; } = ""; - public string? HeaderUrl { get; set; } + + private string _name = ""; + public string Name + { + get => _name; + set { _name = value; Notify(nameof(Name)); Notify(nameof(DisplayName)); } + } + + private string? _headerUrl; + public string? HeaderUrl + { + get => _headerUrl; + set { _headerUrl = value; Notify(nameof(HeaderUrl)); } + } + public bool IsPinned { get; set; } - public bool IsExpanded { get; set; } + + private bool _isExpanded; + public bool IsExpanded + { + get => _isExpanded; + set { _isExpanded = value; Notify(nameof(IsExpanded)); Notify(nameof(ChevronSymbol)); Notify(nameof(DepotsVisibility)); } + } + public List<DepotEntry> Depots { get; set; } = new(); public string DisplayName @@ -398,6 +428,9 @@ public string DisplayName public Visibility DepotsVisibility => IsExpanded ? Visibility.Visible : Visibility.Collapsed; + + public event PropertyChangedEventHandler? PropertyChanged; + private void Notify(string n) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n)); } internal class DepotEntry diff --git a/ui/Services/EmbeddedCloud760.cs b/ui/Services/EmbeddedCloud760.cs new file mode 100644 index 00000000..7ed151fb --- /dev/null +++ b/ui/Services/EmbeddedCloud760.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Reflection; + +namespace CloudRedirect.Services; + +// Extracts the embedded 32-bit cloud760_tool.exe + steam_api.dll into a +// hash-keyed temp dir (both in the same folder so LoadLibrary finds the DLL). +internal static class EmbeddedCloud760 +{ + private const string ToolResourceName = "cloud760_tool.exe"; + private const string DllResourceName = "steam_api.dll"; + private static string? _cachedToolPath; + + // Returns the path to cloud760_tool.exe, or null if not embedded. + public static string? EnsureExtracted() + { + if (_cachedToolPath != null && File.Exists(_cachedToolPath)) + return _cachedToolPath; + + var assembly = Assembly.GetExecutingAssembly(); + using var toolStream = assembly.GetManifestResourceStream(ToolResourceName); + using var dllStream = assembly.GetManifestResourceStream(DllResourceName); + if (toolStream == null || dllStream == null) + return null; + + string baseDir = Path.Combine(Path.GetTempPath(), "CloudRedirect", "cloud760_" + ComputeResourceHash(toolStream)); + Directory.CreateDirectory(baseDir); + + string exePath = Path.Combine(baseDir, "cloud760_tool.exe"); + string dllPath = Path.Combine(baseDir, "steam_api.dll"); + + if (!File.Exists(exePath)) + { + toolStream.Position = 0; + using var ms = new MemoryStream(checked((int)toolStream.Length)); + toolStream.CopyTo(ms); + FileUtils.AtomicWriteAllBytes(exePath, ms.ToArray()); + } + + if (!File.Exists(dllPath)) + { + dllStream.Position = 0; + using var ms = new MemoryStream(checked((int)dllStream.Length)); + dllStream.CopyTo(ms); + FileUtils.AtomicWriteAllBytes(dllPath, ms.ToArray()); + } + + _cachedToolPath = exePath; + return exePath; + } + + private static string ComputeResourceHash(Stream stream) + { + stream.Position = 0; + using var sha = System.Security.Cryptography.SHA256.Create(); + var hash = sha.ComputeHash(stream); + return Convert.ToHexString(hash).Substring(0, 16); + } +} diff --git a/ui/Services/Steam760Cloud.cs b/ui/Services/Steam760Cloud.cs new file mode 100644 index 00000000..5734d549 --- /dev/null +++ b/ui/Services/Steam760Cloud.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text; + +namespace CloudRedirect.Services; + +// Views/deletes Steam Cloud files for a single AppID (default 760). Out-of-process +// driver for the 32-bit cloud760_tool.exe (the 64-bit UI can't load the 32-bit +// steam_api.dll in-process); each call spawns the tool with --porcelain and parses +// its tab-separated stdout. +public sealed class Steam760Cloud : IDisposable +{ + public sealed class CloudFile + { + public string Name { get; init; } = ""; + public int Size { get; init; } + public bool Persisted { get; init; } + public DateTime Timestamp { get; init; } + } + + private uint _appId; + private bool _connected; + private bool _disposed; + + private static string ToolPath() + { + string? exe = EmbeddedCloud760.EnsureExtracted(); + if (exe == null || !File.Exists(exe)) + throw new InvalidOperationException( + "The Steam Cloud manager tool could not be prepared (embedded resource missing)."); + return exe; + } + + private readonly struct ToolResult + { + public ToolResult(int exitCode, List<string> stdout, string stderr) + { + ExitCode = exitCode; + Stdout = stdout; + Stderr = stderr; + } + public int ExitCode { get; } + public List<string> Stdout { get; } + public string Stderr { get; } + } + + // Runs the tool (working dir = tool dir, so steam_api.dll resolves) and + // captures stdout/stderr. + private static ToolResult Run(IReadOnlyList<string> args) + { + string exe = ToolPath(); + + var psi = new ProcessStartInfo + { + FileName = exe, + WorkingDirectory = Path.GetDirectoryName(exe)!, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8, + }; + foreach (var a in args) + psi.ArgumentList.Add(a); + + using var proc = new Process { StartInfo = psi }; + + var stdout = new List<string>(); + var stderr = new StringBuilder(); + proc.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.Add(e.Data); }; + proc.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); }; + + try + { + proc.Start(); + } + catch (Exception ex) + { + throw new InvalidOperationException("Failed to launch cloud760_tool.exe: " + ex.Message, ex); + } + + proc.BeginOutputReadLine(); + proc.BeginErrorReadLine(); + + if (!proc.WaitForExit(30000)) + { + try { proc.Kill(true); } catch { } + throw new InvalidOperationException("cloud760_tool.exe timed out."); + } + proc.WaitForExit(); // flush async readers + + return new ToolResult(proc.ExitCode, stdout, stderr.ToString()); + } + + private static InvalidOperationException ToolError(ToolResult r, string fallback) + { + string msg = r.Stderr.Trim(); + // Strip Steam's breakpad/minidump stderr chatter (emitted even on success). + if (!string.IsNullOrEmpty(msg)) + { + var meaningful = new List<string>(); + foreach (var line in msg.Split('\n')) + { + var t = line.Trim(); + if (t.Length == 0) continue; + if (t.StartsWith("Setting breakpad", StringComparison.OrdinalIgnoreCase)) continue; + if (t.StartsWith("Steam_SetMinidump", StringComparison.OrdinalIgnoreCase)) continue; + meaningful.Add(t); + } + if (meaningful.Count > 0) + return new InvalidOperationException(string.Join(" ", meaningful)); + } + return new InvalidOperationException(fallback); + } + + // Validates the tool can reach Steam as appId (via a quota probe). No + // persistent session; each later call re-inits in a fresh tool process. + public void Connect(uint appId = 760) + { + if (_disposed) throw new ObjectDisposedException(nameof(Steam760Cloud)); + + var r = Run(new[] { "quota", appId.ToString(CultureInfo.InvariantCulture), "--porcelain" }); + if (r.ExitCode != 0) + throw ToolError(r, $"Could not connect to Steam Cloud as AppID {appId}. " + + "Make sure Steam is running and you are logged in."); + + _appId = appId; + _connected = true; + } + + /// <summary>Returns (totalBytes, usedBytes) for the connected AppID's cloud.</summary> + public (ulong total, ulong used) GetQuota() + { + EnsureConnected(); + var r = Run(new[] { "quota", _appId.ToString(CultureInfo.InvariantCulture), "--porcelain" }); + if (r.ExitCode != 0) + throw ToolError(r, "Failed to read cloud quota."); + + ulong total = 0, used = 0; + foreach (var line in r.Stdout) + { + var f = line.Split('\t'); + if (f.Length >= 3 && f[0] == "QUOTA") + { + ulong.TryParse(f[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out total); + ulong.TryParse(f[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out used); + } + } + return (total, used); + } + + /// <summary>Enumerates the cloud files for the connected AppID.</summary> + public List<CloudFile> ListFiles() + { + EnsureConnected(); + var r = Run(new[] { "list", _appId.ToString(CultureInfo.InvariantCulture), "--porcelain" }); + if (r.ExitCode != 0) + throw ToolError(r, "Failed to list cloud files."); + + var files = new List<CloudFile>(); + foreach (var line in r.Stdout) + { + var f = line.Split('\t'); + if (f.Length >= 4 && f[0] == "FILE") + { + int.TryParse(f[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int size); + files.Add(new CloudFile + { + Name = f[1], + Size = size, + Persisted = f[3] == "1", + }); + } + } + return files; + } + + /// <summary>Deletes a single cloud file (and forgets it so it won't re-sync).</summary> + public bool DeleteFile(string name) + { + EnsureConnected(); + if (string.IsNullOrEmpty(name)) return false; + + var r = Run(new[] { "delete", _appId.ToString(CultureInfo.InvariantCulture), name, "--porcelain" }); + // The tool returns non-zero only on a delete failure; parse the DEL line. + foreach (var line in r.Stdout) + { + var f = line.Split('\t'); + if (f.Length >= 3 && f[0] == "DEL" && f[1] == name) + return f[2] == "OK"; + } + return r.ExitCode == 0; + } + + private void EnsureConnected() + { + if (_disposed) throw new ObjectDisposedException(nameof(Steam760Cloud)); + if (!_connected) throw new InvalidOperationException("Not connected. Call Connect() first."); + } + + public void Dispose() + { + // Nothing to tear down: each operation is a self-contained child process. + _disposed = true; + _connected = false; + } +} diff --git a/ui/Services/SteamConsole.cs b/ui/Services/SteamConsole.cs new file mode 100644 index 00000000..38e8d7ae --- /dev/null +++ b/ui/Services/SteamConsole.cs @@ -0,0 +1,19 @@ +using System.Diagnostics; + +namespace CloudRedirect.Services; + +// Opens Steam's in-client developer console (for the cloud_sync_up command). +public static class SteamConsole +{ + public static void OpenConsole() + { + try + { + Process.Start(new ProcessStartInfo("steam://open/console") { UseShellExecute = true }); + } + catch + { + // best-effort + } + } +} From 22a0c68253902a916ec68626eac230bab646b9ab Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Sun, 7 Jun 2026 03:22:38 -0400 Subject: [PATCH 06/24] Harden mixed-root quota guard against compounding --- CMakeLists.txt | 22 ++++++ src/common/autocloud_scan.cpp | 58 ++++++++++++++ src/common/autocloud_scan.h | 25 ++++++ src/common/autocloud_util.h | 52 +++++++++++++ src/common/rpc_handlers.cpp | 139 ++++++++++++++++------------------ 5 files changed, 222 insertions(+), 74 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0f1d248c..4633a81c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -343,6 +343,28 @@ if(EXISTS ${CMAKE_SOURCE_DIR}/tests/autocloud_native_tests.cpp) ) target_include_directories(vdf_tests PRIVATE src/common) add_test(NAME vdf_tests COMMAND vdf_tests) + + # Mixed-root quota multiplier collision accounting (header-only logic in + # autocloud_util.h). Pulls log + platform helpers because autocloud_util.h + # includes log.h / file_util.h transitively. + if(WIN32) + add_executable(rule_collision_tests + tests/rule_collision_tests.cpp + src/platform/win/log.cpp + src/platform/win/platform_win.cpp + ) + target_include_directories(rule_collision_tests PRIVATE src/common src/platform/win) + target_compile_definitions(rule_collision_tests PRIVATE CLOUDREDIRECT_TESTING) + target_link_libraries(rule_collision_tests PRIVATE Shlwapi Advapi32 Shell32 Ole32) + else() + add_executable(rule_collision_tests + tests/rule_collision_tests.cpp + src/platform/linux/log.cpp + ) + target_include_directories(rule_collision_tests PRIVATE src/common src/platform/linux) + target_compile_definitions(rule_collision_tests PRIVATE CLOUDREDIRECT_TESTING) + endif() + add_test(NAME rule_collision_tests COMMAND rule_collision_tests) endif() # ── UI (Windows only) ─────────────────────────────────────────────────── diff --git a/src/common/autocloud_scan.cpp b/src/common/autocloud_scan.cpp index a872ae21..096fb333 100644 --- a/src/common/autocloud_scan.cpp +++ b/src/common/autocloud_scan.cpp @@ -872,6 +872,24 @@ ScanResult GetFileList(const std::string& steamPath, std::unordered_map<std::string, std::string> seenRootsByCloudPath; // Sibling dedupe; separate from primary so siblings can't trip the abort. std::unordered_set<std::string> emittedSiblings; + + // Per-rule double-count accounting (see ScanResult comment). YldOnAppExit + // counts each on-disk file once per matching rule, with NO cross-rule dedup -- + // unlike `files` below, which is deduped via seenRootsByCloudPath. We therefore + // tally claims here BEFORE that dedup, so countedInstances reflects what the + // native exit loop actually charges against maxnumfiles. + // + // Each rule that reaches a valid scan dir contributes one AutoCloudRuleClaimTally; + // AggregateRuleCollisions() turns these into countedInstances / collision factor + // / headroom. Keying by the resolved physical dir is what makes this correct + // across Windows/macOS/Linux roots: a Mac-only or Linux-only rule simply never + // resolves to a dir on this OS (platform-filtered or no root mapping), so it is + // absent from the tallies and correctly contributes nothing. + std::vector<AutoCloudUtil::AutoCloudRuleClaimTally> ruleClaimTallies; + // Index into ruleClaimTallies for the rule currently being walked, so the + // considerFile lambda can attribute claims without re-resolving the dir. + size_t activeRuleTallyIdx = static_cast<size_t>(-1); + bool hasRootCollision = false; bool scanLimitHit = false; size_t visitedFiles = 0; @@ -972,6 +990,26 @@ ScanResult GetFileList(const std::string& steamPath, std::string scanRootPrefix = FileUtil::MakePathPrefix(scanRootUtf8); + // Attribution key for double-count accounting: the resolved physical scan + // directory (normalized, case-insensitive). Two effective rules with this + // same key are the collision that YldOnAppExit double-counts. recursive is + // folded in because a recursive and non-recursive rule on the same dir do + // not claim an identical file set, so they should not be treated as one + // collision group for the multiplication factor. + { + // This rule reached a valid, existing scan dir -> it participates in the + // exit walk. Register one tally; claims accrue into it via considerFile. + // recursive is folded into the dir key because a recursive and a + // non-recursive rule on the same dir do not claim an identical file set, + // so they are not one collision group for the multiplication factor. + AutoCloudUtil::AutoCloudRuleClaimTally tally; + tally.physicalDirKey = ToLowerAscii(NormalizeSlashes(scanRootUtf8)) + + (rule.recursive ? "\x01r" : "\x01n"); + tally.siblingWeight = 1 + rule.siblings.size(); + activeRuleTallyIdx = ruleClaimTallies.size(); + ruleClaimTallies.push_back(std::move(tally)); + } + auto considerFile = [&](const std::filesystem::directory_entry& entry) { std::error_code fileEc; // Junction/symlink gate before is_regular_file. @@ -1001,6 +1039,13 @@ ScanResult GetFileList(const std::string& steamPath, if (WildcardMatchInsensitive(exPat, exTarget)) return; } + // This file matches the current rule's pattern -> YldOnAppExit's exit + // loop counts it for THIS rule. Tally the claim now, BEFORE the + // cross-rule dedup below (which only affects our unique `files` list, + // not the native exit count). + if (activeRuleTallyIdx < ruleClaimTallies.size()) + ++ruleClaimTallies[activeRuleTallyIdx].claimedFiles; + std::string cloudPath = normalizedCloudPath.empty() ? relFromRoot : normalizedCloudPath + "/" + relFromRoot; std::string collisionKey = ToLowerAscii(NormalizeSlashes(cloudPath)); auto seenIt = seenRootsByCloudPath.find(collisionKey); @@ -1036,6 +1081,10 @@ ScanResult GetFileList(const std::string& steamPath, siblingRel = NormalizeSlashes(FileUtil::PathToUtf8(siblingPath.filename())); } if (!IsSafeRelativePath(siblingRel)) continue; + // Existing sibling on disk -> YldOnAppExit counts it for this rule + // too. Tally before dedup, same as the primary claim above. + if (activeRuleTallyIdx < ruleClaimTallies.size()) + ++ruleClaimTallies[activeRuleTallyIdx].claimedFiles; std::string siblingCloudPath = normalizedCloudPath.empty() ? siblingRel : normalizedCloudPath + "/" + siblingRel; @@ -1094,6 +1143,15 @@ ScanResult GetFileList(const std::string& steamPath, LOG("GetAutoCloudFileList: aborting app %u bootstrap due to root/path collision", appId); } + // Finalize per-rule double-count accounting for the quota multiplier. These + // describe what CAutoCloudManager::YldOnAppExit charges against maxnumfiles, + // which (unlike outResult.files) is NOT cross-rule deduped on the exit path. + AutoCloudUtil::RuleCollisionAggregate agg = + AutoCloudUtil::AggregateRuleCollisions(ruleClaimTallies); + outResult.countedInstances = agg.countedInstances; + outResult.maxCollisionFactor = agg.maxCollisionFactor; + outResult.collisionSiblingHeadroom = agg.collisionSiblingHeadroom; + LOG("GetAutoCloudFileList: found %zu rule-matched Auto-Cloud files for app %u (scanLimitHit=%d, hasRootCollision=%d)", outResult.files.size(), appId, (int)scanLimitHit, (int)hasRootCollision); for (const auto& fe : outResult.files) { diff --git a/src/common/autocloud_scan.h b/src/common/autocloud_scan.h index cdb116b0..f896b23d 100644 --- a/src/common/autocloud_scan.h +++ b/src/common/autocloud_scan.h @@ -29,6 +29,31 @@ struct ScanResult { bool complete = false; // true if scan completed without truncation or collision bool hasRules = false; // true if app has AutoCloud rules in appinfo.vdf bool hasRootCollision = false; // true if two rules resolved to same path under different roots + + // Per-rule double-count accounting for the mixed-root quota multiplier. + // + // Steam's CAutoCloudManager::YldOnAppExit walks the savefiles rules and counts + // each matching file once PER RULE -- the native per-rule dedup (sub_1384D1DA0 + // @ 0x1384d221a) is dead on the exit path because YldOnAppExit seeds it with a + // null "previous path" (it is only live on the staging/save path). So when two + // effective-platform rules resolve to the same directory (via rootoverrides), + // every file there is counted twice against maxnumfiles -> false over-quota -> + // cloud wipe. `files` below is the UNIQUE set (cross-rule deduped at scan time); + // these two fields capture what the native exit loop actually counts so the + // multiplier can size the budget to the real worst case rather than a blunt + // fileCount*ruleCount. + + // Total file-claims summed across all effective rules (counts a file once for + // each rule, including siblings, whose resolved scan dir + pattern match it). + // This equals the instance count YldOnAppExit charges against maxnumfiles. + size_t countedInstances = 0; + // Largest number of effective rules that resolve to (and claim files in) the + // same physical directory. 1 = no collision (native dedup irrelevant, no wipe + // risk); >1 = the per-file multiplication factor on the exit path. + size_t maxCollisionFactor = 0; + // Sum of (1 + siblingCount) over rules that participate in a collision; mirrors + // the extra budget YldOnAppExit's loop consumes per file beyond the raw count. + size_t collisionSiblingHeadroom = 0; }; // Scan AutoCloud rules for an app and return matching files from disk. diff --git a/src/common/autocloud_util.h b/src/common/autocloud_util.h index 13419157..c1c5609d 100644 --- a/src/common/autocloud_util.h +++ b/src/common/autocloud_util.h @@ -10,6 +10,7 @@ #include <chrono> #include <filesystem> #include <sstream> +#include <unordered_map> #include <utility> #ifdef _WIN32 @@ -171,6 +172,57 @@ struct AutoCloudRootOverrideNative { std::vector<std::pair<std::string, std::string>> pathTransforms; }; +// Per-rule tally produced by the AutoCloud scan walk: how many on-disk files +// (primaries + existing siblings) the rule claimed, the resolved physical +// directory it scanned (case-normalized + recursive flag), and its sibling +// weight (1 + siblingCount). Feeds RuleCollisionAggregate below. +struct AutoCloudRuleClaimTally { + std::string physicalDirKey; // normalized resolved scan dir (+ recursion tag) + size_t claimedFiles = 0; // files this rule matched on disk (pre cross-rule dedup) + size_t siblingWeight = 1; // 1 + rule.siblings.size() +}; + +// What CAutoCloudManager::YldOnAppExit charges against maxnumfiles, derived from +// per-rule claim tallies. This is the pure, filesystem-free core of the mixed-root +// quota multiplier so it can be unit-tested across Windows/macOS/Linux rule shapes. +struct RuleCollisionAggregate { + // Sum of claimedFiles across all rules == the per-rule, NON-deduped instance + // count the exit walk charges (a file in a dir hit by N rules counts N times). + size_t countedInstances = 0; + // Largest number of distinct rules resolving to one physical directory == the + // per-file multiplication factor on the exit path. 1 = no collision. + size_t maxCollisionFactor = 0; + // Budget slack for the worst-colliding dir: (#rules there) * (max siblingWeight + // there), mirroring the (1 + siblingCount) the exit loop consumes per rule pass. + size_t collisionSiblingHeadroom = 0; +}; + +// Aggregate per-rule claim tallies into the exit-walk accounting. Rules that +// claimed zero files still count toward the collision factor IF they resolved to a +// shared directory (they participate in the walk), but only directories that other +// rules also resolve to can produce a factor > 1. macOS/Linux-only rules that +// never resolve to a dir on this OS are simply absent from `tallies` and thus +// correctly contribute nothing. +inline RuleCollisionAggregate AggregateRuleCollisions( + const std::vector<AutoCloudRuleClaimTally>& tallies) { + struct DirAgg { size_t rules = 0; size_t maxSiblingWeight = 1; }; + std::unordered_map<std::string, DirAgg> byDir; + RuleCollisionAggregate out; + for (const auto& t : tallies) { + out.countedInstances += t.claimedFiles; + DirAgg& d = byDir[t.physicalDirKey]; + ++d.rules; + if (t.siblingWeight > d.maxSiblingWeight) d.maxSiblingWeight = t.siblingWeight; + } + for (const auto& [dir, d] : byDir) { + if (d.rules > out.maxCollisionFactor) { + out.maxCollisionFactor = d.rules; + out.collisionSiblingHeadroom = d.rules * d.maxSiblingWeight; + } + } + return out; +} + struct AppInfoKVNode { std::string key; std::string stringValue; diff --git a/src/common/rpc_handlers.cpp b/src/common/rpc_handlers.cpp index b5ae742a..8e96a2b6 100644 --- a/src/common/rpc_handlers.cpp +++ b/src/common/rpc_handlers.cpp @@ -223,65 +223,71 @@ void FlushPendingSyncStates() {} static constexpr uint64_t kFallbackQuotaBytes = 1073741824ULL; // 1 GB static constexpr uint32_t kFallbackMaxFiles = 10000; -// Cache each app's ORIGINAL (dev/PICS) ufs quota the first time we observe it, -// before any rule-multiplier scaling, so the scaling stays idempotent across the -// many EnsureAppQuotaInjected calls per session (otherwise re-reading the live -// KV would compound the multiplier each call). -struct OriginalQuota { uint64_t quotaBytes; uint32_t maxNumFiles; }; -static std::unordered_map<uint32_t, OriginalQuota> g_originalQuota; -static std::mutex g_originalQuotaMutex; - -// Resolve the dev's original ufs quota for an app. On first sight, seeds the -// cache from the live KV (which has not yet been scaled this session). Returns -// the cached original via in/out params. -static void GetOriginalQuota(uint32_t appId, uint64_t& quotaBytes, - uint32_t& maxNumFiles) { - std::lock_guard<std::mutex> lock(g_originalQuotaMutex); - auto it = g_originalQuota.find(appId); - if (it == g_originalQuota.end()) { - g_originalQuota[appId] = OriginalQuota{quotaBytes, maxNumFiles}; - return; - } - quotaBytes = it->second.quotaBytes; - maxNumFiles = it->second.maxNumFiles; -} - -// When an app has >1 savefiles rule, Steam's AC-exit disk walk counts each file -// once per rule. Scale the live ufs budget by the rule count so colliding rules -// (rootoverrides resolving to the same path) can't trip a false "over quota" -// that deletes all cloud files. Idempotent: derives from the cached original. -static void EnsureQuotaSurvivesRuleMultiplier(uint32_t appId, +// Steam's AC-exit disk walk (CAutoCloudManager::YldOnAppExit) counts each save +// file once PER RULE that matches it -- the native per-rule dedup is dead on the +// exit path (sub_1384D1DA0 @ 0x1384d221a is seeded with a null "previous path" +// there; it is only live on the staging/save path). So when two effective-platform +// rules resolve to the SAME physical directory (via rootoverrides), every file +// there is counted twice against maxnumfiles, tripping a false "over quota" that +// deletes all cloud files (e.g. app 1583520: 5 files x 2 colliding rules = 10 > +// maxnumfiles=5 -> wipe). +// +// We size the live maxnumfiles to the EXACT instance count that exit walk charges, +// computed by GetFileList (ScanResult.countedInstances) which mirrors the native +// per-rule, pre-dedup counting -- including siblings and macOS/Linux-only rules +// that simply never resolve to a dir on this OS and therefore add nothing. Unlike +// the old fileCount*ruleCount estimate this is collision-aware: an app whose rules +// resolve to DISTINCT paths reports maxCollisionFactor==1 and we no-op (no wipe +// risk), and an app with partial collisions (e.g. 3 rules, 2 colliding) gets x2, +// not x3. +// +// All inputs are immutable on-disk facts we never write, so the target can NEVER +// compound across sessions (a prior fix scaled the LIVE maxnumfiles and read its +// own previous bump back, running away 5->31->109->343). We only RAISE the budget. +static void EnsureQuotaSurvivesRuleMultiplier(uint32_t accountId, uint32_t appId, uint64_t liveQuota, uint32_t liveFiles) { std::string steamPath = CloudIntercept::GetSteamPath(); if (steamPath.empty()) return; - size_t ruleCount = 0; + + AutoCloudScan::ScanResult scan; try { - ruleCount = AutoCloudScan::GetRules(steamPath, appId).size(); + scan = AutoCloudScan::GetFileList(steamPath, accountId, appId); } catch (...) { return; } - if (ruleCount <= 1) return; // single rule -> no per-rule double-count - - uint64_t origQuota = liveQuota; - uint32_t origFiles = liveFiles; - GetOriginalQuota(appId, origQuota, origFiles); - - // Effective budget must cover original_per_file_budget * ruleCount instances, - // PLUS headroom: Steam's YldOnAppExit budget loop also subtracts apireserve* - // and decrements the file budget by (1 + siblingCount) per file, so an exact - // fileCount*ruleCount budget still trips. Add a full extra rule-multiple of - // slack so the colliding duplicates never reach the cutoff. - uint64_t targetFiles = static_cast<uint64_t>(origFiles) * (ruleCount + 1) + 16; - uint64_t targetQuota = origQuota * (ruleCount + 1); - if (targetFiles <= liveFiles && targetQuota <= liveQuota) { - return; // already sufficient (idempotent no-op) - } - SteamKvInjector::SetAppQuota(appId, targetQuota, - static_cast<uint32_t>(targetFiles)); - LOG("[NS] EnsureQuotaSurvivesRuleMultiplier app=%u: %zu rules -> set budget " - "files=%u->%llu quota=%llu->%llu (original files=%u quota=%llu)", - appId, ruleCount, liveFiles, (unsigned long long)targetFiles, - (unsigned long long)liveQuota, (unsigned long long)targetQuota, - origFiles, (unsigned long long)origQuota); + + // A collision factor <= 1 means every effective rule resolves to a distinct + // physical directory: the native exit walk counts each file once, exactly like + // the dedup-protected staging path, so there is no over-quota risk to cover. + if (scan.maxCollisionFactor <= 1) return; + if (scan.countedInstances == 0) return; + + // neededFiles = exact instances the exit walk charges + derived slack. The + // slack replaces the old magic +16: YldOnAppExit's loop also consumes + // (1 + siblingCount) budget per colliding rule pass, captured per worst dir as + // collisionSiblingHeadroom. Keep a small floor so we are never under by 1-2. + size_t derivedHeadroom = scan.collisionSiblingHeadroom; + if (derivedHeadroom < scan.maxCollisionFactor) derivedHeadroom = scan.maxCollisionFactor; + uint64_t needed = static_cast<uint64_t>(scan.countedInstances) + derivedHeadroom; + + // Guard against pathological inputs producing an implausible budget. + constexpr uint64_t kMaxPlausibleFiles = 1000000ULL; + if (needed > kMaxPlausibleFiles) needed = kMaxPlausibleFiles; + uint32_t neededFiles = static_cast<uint32_t>(needed); + if (neededFiles <= liveFiles) return; // current budget already covers it + + // Raise quota bytes proportionally too (rough: keep per-file budget constant). + uint64_t neededQuota = liveFiles > 0 + ? liveQuota * neededFiles / liveFiles + : liveQuota; + if (neededQuota < liveQuota) neededQuota = liveQuota; + + SteamKvInjector::SetAppQuota(appId, neededQuota, neededFiles); + LOG("[NS] EnsureQuotaSurvivesRuleMultiplier app=%u: %zu unique files, " + "%zu counted instances x%zu collision (+%zu headroom) -> " + "raise maxnumfiles %u->%u quota %llu->%llu", + appId, scan.files.size(), scan.countedInstances, scan.maxCollisionFactor, + derivedHeadroom, liveFiles, neededFiles, + (unsigned long long)liveQuota, (unsigned long long)neededQuota); } static bool EnsureAppQuotaInjected(uint32_t accountId, uint32_t appId, @@ -296,35 +302,20 @@ static bool EnsureAppQuotaInjected(uint32_t accountId, uint32_t appId, bool readOk = SteamKvInjector::ReadAppQuota(appId, existingQuota, existingFiles); if (readOk && existingQuota > 0 && existingFiles > 0) { - // The live KV may already carry our rule-multiplier scaling from an - // earlier call this session. Resolve the dev's ORIGINAL value (cached on - // first sight) so we never cache/propagate a scaled budget. - uint64_t origQuota = existingQuota; - uint32_t origFiles = existingFiles; - GetOriginalQuota(appId, origQuota, origFiles); - if (cloudState && - (cloudState->quota.quotaBytes != origQuota || - cloudState->quota.maxNumFiles != origFiles)) { - cloudState->quota.quotaBytes = origQuota; - cloudState->quota.maxNumFiles = origFiles; + (cloudState->quota.quotaBytes != existingQuota || + cloudState->quota.maxNumFiles != existingFiles)) { + cloudState->quota.quotaBytes = existingQuota; + cloudState->quota.maxNumFiles = existingFiles; cloudState->quota.fetchedAtUnix = static_cast<uint64_t>(time(nullptr)); cloudState->quota.lastSeenBuildId = cloudState->appBuildId; LOG("[NS] EnsureAppQuotaInjected app=%u: caching PICS quota=%llu files=%u (publish deferred to next batch)", - appId, (unsigned long long)origQuota, origFiles); + appId, (unsigned long long)existingQuota, existingFiles); // Quota persisted on next CompleteBatch; async publish risks overwriting newer state. } LOG("[NS] EnsureAppQuotaInjected app=%u: Steam has quota=%llu files=%u", appId, (unsigned long long)existingQuota, existingFiles); - // Mixed-root collision guard: when an app has >1 savefiles rule, Steam's - // exit disk-walk counts each file once PER RULE. Apps whose rules resolve - // to the same path on this OS (via rootoverrides) get fileCount*ruleCount - // instances counted against maxnumfiles -> spurious "over quota" eviction - // that DELETES all cloud files (e.g. app 1583520: 2 rules, maxnumfiles=5, - // 5 files -> 10 instances > 5 -> wipe). Scale the live budget by the rule - // count so the dev's effective per-file budget is preserved. Idempotent: - // computed from the cached ORIGINAL value, then set (not multiplied). - EnsureQuotaSurvivesRuleMultiplier(appId, existingQuota, existingFiles); + EnsureQuotaSurvivesRuleMultiplier(accountId, appId, existingQuota, existingFiles); return true; } From 4ec9a9b32e69b17dc7ec53330a8f032b3e498e7c Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Sun, 7 Jun 2026 13:39:08 -0400 Subject: [PATCH 07/24] Add CR_SetApps export to atomically sync namespace app set --- src/common/cr_api.h | 4 ++++ src/platform/win/cloud_intercept.cpp | 24 ++++++++++++++++++++++++ src/platform/win/cloud_intercept.h | 3 +++ src/platform/win/cr_api.cpp | 9 +++++++++ 4 files changed, 40 insertions(+) diff --git a/src/common/cr_api.h b/src/common/cr_api.h index 83219c69..4df700dd 100644 --- a/src/common/cr_api.h +++ b/src/common/cr_api.h @@ -39,4 +39,8 @@ CR_API bool CR_HandleCloudRpc(const char* method, uint32_t appId, CR_API void CR_AddApp(uint32_t appId); CR_API void CR_RemoveApp(uint32_t appId); CR_API bool CR_IsApp(uint32_t appId); + +// Replace the namespace-app set with the given list. NULL/0 clears it. +CR_API void CR_SetApps(const uint32_t* appIds, uint32_t count); + CR_API void CR_Shutdown(void); diff --git a/src/platform/win/cloud_intercept.cpp b/src/platform/win/cloud_intercept.cpp index 986bee83..2842885b 100644 --- a/src/platform/win/cloud_intercept.cpp +++ b/src/platform/win/cloud_intercept.cpp @@ -455,6 +455,30 @@ void RemoveNamespaceApp(uint32_t appId) { g_namespaceApps.erase(appId); } +// Replace the namespace-app set; reports add/remove counts for logging. +void SetNamespaceApps(const uint32_t* appIds, uint32_t count, + size_t* outAdded, size_t* outRemoved) { + std::unordered_set<uint32_t> next; + next.reserve(count); + for (uint32_t i = 0; i < count; ++i) { + if (appIds[i] != 0) next.insert(appIds[i]); + } + std::lock_guard<std::mutex> lock(g_namespaceAppsMutex); + if (outAdded) { + size_t added = 0; + for (uint32_t id : next) + if (g_namespaceApps.count(id) == 0) ++added; + *outAdded = added; + } + if (outRemoved) { + size_t removed = 0; + for (uint32_t id : g_namespaceApps) + if (next.count(id) == 0) ++removed; + *outRemoved = removed; + } + g_namespaceApps = std::move(next); +} + // per-app launch timestamp for internal playtime tracking static std::mutex g_launchTimeMutex; static std::unordered_map<uint32_t, time_t> g_launchTimes; diff --git a/src/platform/win/cloud_intercept.h b/src/platform/win/cloud_intercept.h index a4801a6a..d6a0c01a 100644 --- a/src/platform/win/cloud_intercept.h +++ b/src/platform/win/cloud_intercept.h @@ -56,6 +56,9 @@ void RecordLaunchTime(uint32_t appId); void AddNamespaceApp(uint32_t appId); void RemoveNamespaceApp(uint32_t appId); bool IsNamespaceApp(uint32_t appId); +// outAdded/outRemoved may be null. +void SetNamespaceApps(const uint32_t* appIds, uint32_t count, + size_t* outAdded, size_t* outRemoved); // signal shutdown void Shutdown(); diff --git a/src/platform/win/cr_api.cpp b/src/platform/win/cr_api.cpp index 71a2526a..45539748 100644 --- a/src/platform/win/cr_api.cpp +++ b/src/platform/win/cr_api.cpp @@ -133,6 +133,15 @@ bool CR_IsApp(uint32_t appId) { return CloudIntercept::IsNamespaceApp(appId); } +void CR_SetApps(const uint32_t* appIds, uint32_t count) { + if (count != 0 && appIds == nullptr) count = 0; + size_t added = 0, removed = 0; + CloudIntercept::SetNamespaceApps(appIds, count, &added, &removed); + if (g_crInitDone.load(std::memory_order_acquire)) + LOG("[CR_API] SetApps: %u app(s) (%zu added, %zu removed)", + count, added, removed); +} + void CR_Shutdown(void) { if (g_crInitDone.load(std::memory_order_acquire)) { LOG("[CR_API] Shutdown requested"); From 182937a7d26f45cd47d2c0df155059848e948565 Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:15:58 -0400 Subject: [PATCH 08/24] Reorganize Settings and restore the Cleanup tab --- ui/MainWindow.xaml | 4 + ui/MainWindow.xaml.cs | 1 + ui/Pages/CleanupPage.xaml | 191 ++++++ ui/Pages/CleanupPage.xaml.cs | 1079 +++++++++++++++++++++++++++++++++ ui/Pages/Cloud760Page.xaml | 2 +- ui/Pages/SettingsPage.xaml | 108 ++-- ui/Pages/SettingsPage.xaml.cs | 50 +- ui/Resources/Strings.resx | 32 +- ui/Services/Dialog.cs | 59 +- 9 files changed, 1442 insertions(+), 84 deletions(-) create mode 100644 ui/Pages/CleanupPage.xaml create mode 100644 ui/Pages/CleanupPage.xaml.cs diff --git a/ui/MainWindow.xaml b/ui/MainWindow.xaml index e2efe863..8da9a9c6 100644 --- a/ui/MainWindow.xaml +++ b/ui/MainWindow.xaml @@ -106,6 +106,10 @@ <ui:NavigationViewItem x:Name="NavCleanup" Icon="{ui:SymbolIcon Delete24}" Content="{res:Loc Nav_Cleanup}" + TargetPageType="{x:Type pages:CleanupPage}" /> + <ui:NavigationViewItem x:Name="NavCloud760" + Icon="{ui:SymbolIcon CloudDismiss24}" + Content="760 Cleanup" TargetPageType="{x:Type pages:Cloud760Page}" /> <ui:NavigationViewItem Content="{res:Loc Nav_ManifestPinning}" Icon="{ui:SymbolIcon Pin24}" diff --git a/ui/MainWindow.xaml.cs b/ui/MainWindow.xaml.cs index 3ccc4934..1e315e99 100644 --- a/ui/MainWindow.xaml.cs +++ b/ui/MainWindow.xaml.cs @@ -50,6 +50,7 @@ public void ApplyMode(string? mode) NavCloudProvider.Visibility = vis; NavApps.Visibility = vis; NavCleanup.Visibility = vis; + NavCloud760.Visibility = vis; // Hide the mode chooser once fully committed to cloud_redirect NavChoiceMode.Visibility = cloudOnly ? Visibility.Collapsed : Visibility.Visible; diff --git a/ui/Pages/CleanupPage.xaml b/ui/Pages/CleanupPage.xaml new file mode 100644 index 00000000..988afa05 --- /dev/null +++ b/ui/Pages/CleanupPage.xaml @@ -0,0 +1,191 @@ +<Page x:Class="CloudRedirect.Pages.CleanupPage" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + xmlns:res="clr-namespace:CloudRedirect.Resources" + ScrollViewer.CanContentScroll="False"> + + <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="24"> + <StackPanel MaxWidth="800"> + + <TextBlock Text="{res:Loc Cleanup_Title}" + FontSize="28" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,0,0,4" /> + <TextBlock Text="{res:Loc Cleanup_Description}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" + Margin="0,0,0,24" /> + + <Border x:Name="OstContextBanner" + Visibility="Collapsed" + Background="{DynamicResource ControlFillColorDefaultBrush}" + BorderBrush="{DynamicResource SystemAccentColorPrimaryBrush}" + BorderThickness="1" + CornerRadius="8" + Padding="16" + Margin="0,0,0,16"> + <StackPanel> + <TextBlock Text="{res:Loc Cleanup_OstBanner_Title}" + FontSize="14" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <TextBlock Text="{res:Loc Cleanup_OstBanner_Description}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" + Margin="0,4,0,0" /> + </StackPanel> + </Border> + + <StackPanel x:Name="ScanCleanPanel"> + + <WrapPanel Margin="0,0,0,16"> + <ui:Button x:Name="ScanButton" + Content="{res:Loc Cleanup_ScanForContamination}" + Icon="{ui:SymbolIcon Search24}" + Appearance="Primary" + Click="ScanButton_Click" + Margin="0,0,8,0" /> + <ui:Button x:Name="RestoreButton" + Content="{res:Loc Cleanup_RestoreFromBackup}" + Icon="{ui:SymbolIcon FolderOpen24}" + Click="RestoreButton_Click" + Margin="0,0,8,0" /> + <TextBlock x:Name="ScanStatus" + Text="" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + VerticalAlignment="Center" /> + </WrapPanel> + + <Border x:Name="UndoBanner" + Visibility="Collapsed" + Background="#303080E0" + BorderBrush="#603080E0" + BorderThickness="1" + CornerRadius="8" + Padding="16" + Margin="0,0,0,16"> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <StackPanel Grid.Column="0" VerticalAlignment="Center"> + <TextBlock x:Name="UndoBannerText" + Text="" + FontSize="14" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <TextBlock Text="{res:Loc Cleanup_UndoBannerDescription}" + FontSize="12" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" + Margin="0,2,0,0" /> + </StackPanel> + <StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center" Margin="12,0,0,0"> + <ui:Button x:Name="UndoButton" + Content="{res:Loc Cleanup_Undo}" + Icon="{ui:SymbolIcon ArrowUndo24}" + Appearance="Primary" + Click="UndoButton_Click" + Margin="0,0,8,0" /> + <ui:Button x:Name="UndoDismissButton" + Content="{res:Loc Cleanup_Dismiss}" + Click="UndoDismiss_Click" /> + </StackPanel> + </Grid> + </Border> + + <StackPanel x:Name="LoadingPanel" + Visibility="Collapsed" + HorizontalAlignment="Center" + Margin="0,40,0,40"> + <ui:ProgressRing IsIndeterminate="True" Width="48" Height="48" Margin="0,0,0,12" /> + <TextBlock Text="{res:Loc Cleanup_ScanningApps}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + HorizontalAlignment="Center" /> + </StackPanel> + + <Border x:Name="NukePanel" + Visibility="Collapsed" + Background="#30FF3030" + BorderBrush="#60FF3030" + BorderThickness="1" + CornerRadius="8" + Padding="20" + Margin="0,0,0,20"> + <StackPanel> + <TextBlock Text="{res:Loc Cleanup_DangerZone}" + FontSize="18" FontWeight="Bold" + Foreground="#FFE04040" + Margin="0,0,0,6" /> + <TextBlock x:Name="NukeDescription" + Text="" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" + Margin="0,0,0,12" /> + <ui:Button x:Name="NukeButton" + Content="{res:Loc Cleanup_CleanAllSuspectFiles}" + Icon="{ui:SymbolIcon Delete24}" + Appearance="Danger" + Click="NukeButton_Click" /> + </StackPanel> + </Border> + + <ui:TextBox x:Name="GameSearchBox" + PlaceholderText="{res:Loc Search_Placeholder}" + Width="300" + HorizontalAlignment="Left" + TextChanged="GameSearchBox_TextChanged" + Visibility="Collapsed" + Margin="0,0,0,12" /> + + <StackPanel x:Name="GameListPanel" + Visibility="Collapsed" /> + + </StackPanel> + + <StackPanel x:Name="RestorePanel" Visibility="Collapsed"> + + <WrapPanel Margin="0,0,0,16"> + <ui:Button x:Name="BackButton" + Content="{res:Loc Cleanup_Back}" + Icon="{ui:SymbolIcon ArrowUndo24}" + Click="BackToScan_Click" + Margin="0,0,8,0" /> + <ui:Button x:Name="RefreshBackupsButton" + Content="{res:Loc Cleanup_RefreshBackups}" + Icon="{ui:SymbolIcon Search24}" + Click="RefreshBackupsButton_Click" + Margin="0,0,8,0" /> + <ui:TextBox x:Name="RestoreSearchBox" + PlaceholderText="{res:Loc Search_Placeholder}" + Width="250" + TextChanged="RestoreSearchBox_TextChanged" + Margin="0,0,8,0" /> + <TextBlock x:Name="RestoreStatus" + Text="" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + VerticalAlignment="Center" /> + </WrapPanel> + + <TextBlock Text="{res:Loc Cleanup_RestoreDescription}" + Foreground="{DynamicResource TextFillColorTertiaryBrush}" + TextWrapping="Wrap" + Margin="0,0,0,16" /> + + <StackPanel x:Name="RestoreLoadingPanel" + Visibility="Collapsed" + HorizontalAlignment="Center" + Margin="0,40,0,40"> + <ui:ProgressRing IsIndeterminate="True" Width="48" Height="48" Margin="0,0,0,12" /> + <TextBlock Text="{res:Loc Cleanup_LoadingBackups}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + HorizontalAlignment="Center" /> + </StackPanel> + + <StackPanel x:Name="BackupListPanel" /> + + </StackPanel> + + </StackPanel> + </ScrollViewer> +</Page> diff --git a/ui/Pages/CleanupPage.xaml.cs b/ui/Pages/CleanupPage.xaml.cs new file mode 100644 index 00000000..27e2abfa --- /dev/null +++ b/ui/Pages/CleanupPage.xaml.cs @@ -0,0 +1,1079 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using CloudRedirect.Resources; +using CloudRedirect.Services; + +namespace CloudRedirect.Pages; + +internal class LastCleanupState +{ + public string Utc { get; set; } = ""; + public int FileCount { get; set; } +} + +[JsonSerializable(typeof(LastCleanupState))] +internal partial class CleanupStateJsonContext : JsonSerializerContext { } + +public partial class CleanupPage : Page +{ + private readonly SteamStoreClient _storeClient = SteamStoreClient.Shared; + private Dictionary<uint, StoreAppInfo> _storeCache = new(); + private string? _steamPath; + private List<AppScanResult>? _scanResults; + + // Filtered list used for display (retained for search to rebuild from) + private List<AppScanResult>? _displayedApps; + + // Track whether backups have been loaded for the restore tab + private bool _backupsLoaded; + private List<BackupInfo>? _backups; + + // Reuse the CloudCleanup instance from the last scan (has populated _namespaceApps, _appConfigs, etc.) + private CloudCleanup? _cleanup; + + // Track whether a scan has been performed (for back button label) + private bool _hasScanned; + + // Track the most recent cleanup for undo banner + backup highlighting + private DateTime? _lastCleanupUtc; + private int _lastCleanupFileCount; + private bool _bannerChecked; + + public CleanupPage() + { + InitializeComponent(); + Loaded += CleanupPage_Loaded; + } + + private async void CleanupPage_Loaded(object sender, RoutedEventArgs e) + { + // Restore persisted undo banner from a previous session (once) + if (_bannerChecked) return; + _bannerChecked = true; + + var steamPath = await Task.Run(() => SteamDetector.FindSteamPath()); + if (steamPath == null) return; + _steamPath = steamPath; + + var saved = LoadCleanupState(steamPath); + if (saved == null) return; + + // Verify the backup still exists on disk before showing the banner + var backups = await Task.Run(() => BackupDiscovery.ListCleanupBackups(steamPath)); + var cutoff = saved.Value.utc.AddSeconds(-5); + if (!backups.Any(b => b.Timestamp >= cutoff)) + { + ClearCleanupState(steamPath); // stale state, clean up + return; + } + + _lastCleanupUtc = saved.Value.utc; + ShowUndoBanner(saved.Value.fileCount); + } + + private void RestoreButton_Click(object sender, RoutedEventArgs e) + { + ScanCleanPanel.Visibility = Visibility.Collapsed; + RestorePanel.Visibility = Visibility.Visible; + BackButton.Content = _hasScanned ? S.Get("Cleanup_BackToScan") : S.Get("Cleanup_Back"); + + // Auto-load backups on first visit + if (!_backupsLoaded) + { + LoadBackups(); + } + } + + private void BackToScan_Click(object sender, RoutedEventArgs e) + { + RestorePanel.Visibility = Visibility.Collapsed; + ScanCleanPanel.Visibility = Visibility.Visible; + } + + private async void ScanButton_Click(object sender, RoutedEventArgs e) + { + _steamPath = await Task.Run(() => SteamDetector.FindSteamPath()); + if (_steamPath == null) + { + ScanStatus.Text = S.Get("Cleanup_SteamNotFound"); + return; + } + + ScanButton.IsEnabled = false; + ScanStatus.Text = ""; + GameListPanel.Visibility = Visibility.Collapsed; + NukePanel.Visibility = Visibility.Collapsed; + LoadingPanel.Visibility = Visibility.Visible; + + try + { + // Run the scan off the UI thread (store instance for reuse by nuke/per-app clean) + _scanResults = await Task.Run(() => + { + _cleanup = new CloudCleanup(_steamPath, _ => { }); + return _cleanup.ScanApps(); + }); + _hasScanned = true; + + // Filter to apps that have files (show all namespace apps with remote/ content) + var appsWithFiles = _scanResults + .Where(r => r.Files.Count > 0) + .OrderByDescending(r => r.PollutedCount) + .ThenByDescending(r => r.TotalBytes) + .ToList(); + + _displayedApps = appsWithFiles; + + if (appsWithFiles.Count == 0) + { + ScanStatus.Text = S.Get("Cleanup_NoNamespaceApps"); + LoadingPanel.Visibility = Visibility.Collapsed; + ScanButton.IsEnabled = true; + GameSearchBox.Visibility = Visibility.Collapsed; + return; + } + + int totalPolluted = appsWithFiles.Sum(a => a.PollutedCount); + long totalPollutedBytes = appsWithFiles.Sum(a => a.PollutedBytes); + int appsAffected = appsWithFiles.Count(a => a.PollutedCount > 0); + ScanStatus.Text = S.Format("Cleanup_ScanStatusFormat", appsWithFiles.Count, totalPolluted, appsAffected); + + // Fetch game names + images in one batch + var storeInfo = await _storeClient.GetAppInfoAsync(appsWithFiles.Select(a => a.AppId).ToList()); + foreach (var (id, info) in storeInfo) + _storeCache[id] = info; + + BuildGameList(appsWithFiles); + + // Reveal only after data is ready to avoid a layout bounce. + LoadingPanel.Visibility = Visibility.Collapsed; + GameSearchBox.Text = ""; + GameSearchBox.Visibility = Visibility.Visible; + + if (totalPolluted > 0) + { + NukeDescription.Text = S.Format("Cleanup_NukeDescriptionFormat", totalPolluted, FileUtils.FormatSize(totalPollutedBytes), appsAffected); + NukePanel.Visibility = Visibility.Visible; + } + else + { + NukePanel.Visibility = Visibility.Collapsed; + } + GameListPanel.Visibility = Visibility.Visible; + } + catch (Exception ex) + { + ScanStatus.Text = S.Format("Cleanup_ScanFailed", ex.Message); + } + finally + { + LoadingPanel.Visibility = Visibility.Collapsed; + ScanButton.IsEnabled = true; + } + } + + private async void NukeButton_Click(object sender, RoutedEventArgs e) + { + if (_scanResults == null || _steamPath == null) return; + + var allSuspect = _scanResults + .SelectMany(app => app.Files + .Where(f => f.Classification != FileClassification.Legitimate && + f.Classification != FileClassification.Unknown) + .Select(f => (app, file: f))) + .ToList(); + + if (allSuspect.Count == 0) return; + + long totalBytes = allSuspect.Sum(x => x.file.SizeBytes); + int appCount = allSuspect.Select(x => x.app.AppId).Distinct().Count(); + + bool confirmed = await Dialog.ConfirmDangerAsync( + S.Get("Cleanup_ConfirmCleanAllTitle"), + S.Format("Cleanup_ConfirmCleanAllMessage", allSuspect.Count, FileUtils.FormatSize(totalBytes), appCount)); + + if (!confirmed) return; + + NukeButton.IsEnabled = false; + NukeButton.Content = S.Get("Cleanup_Cleaning"); + ScanButton.IsEnabled = false; + + try + { + int totalMoved = 0; + var cleanupStartUtc = DateTime.UtcNow; + + await Task.Run(() => + { + var cleanup = _cleanup ?? new CloudCleanup(_steamPath, _ => { }); + + // Group by account first, then by app -- each account gets its own batch/undo log + var byAccount = allSuspect.GroupBy(x => x.app.AccountId); + + foreach (var accountGroup in byAccount) + { + cleanup.BeginBatch(); + try + { + foreach (var appGroup in accountGroup.GroupBy(x => (x.app.AppId, x.app.RemoteDir))) + { + string appDir = Path.GetDirectoryName(appGroup.Key.RemoteDir)!; + var files = appGroup.Select(x => x.file).ToList(); + totalMoved += cleanup.CleanFiles(accountGroup.Key, appGroup.Key.AppId, appDir, files); + } + } + finally + { + cleanup.EndBatch(accountGroup.Key); + } + } + }); + + await Dialog.ShowInfoAsync(S.Get("Cleanup_CleanupCompleteTitle"), + S.Format("Cleanup_CleanupCompleteMessage", totalMoved)); + + // Track for undo banner + backup highlighting + _lastCleanupUtc = cleanupStartUtc; + _backupsLoaded = false; + ShowUndoBanner(totalMoved); + + ScanButton_Click(null!, null!); + } + catch (Exception ex) + { + await Dialog.ShowErrorAsync(S.Get("Cleanup_CleanupFailedTitle"), ex.Message); + } + finally + { + NukeButton.IsEnabled = true; + NukeButton.Content = S.Get("Cleanup_CleanAllSuspectFiles"); + ScanButton.IsEnabled = true; + } + } + + private async void RefreshBackupsButton_Click(object sender, RoutedEventArgs e) + { + _backupsLoaded = false; + await LoadBackupsAsync(); + } + + private async void LoadBackups() + { + await LoadBackupsAsync(); + } + + private async Task LoadBackupsAsync() + { + _steamPath ??= await Task.Run(() => SteamDetector.FindSteamPath()); + if (_steamPath == null) + { + RestoreStatus.Text = S.Get("Cleanup_SteamNotFound"); + return; + } + + RefreshBackupsButton.IsEnabled = false; + RestoreStatus.Text = ""; + BackupListPanel.Children.Clear(); + RestoreLoadingPanel.Visibility = Visibility.Visible; + + try + { + _backups = await Task.Run(() => BackupDiscovery.ListCleanupBackups(_steamPath)); + _backupsLoaded = true; + + if (_backups.Count == 0) + { + RestoreStatus.Text = S.Get("Cleanup_NoBackupsFound"); + RestoreLoadingPanel.Visibility = Visibility.Collapsed; + return; + } + + // Fetch game names + images for all apps across all backups + var allAppIds = _backups.SelectMany(b => b.AppIds).Distinct().ToList(); + var storeInfo = await _storeClient.GetAppInfoAsync(allAppIds); + foreach (var (id, info) in storeInfo) + _storeCache[id] = info; + + // Build the backup list + BuildBackupList(); + + RestoreStatus.Text = S.Format("Cleanup_BackupCountFormat", _backups.Count); + } + catch (Exception ex) + { + RestoreStatus.Text = S.Format("Cleanup_FailedLoadBackups", ex.Message); + } + finally + { + RestoreLoadingPanel.Visibility = Visibility.Collapsed; + RefreshBackupsButton.IsEnabled = true; + } + } + + private void BuildBackupList() + { + // If there's an active search query, apply the filter instead + var query = RestoreSearchBox?.Text?.Trim() ?? ""; + if (!string.IsNullOrEmpty(query)) + { + ApplyRestoreFilter(); + return; + } + + BackupListBuilder.Build( + BackupListPanel, + _backups, + appId => _storeCache.TryGetValue(appId, out var si) ? si : null, + FindResource, + RunBackupPreview, + RunBackupRestore, + _lastCleanupUtc); + } + + private async Task RunBackupPreview(BackupInfo backup, StackPanel detailPanel, Wpf.Ui.Controls.Button previewBtn) + { + if (detailPanel.Visibility == Visibility.Visible) + { + detailPanel.Visibility = Visibility.Collapsed; + previewBtn.Content = S.Get("Backup_Preview"); + return; + } + + previewBtn.IsEnabled = false; + previewBtn.Content = S.Get("Backup_Loading"); + + try + { + var logLines = new List<string>(); + RevertResult result = null; + + await Task.Run(() => + { + var revert = new CloudCleanupRevert(_steamPath!, RevertConflictMode.Skip, msg => logLines.Add(msg)); + result = revert.RestoreFromLog(backup.UndoLogPath, dryRun: true); + }); + + detailPanel.Children.Clear(); + + var summary = new TextBlock + { + Text = result != null + ? S.Format("Preview_SummaryFormat", result.FilesRestored, result.FilesSkipped, result.RemotecachesRestored) + : S.Get("Preview_Failed"), + FontSize = 13, + FontWeight = FontWeights.SemiBold, + Foreground = (Brush)FindResource("TextFillColorSecondaryBrush"), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 0, 0, 8) + }; + detailPanel.Children.Add(summary); + + if (logLines.Count > 0) + { + var logBorder = new Border + { + Background = (Brush)FindResource("ControlFillColorDefaultBrush"), + BorderBrush = (Brush)FindResource("ControlStrokeColorDefaultBrush"), + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(8), + MaxHeight = 300 + }; + var logScroll = new ScrollViewer { VerticalScrollBarVisibility = ScrollBarVisibility.Auto }; + var logText = new TextBlock + { + Text = string.Join("\n", logLines), + FontFamily = new FontFamily("Cascadia Code,Consolas,Courier New"), + FontSize = 11, + Foreground = (Brush)FindResource("TextFillColorSecondaryBrush"), + TextWrapping = TextWrapping.Wrap + }; + logScroll.Content = logText; + logBorder.Child = logScroll; + detailPanel.Children.Add(logBorder); + } + + if (result?.Errors.Count > 0) + { + var errText = new TextBlock + { + Text = S.Format("Preview_ErrorsHeader", string.Join("\n", result.Errors)), + Foreground = new SolidColorBrush(Color.FromRgb(230, 80, 80)), + TextWrapping = TextWrapping.Wrap, + Margin = new Thickness(0, 8, 0, 0) + }; + detailPanel.Children.Add(errText); + } + + detailPanel.Visibility = Visibility.Visible; + previewBtn.Content = S.Get("Backup_HidePreview"); + } + catch (Exception ex) + { + await Dialog.ShowErrorAsync(S.Get("Preview_FailedTitle"), ex.Message); + previewBtn.Content = S.Get("Backup_Preview"); + } + finally + { + previewBtn.IsEnabled = true; + } + } + + private async Task RunBackupRestore(BackupInfo backup, Wpf.Ui.Controls.Button restoreBtn) + { + if (!await SteamDetector.EnsureSteamClosedAsync()) return; + + string timestampText = backup.Timestamp != DateTime.MinValue + ? backup.Timestamp.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") + : backup.Id; + + bool confirmed = await Dialog.ConfirmDangerAsync( + S.Get("Cleanup_RestoreFromBackupTitle"), + S.Format("Cleanup_RestoreConfirmMessage", timestampText, backup.AccountId, backup.FileCount, string.Join(", ", backup.AppIds))); + + if (!confirmed) return; + + restoreBtn.IsEnabled = false; + restoreBtn.Content = S.Get("Apps_Restoring"); + + try + { + RevertResult result = null; + await Task.Run(() => + { + var revert = new CloudCleanupRevert(_steamPath!, RevertConflictMode.Skip, _ => { }); + result = revert.RestoreFromLog(backup.UndoLogPath, dryRun: false); + }); + + if (result != null) + { + string msg = S.Format("Cleanup_RestoredFormat", result.FilesRestored, result.RemotecachesRestored); + if (result.FilesSkipped > 0) + msg += S.Format("Cleanup_SkippedFormat", result.FilesSkipped); + if (result.Errors.Count > 0) + msg += S.Format("Cleanup_ErrorsFormat", result.Errors.Count, string.Join("\n", result.Errors.Take(5))); + + await Dialog.ShowInfoAsync(S.Get("Cleanup_RestoreCompleteTitle"), msg); + } + } + catch (Exception ex) + { + await Dialog.ShowErrorAsync(S.Get("Cleanup_RestoreFailedTitle"), ex.Message); + } + finally + { + restoreBtn.IsEnabled = true; + restoreBtn.Content = S.Get("Apps_Restore"); + } + } + + private void GameSearchBox_TextChanged(object sender, TextChangedEventArgs e) + { + ApplyGameFilter(); + } + + private void ApplyGameFilter() + { + if (_displayedApps == null) return; + var query = GameSearchBox?.Text?.Trim() ?? ""; + + List<AppScanResult> filtered; + if (string.IsNullOrEmpty(query)) + { + filtered = _displayedApps; + } + else + { + filtered = _displayedApps + .Where(a => MatchesGameQuery(a, query)) + .ToList(); + } + + BuildGameList(filtered); + GameListPanel.Visibility = filtered.Count > 0 ? Visibility.Visible : Visibility.Collapsed; + } + + private bool MatchesGameQuery(AppScanResult app, string query) + { + if (app.AppId.ToString().Contains(query, StringComparison.OrdinalIgnoreCase)) + return true; + if (_storeCache.TryGetValue(app.AppId, out var si) + && !string.IsNullOrEmpty(si.Name) + && si.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) + return true; + return false; + } + + private void RestoreSearchBox_TextChanged(object sender, TextChangedEventArgs e) + { + ApplyRestoreFilter(); + } + + private void ApplyRestoreFilter() + { + if (_backups == null || !_backupsLoaded) return; + + var query = RestoreSearchBox?.Text?.Trim() ?? ""; + IReadOnlyList<BackupInfo> filtered; + + if (string.IsNullOrEmpty(query)) + { + filtered = _backups; + } + else + { + filtered = _backups + .Where(b => MatchesBackupQuery(b, query)) + .ToList(); + } + + BackupListPanel.Children.Clear(); + if (filtered.Count == 0) + { + RestoreStatus.Text = S.Get("Cleanup_NoBackupsFound"); + return; + } + + BackupListBuilder.Build( + BackupListPanel, + filtered, + appId => _storeCache.TryGetValue(appId, out var si) ? si : null, + FindResource, + RunBackupPreview, + RunBackupRestore, + _lastCleanupUtc); + + RestoreStatus.Text = S.Format("Cleanup_BackupCountFormat", filtered.Count); + } + + private bool MatchesBackupQuery(BackupInfo b, string query) + { + foreach (var id in b.AppIds) + { + if (id.ToString().Contains(query, StringComparison.OrdinalIgnoreCase)) + return true; + if (_storeCache.TryGetValue(id, out var info) + && !string.IsNullOrEmpty(info.Name) + && info.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) + return true; + } + return false; + } + + private static string GetCleanupStatePath(string steamPath) => + Path.Combine(steamPath, "cloud_redirect", "last_cleanup.json"); + + private void SaveCleanupState(string steamPath, DateTime utc, int fileCount) + { + try + { + var state = new LastCleanupState + { + Utc = utc.ToString("o"), + FileCount = fileCount + }; + var dir = Path.GetDirectoryName(GetCleanupStatePath(steamPath))!; + Directory.CreateDirectory(dir); + File.WriteAllText(GetCleanupStatePath(steamPath), + JsonSerializer.Serialize(state, CleanupStateJsonContext.Default.LastCleanupState)); + } + catch { } + } + + private void ClearCleanupState(string steamPath) + { + try { File.Delete(GetCleanupStatePath(steamPath)); } catch { } + } + + private (DateTime utc, int fileCount)? LoadCleanupState(string steamPath) + { + try + { + var path = GetCleanupStatePath(steamPath); + if (!File.Exists(path)) return null; + var json = File.ReadAllText(path); + var state = JsonSerializer.Deserialize(json, CleanupStateJsonContext.Default.LastCleanupState); + if (state == null || string.IsNullOrEmpty(state.Utc)) return null; + if (!DateTime.TryParse(state.Utc, null, System.Globalization.DateTimeStyles.RoundtripKind, out var utc)) + return null; + return (utc, state.FileCount); + } + catch { return null; } + } + + private void ShowUndoBanner(int fileCount) + { + _lastCleanupFileCount = fileCount; + UndoBannerText.Text = S.Format("Cleanup_CleanedBannerFormat", fileCount); + UndoBanner.Visibility = Visibility.Visible; + UndoButton.IsEnabled = true; + UndoButton.Content = S.Get("Cleanup_Undo"); + + // Persist so the banner survives app restart + if (_steamPath != null && _lastCleanupUtc != null) + SaveCleanupState(_steamPath, _lastCleanupUtc.Value, fileCount); + } + + private void UndoDismiss_Click(object sender, RoutedEventArgs e) + { + UndoBanner.Visibility = Visibility.Collapsed; + } + + private async void UndoButton_Click(object sender, RoutedEventArgs e) + { + if (_lastCleanupUtc == null) return; + + if (!await SteamDetector.EnsureSteamClosedAsync()) return; + + UndoButton.IsEnabled = false; + UndoButton.Content = S.Get("Apps_Restoring"); + UndoDismissButton.IsEnabled = false; + + try + { + _steamPath ??= await Task.Run(() => SteamDetector.FindSteamPath()); + if (_steamPath == null) + { + await Dialog.ShowErrorAsync(S.Get("Cleanup_UndoFailedTitle"), S.Get("Cleanup_UndoSteamNotFound")); + return; + } + + // Load backups from disk and find the one(s) created by the last cleanup + var cutoff = _lastCleanupUtc.Value.AddSeconds(-5); // small tolerance for clock skew + var allBackups = await Task.Run(() => BackupDiscovery.ListCleanupBackups(_steamPath)); + var recentBackups = allBackups + .Where(b => b.Timestamp >= cutoff) + .ToList(); + + if (recentBackups.Count == 0) + { + await Dialog.ShowErrorAsync(S.Get("Cleanup_UndoFailedTitle"), + S.Get("Cleanup_UndoNoBackupFound")); + return; + } + + int totalRestored = 0; + int totalSkipped = 0; + int totalRemotecaches = 0; + var allErrors = new List<string>(); + + await Task.Run(() => + { + foreach (var backup in recentBackups) + { + var revert = new CloudCleanupRevert(_steamPath, RevertConflictMode.Skip, _ => { }); + var result = revert.RestoreFromLog(backup.UndoLogPath, dryRun: false); + if (result != null) + { + totalRestored += result.FilesRestored; + totalSkipped += result.FilesSkipped; + totalRemotecaches += result.RemotecachesRestored; + allErrors.AddRange(result.Errors); + } + } + }); + + string msg = S.Format("Cleanup_RestoredFormat", totalRestored, totalRemotecaches); + if (totalSkipped > 0) + msg += S.Format("Cleanup_SkippedFormat", totalSkipped); + if (allErrors.Count > 0) + msg += S.Format("Cleanup_ErrorsFormat", allErrors.Count, string.Join("\n", allErrors.Take(5))); + + await Dialog.ShowInfoAsync(S.Get("Cleanup_UndoCompleteTitle"), msg); + + // Clear undo state and hide banner + _lastCleanupUtc = null; + UndoBanner.Visibility = Visibility.Collapsed; + if (_steamPath != null) ClearCleanupState(_steamPath); + + // Invalidate backups cache and re-scan + _backupsLoaded = false; + ScanButton_Click(null!, null!); + } + catch (Exception ex) + { + await Dialog.ShowErrorAsync(S.Get("Cleanup_UndoFailedTitle"), ex.Message); + } + finally + { + UndoButton.IsEnabled = true; + UndoButton.Content = S.Get("Cleanup_Undo"); + UndoDismissButton.IsEnabled = true; + } + } + + private void BuildGameList(List<AppScanResult> apps) + { + GameListPanel.Children.Clear(); + + foreach (var app in apps) + { + string gameName = _storeCache.TryGetValue(app.AppId, out var si) && !string.IsNullOrEmpty(si.Name) + ? si.Name : app.AppId.ToString(); + bool hasPollution = app.PollutedCount > 0; + + var card = new Border + { + Background = (Brush)FindResource("ControlFillColorDefaultBrush"), + CornerRadius = new CornerRadius(8), + Margin = new Thickness(0, 0, 0, 8), + Padding = new Thickness(16) + }; + + var cardContent = new StackPanel(); + card.Child = cardContent; + + var headerRow = new Grid(); + headerRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(42) }); + headerRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + headerRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + // Game icon from SteamStoreClient + var iconImage = new Image + { + Width = 32, + Height = 32, + Stretch = Stretch.UniformToFill, + Margin = new Thickness(0, 0, 10, 0) + }; + if (_storeCache.TryGetValue(app.AppId, out var storeInfo2) && SteamStoreClient.IsValidImageUrl(storeInfo2.HeaderUrl)) + { + try + { + var uri = new Uri(storeInfo2.HeaderUrl); + var bitmap = new BitmapImage(); + bitmap.BeginInit(); + if (uri.IsFile) + { + // OnLoad decodes immediately and releases the backing + // file handle; without it a file:// URI keeps the + // cached JPEG locked, blocking eviction and the + // File.Move(overwrite:true) that installs a refreshed + // asset after a Steam CDN hash rotation. + bitmap.CacheOption = BitmapCacheOption.OnLoad; + } + // HTTP URIs: leave CacheOption at Default so the download + // streams async rather than blocking the UI thread with a + // synchronous fetch (which was causing cold-cache renders + // to drop images silently). + bitmap.UriSource = uri; + bitmap.DecodePixelWidth = 64; + bitmap.EndInit(); + if (uri.IsFile) + bitmap.Freeze(); + iconImage.Source = bitmap; + } + catch { /* icon load failure is fine */ } + } + Grid.SetColumn(iconImage, 0); + headerRow.Children.Add(iconImage); + + var nameStack = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + var nameText = new TextBlock + { + Text = gameName, + FontSize = 15, + FontWeight = FontWeights.SemiBold, + Foreground = (Brush)FindResource("TextFillColorPrimaryBrush"), + TextTrimming = TextTrimming.CharacterEllipsis + }; + nameStack.Children.Add(nameText); + + var statsWrap = new WrapPanel { Margin = new Thickness(0, 2, 0, 0) }; + statsWrap.Children.Add(MakeStatText($"AppID {app.AppId}", false)); + statsWrap.Children.Add(MakeStatText(S.Format("Cleanup_FilesCount", app.Files.Count), false)); + statsWrap.Children.Add(MakeStatText(FileUtils.FormatSize(app.TotalBytes), false)); + if (hasPollution) + { + statsWrap.Children.Add(MakeStatText(S.Format("Cleanup_Suspect", app.PollutedCount), true)); + statsWrap.Children.Add(MakeStatText(FileUtils.FormatSize(app.PollutedBytes), true)); + } + else + { + statsWrap.Children.Add(MakeStatText(S.Get("Cleanup_Clean"), false)); + } + nameStack.Children.Add(statsWrap); + + Grid.SetColumn(nameStack, 1); + headerRow.Children.Add(nameStack); + + var expandBtn = new Wpf.Ui.Controls.Button + { + Content = hasPollution ? S.Get("Cleanup_ReviewFiles") : S.Get("Cleanup_ViewFiles"), + Appearance = hasPollution ? Wpf.Ui.Controls.ControlAppearance.Caution : Wpf.Ui.Controls.ControlAppearance.Secondary, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(8, 0, 0, 0) + }; + Grid.SetColumn(expandBtn, 2); + headerRow.Children.Add(expandBtn); + + cardContent.Children.Add(headerRow); + + // File detail panel (hidden by default) + var detailPanel = new StackPanel + { + Visibility = Visibility.Collapsed, + Margin = new Thickness(0, 12, 0, 0) + }; + cardContent.Children.Add(detailPanel); + + expandBtn.Click += (_, _) => + { + if (detailPanel.Visibility == Visibility.Collapsed) + { + detailPanel.Visibility = Visibility.Visible; + expandBtn.Content = S.Get("Cleanup_Collapse"); + if (detailPanel.Children.Count == 0) + BuildFileList(detailPanel, app); + } + else + { + detailPanel.Visibility = Visibility.Collapsed; + expandBtn.Content = hasPollution ? S.Get("Cleanup_ReviewFiles") : S.Get("Cleanup_ViewFiles"); + } + }; + + GameListPanel.Children.Add(card); + } + } + + private void BuildFileList(StackPanel container, AppScanResult app) + { + // Group files: suspect first, then unknown, then legitimate + var suspectLabel = S.Get("Cleanup_SuspectFiles"); + var unknownLabel = S.Get("Cleanup_Unknown"); + var groups = new[] + { + (suspectLabel, app.Files.Where(f => + f.Classification != FileClassification.Legitimate && + f.Classification != FileClassification.Unknown).ToList()), + (unknownLabel, app.Files.Where(f => f.Classification == FileClassification.Unknown).ToList()), + (S.Get("Cleanup_Legitimate"), app.Files.Where(f => f.Classification == FileClassification.Legitimate).ToList()) + }; + + // Track all checkboxes for this app's suspect files for the "clean selected" button + var checkboxes = new List<(CheckBox cb, ClassifiedFile file)>(); + + foreach (var (header, files) in groups) + { + if (files.Count == 0) continue; + + var groupHeader = new TextBlock + { + Text = S.Format("Cleanup_GroupHeaderFormat", header, files.Count), + FontSize = 13, + FontWeight = FontWeights.SemiBold, + Foreground = (Brush)FindResource("TextFillColorSecondaryBrush"), + Margin = new Thickness(0, 8, 0, 4) + }; + container.Children.Add(groupHeader); + + bool isSuspect = header == suspectLabel; + + foreach (var file in files.OrderBy(f => f.RelativePath)) + { + var fileRow = new Grid { Margin = new Thickness(0, 1, 0, 1) }; + fileRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // checkbox + fileRow.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // filename + fileRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // classification badge + fileRow.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // size + + // Checkbox (only for suspect + unknown files) + if (isSuspect || header == unknownLabel) + { + var cb = new CheckBox + { + IsChecked = isSuspect, // pre-check suspect files + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 6, 0) + }; + Grid.SetColumn(cb, 0); + fileRow.Children.Add(cb); + checkboxes.Add((cb, file)); + } + + var fileNameText = new TextBlock + { + Text = file.RelativePath, + FontFamily = new FontFamily("Cascadia Code,Consolas,Courier New"), + FontSize = 12, + Foreground = (Brush)FindResource("TextFillColorPrimaryBrush"), + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis, + Margin = new Thickness(0, 0, 8, 0) + }; + if (!string.IsNullOrEmpty(file.Reason)) + fileNameText.ToolTip = file.Reason; + Grid.SetColumn(fileNameText, 1); + fileRow.Children.Add(fileNameText); + + var badge = new Border + { + CornerRadius = new CornerRadius(4), + Padding = new Thickness(6, 2, 6, 2), + Margin = new Thickness(0, 0, 8, 0), + VerticalAlignment = VerticalAlignment.Center, + Background = file.Classification switch + { + FileClassification.PollutionCrossApp => new SolidColorBrush(Color.FromRgb(180, 60, 60)), + FileClassification.PollutionAppIdDir => new SolidColorBrush(Color.FromRgb(180, 100, 40)), + FileClassification.PollutionMangled => new SolidColorBrush(Color.FromRgb(180, 100, 40)), + FileClassification.Legitimate => new SolidColorBrush(Color.FromRgb(40, 120, 60)), + _ => new SolidColorBrush(Color.FromRgb(100, 100, 100)) + } + }; + badge.Child = new TextBlock + { + Text = file.Classification switch + { + FileClassification.PollutionCrossApp => S.Get("Cleanup_Badge_CrossApp"), + FileClassification.PollutionAppIdDir => S.Get("Cleanup_Badge_WrongAppId"), + FileClassification.PollutionMangled => S.Get("Cleanup_Badge_Mangled"), + FileClassification.PollutionOrphan => S.Get("Cleanup_Badge_Orphan"), + FileClassification.Legitimate => S.Get("Cleanup_Badge_Legit"), + _ => S.Get("Cleanup_Badge_Unknown") + }, + FontSize = 11, + Foreground = Brushes.White + }; + Grid.SetColumn(badge, 2); + fileRow.Children.Add(badge); + + var sizeText = new TextBlock + { + Text = FileUtils.FormatSize(file.SizeBytes), + FontSize = 12, + Foreground = (Brush)FindResource("TextFillColorTertiaryBrush"), + VerticalAlignment = VerticalAlignment.Center, + MinWidth = 60, + TextAlignment = TextAlignment.Right + }; + Grid.SetColumn(sizeText, 3); + fileRow.Children.Add(sizeText); + + container.Children.Add(fileRow); + } + } + + if (checkboxes.Count > 0) + { + var actionBar = new WrapPanel { Margin = new Thickness(0, 12, 0, 0) }; + + var selectAllBtn = new Wpf.Ui.Controls.Button + { + Content = S.Get("Cleanup_SelectAllSuspect"), + Appearance = Wpf.Ui.Controls.ControlAppearance.Secondary, + Margin = new Thickness(0, 0, 8, 0) + }; + selectAllBtn.Click += (_, _) => + { + foreach (var (cb, _) in checkboxes) + cb.IsChecked = true; + }; + actionBar.Children.Add(selectAllBtn); + + var selectNoneBtn = new Wpf.Ui.Controls.Button + { + Content = S.Get("Cleanup_DeselectAll"), + Appearance = Wpf.Ui.Controls.ControlAppearance.Secondary, + Margin = new Thickness(0, 0, 8, 0) + }; + selectNoneBtn.Click += (_, _) => + { + foreach (var (cb, _) in checkboxes) + cb.IsChecked = false; + }; + actionBar.Children.Add(selectNoneBtn); + + var cleanBtn = new Wpf.Ui.Controls.Button + { + Content = S.Get("Cleanup_CleanSelected"), + Icon = new Wpf.Ui.Controls.SymbolIcon { Symbol = Wpf.Ui.Controls.SymbolRegular.Delete24 }, + Appearance = Wpf.Ui.Controls.ControlAppearance.Danger, + Margin = new Thickness(0, 0, 0, 0) + }; + + var capturedApp = app; + var capturedCheckboxes = checkboxes; + + cleanBtn.Click += async (_, _) => + { + var selected = capturedCheckboxes + .Where(x => x.cb.IsChecked == true) + .Select(x => x.file) + .ToList(); + + if (selected.Count == 0) + { + await Dialog.ShowInfoAsync(S.Get("Cleanup_NothingSelectedTitle"), S.Get("Cleanup_NothingSelectedMessage")); + return; + } + + long totalBytes = selected.Sum(f => f.SizeBytes); + bool confirmed = await Dialog.ConfirmDangerAsync( + S.Get("Cleanup_ConfirmCleanupTitle"), + S.Format("Cleanup_ConfirmCleanupMessage", selected.Count, FileUtils.FormatSize(totalBytes), capturedApp.AccountId)); + + if (!confirmed) return; + + cleanBtn.IsEnabled = false; + cleanBtn.Content = S.Get("Cleanup_CleaningButton"); + + try + { + string appDir = Path.GetDirectoryName(capturedApp.RemoteDir)!; + var cleanupStartUtc = DateTime.UtcNow; + int moved = await Task.Run(() => + { + var cleanup = _cleanup ?? new CloudCleanup(_steamPath!, _ => { }); + return cleanup.CleanFiles(capturedApp.AccountId, capturedApp.AppId, appDir, selected); + }); + + await Dialog.ShowInfoAsync(S.Get("Cleanup_CleanupCompleteTitle"), + S.Format("Cleanup_CleanupCompleteMessage", moved)); + + // Track for undo banner + backup highlighting + _lastCleanupUtc = cleanupStartUtc; + _backupsLoaded = false; + ShowUndoBanner(moved); + + ScanButton_Click(null!, null!); + } + catch (Exception ex) + { + await Dialog.ShowErrorAsync(S.Get("Cleanup_CleanupFailedTitle"), ex.Message); + } + finally + { + cleanBtn.IsEnabled = true; + cleanBtn.Content = S.Get("Cleanup_CleanSelected"); + } + }; + + actionBar.Children.Add(cleanBtn); + container.Children.Add(actionBar); + } + } + + private TextBlock MakeStatText(string text, bool isWarning) + { + return new TextBlock + { + Text = text, + FontSize = 12, + Foreground = isWarning + ? new SolidColorBrush(Color.FromRgb(230, 150, 50)) + : (Brush)FindResource("TextFillColorTertiaryBrush"), + Margin = new Thickness(0, 0, 12, 0) + }; + } +} diff --git a/ui/Pages/Cloud760Page.xaml b/ui/Pages/Cloud760Page.xaml index 4255006d..e1cdeea1 100644 --- a/ui/Pages/Cloud760Page.xaml +++ b/ui/Pages/Cloud760Page.xaml @@ -7,7 +7,7 @@ <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="24"> <StackPanel MaxWidth="800"> - <TextBlock Text="Cleanup" + <TextBlock Text="760 Cleanup" FontSize="28" FontWeight="SemiBold" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Margin="0,0,0,8" /> diff --git a/ui/Pages/SettingsPage.xaml b/ui/Pages/SettingsPage.xaml index 2753ae62..62faded2 100644 --- a/ui/Pages/SettingsPage.xaml +++ b/ui/Pages/SettingsPage.xaml @@ -12,12 +12,43 @@ Foreground="{DynamicResource TextFillColorPrimaryBrush}" Margin="0,0,0,24" /> - <TextBlock Text="{res:Loc Settings_Updates}" + <TextBlock Text="{res:Loc Settings_Language}" FontSize="20" FontWeight="SemiBold" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Margin="0,0,0,12" /> <ui:CardControl Margin="0,0,0,32"> + <ui:CardControl.Header> + <StackPanel> + <TextBlock Text="{res:Loc Settings_Language}" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <TextBlock x:Name="LanguageHintText" + Text="{res:Loc Settings_LanguageHint}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" /> + </StackPanel> + </ui:CardControl.Header> + <StackPanel Orientation="Horizontal"> + <ComboBox x:Name="LanguageComboBox" + Width="200" + SelectionChanged="LanguageComboBox_SelectionChanged" /> + <ui:Button x:Name="RestartButton" + Content="{res:Loc Settings_RestartNow}" + Icon="{ui:SymbolIcon ArrowSync24}" + Appearance="Primary" + Click="RestartApp_Click" + Visibility="Collapsed" + Margin="8,0,0,0" /> + </StackPanel> + </ui:CardControl> + + <TextBlock Text="{res:Loc Settings_Updates}" + FontSize="20" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,0,0,12" /> + + <ui:CardControl Margin="0,0,0,8"> <ui:CardControl.Header> <StackPanel> <TextBlock x:Name="UpdateHeaderText" @@ -45,44 +76,29 @@ </StackPanel> </ui:CardControl> - <TextBlock Text="{res:Loc Settings_Language}" - FontSize="20" FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" - Margin="0,0,0,12" /> - <ui:CardControl Margin="0,0,0,32"> <ui:CardControl.Header> <StackPanel> - <TextBlock Text="{res:Loc Settings_Language}" + <TextBlock Text="{res:Loc Settings_AutoUpdateDll}" FontWeight="SemiBold" Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - <TextBlock x:Name="LanguageHintText" - Text="{res:Loc Settings_LanguageHint}" + <TextBlock Text="{res:Loc Settings_AutoUpdateDllHint}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" TextWrapping="Wrap" /> </StackPanel> </ui:CardControl.Header> - <StackPanel Orientation="Horizontal"> - <ComboBox x:Name="LanguageComboBox" - Width="200" - SelectionChanged="LanguageComboBox_SelectionChanged" /> - <ui:Button x:Name="RestartButton" - Content="{res:Loc Settings_RestartNow}" - Icon="{ui:SymbolIcon ArrowSync24}" - Appearance="Primary" - Click="RestartApp_Click" - Visibility="Collapsed" - Margin="8,0,0,0" /> - </StackPanel> + <ui:ToggleSwitch x:Name="AutoUpdateDllToggle" + Checked="SyncToggle_Changed" + Unchecked="SyncToggle_Changed" /> </ui:CardControl> - <StackPanel x:Name="SyncSection"> - <TextBlock Text="{res:Loc Settings_Sync}" + <StackPanel x:Name="ExperimentalSection"> + <TextBlock Text="{res:Loc Settings_Experimental}" FontSize="20" FontWeight="SemiBold" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Margin="0,0,0,4" /> - <TextBlock Text="{res:Loc Settings_SyncHint}" + <TextBlock Text="{res:Loc Settings_ExperimentalHint}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" TextWrapping="Wrap" Margin="0,0,0,12" /> @@ -90,15 +106,15 @@ <ui:CardControl Margin="0,0,0,8"> <ui:CardControl.Header> <StackPanel> - <TextBlock Text="{res:Loc Settings_AutoUpdateDll}" + <TextBlock Text="{res:Loc Settings_SyncAchievements}" FontWeight="SemiBold" Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - <TextBlock Text="{res:Loc Settings_AutoUpdateDllHint}" + <TextBlock Text="{res:Loc Settings_SyncAchievementsHint}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" TextWrapping="Wrap" /> </StackPanel> </ui:CardControl.Header> - <ui:ToggleSwitch x:Name="AutoUpdateDllToggle" + <ui:ToggleSwitch x:Name="SyncAchievementsToggle" Checked="SyncToggle_Changed" Unchecked="SyncToggle_Changed" /> </ui:CardControl> @@ -106,15 +122,15 @@ <ui:CardControl Margin="0,0,0,8"> <ui:CardControl.Header> <StackPanel> - <TextBlock Text="{res:Loc Settings_ShowNonSteamGame}" + <TextBlock Text="{res:Loc Settings_SyncPlaytime}" FontWeight="SemiBold" Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - <TextBlock Text="{res:Loc Settings_ShowNonSteamGameHint}" + <TextBlock Text="{res:Loc Settings_SyncPlaytimeHint}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" TextWrapping="Wrap" /> </StackPanel> </ui:CardControl.Header> - <ui:ToggleSwitch x:Name="ShowNonSteamGameToggle" + <ui:ToggleSwitch x:Name="SyncPlaytimeToggle" Checked="SyncToggle_Changed" Unchecked="SyncToggle_Changed" /> </ui:CardControl> @@ -122,47 +138,59 @@ <ui:CardControl Margin="0,0,0,8"> <ui:CardControl.Header> <StackPanel> - <TextBlock Text="{res:Loc Settings_SyncAchievements}" + <TextBlock Text="{res:Loc Settings_GetAchievementData}" FontWeight="SemiBold" Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - <TextBlock Text="{res:Loc Settings_SyncAchievementsHint}" + <TextBlock Text="{res:Loc Settings_GetAchievementDataHint}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" TextWrapping="Wrap" /> </StackPanel> </ui:CardControl.Header> - <ui:ToggleSwitch x:Name="SyncAchievementsToggle" + <ui:ToggleSwitch x:Name="GetAchievementDataToggle" Checked="SyncToggle_Changed" Unchecked="SyncToggle_Changed" /> </ui:CardControl> - <ui:CardControl Margin="0,0,0,8"> + <ui:CardControl Margin="0,0,0,32"> <ui:CardControl.Header> <StackPanel> - <TextBlock Text="{res:Loc Settings_SyncPlaytime}" + <TextBlock Text="{res:Loc Settings_SyncLuas}" FontWeight="SemiBold" Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - <TextBlock Text="{res:Loc Settings_SyncPlaytimeHint}" + <TextBlock Text="{res:Loc Settings_SyncLuasHint}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" TextWrapping="Wrap" /> </StackPanel> </ui:CardControl.Header> - <ui:ToggleSwitch x:Name="SyncPlaytimeToggle" + <ui:ToggleSwitch x:Name="SyncLuasToggle" Checked="SyncToggle_Changed" Unchecked="SyncToggle_Changed" /> </ui:CardControl> + </StackPanel> + + <StackPanel x:Name="ExtraSection"> + <TextBlock Text="{res:Loc Settings_Extra}" + FontSize="20" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,0,0,4" /> + + <TextBlock Text="{res:Loc Settings_ExtraHint}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" + Margin="0,0,0,12" /> <ui:CardControl Margin="0,0,0,32"> <ui:CardControl.Header> <StackPanel> - <TextBlock Text="{res:Loc Settings_SyncLuas}" + <TextBlock Text="{res:Loc Settings_ShowNonSteamGame}" FontWeight="SemiBold" Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - <TextBlock Text="{res:Loc Settings_SyncLuasHint}" + <TextBlock Text="{res:Loc Settings_ShowNonSteamGameHint}" Foreground="{DynamicResource TextFillColorSecondaryBrush}" TextWrapping="Wrap" /> </StackPanel> </ui:CardControl.Header> - <ui:ToggleSwitch x:Name="SyncLuasToggle" + <ui:ToggleSwitch x:Name="ShowNonSteamGameToggle" Checked="SyncToggle_Changed" Unchecked="SyncToggle_Changed" /> </ui:CardControl> diff --git a/ui/Pages/SettingsPage.xaml.cs b/ui/Pages/SettingsPage.xaml.cs index 8e832b97..caa6dde2 100644 --- a/ui/Pages/SettingsPage.xaml.cs +++ b/ui/Pages/SettingsPage.xaml.cs @@ -62,7 +62,8 @@ private sealed record SettingsSnapshot( bool? AutoUpdateDll, bool? ShowNonSteamGame, bool? ParentalIgnorePlaytime, - bool? ParentalBypassPlaytime); + bool? ParentalBypassPlaytime, + bool? SchemaFetch); // M15: Move language/mode/sync-toggle config reads off the UI thread. // Loaded used to call ReadLanguageSetting + ReadModeSetting + @@ -76,11 +77,11 @@ private async Task LoadSettingsAsync() var lang = ReadLanguageSetting(); var mode = Services.SteamDetector.ReadModeSetting(); - bool? a = null, p = null, l = null, u = null, nsg = null, pip = null, pbp = null; + bool? a = null, p = null, l = null, u = null, nsg = null, pip = null, pbp = null, sf = null; if (mode == "cloud_redirect") - ReadSyncTogglesInto(ref a, ref p, ref l, ref u, ref nsg, ref pip, ref pbp); + ReadSyncTogglesInto(ref a, ref p, ref l, ref u, ref nsg, ref pip, ref pbp, ref sf); - return new SettingsSnapshot(lang, mode, a, p, l, u, nsg, pip, pbp); + return new SettingsSnapshot(lang, mode, a, p, l, u, nsg, pip, pbp, sf); }); ApplySettingsSnapshot(snapshot); @@ -93,15 +94,20 @@ private void ApplySettingsSnapshot(SettingsSnapshot snap) ParentalSection.Visibility = Visibility.Visible; if (snap.Mode == "cloud_redirect") { - SyncSection.Visibility = Visibility.Visible; + ExperimentalSection.Visibility = Visibility.Visible; + ExtraSection.Visibility = Visibility.Visible; ApplySyncToggles(snap.SyncAchievements, snap.SyncPlaytime, snap.SyncLuas, snap.AutoUpdateDll, - snap.ShowNonSteamGame, snap.ParentalIgnorePlaytime, snap.ParentalBypassPlaytime); + snap.ShowNonSteamGame, snap.ParentalIgnorePlaytime, snap.ParentalBypassPlaytime, + snap.SchemaFetch); } else { - SyncSection.Visibility = Visibility.Collapsed; - ApplySyncToggles(false, false, false, false, false, - snap.ParentalIgnorePlaytime, snap.ParentalBypassPlaytime); + ExperimentalSection.Visibility = Visibility.Collapsed; + ExtraSection.Visibility = Visibility.Collapsed; + // Auto-update DLL lives in the always-visible Updates section, so it + // reflects the real setting even when the sync/extra sections are hidden. + ApplySyncToggles(false, false, false, snap.AutoUpdateDll, false, + snap.ParentalIgnorePlaytime, snap.ParentalBypassPlaytime, false); } } @@ -131,7 +137,8 @@ private void ApplyLanguageSelector(string saved) } private void ApplySyncToggles(bool? achievements, bool? playtime, bool? luas, bool? autoUpdateDll, - bool? showNonSteamGame, bool? parentalIgnorePlaytime, bool? parentalBypassPlaytime) + bool? showNonSteamGame, bool? parentalIgnorePlaytime, bool? parentalBypassPlaytime, + bool? schemaFetch) { _syncLoading = true; try @@ -143,6 +150,7 @@ private void ApplySyncToggles(bool? achievements, bool? playtime, bool? luas, bo if (showNonSteamGame == true) ShowNonSteamGameToggle.IsChecked = true; if (parentalIgnorePlaytime == true) ParentalIgnorePlaytimeToggle.IsChecked = true; if (parentalBypassPlaytime == true) ParentalBypassPlaytimeToggle.IsChecked = true; + if (schemaFetch == true) GetAchievementDataToggle.IsChecked = true; } finally { @@ -156,7 +164,8 @@ private void ApplySyncToggles(bool? achievements, bool? playtime, bool? luas, bo /// path never opens config.json synchronously. /// </summary> private static void ReadSyncTogglesInto(ref bool? achievements, ref bool? playtime, ref bool? luas, ref bool? autoUpdateDll, - ref bool? showNonSteamGame, ref bool? parentalIgnorePlaytime, ref bool? parentalBypassPlaytime) + ref bool? showNonSteamGame, ref bool? parentalIgnorePlaytime, ref bool? parentalBypassPlaytime, + ref bool? schemaFetch) { try { @@ -185,12 +194,27 @@ private static void ReadSyncTogglesInto(ref bool? achievements, ref bool? playti parentalIgnorePlaytime = true; if (root.TryGetProperty("parental_bypass_playtime", out var pbp) && pbp.ValueKind == JsonValueKind.True) parentalBypassPlaytime = true; + // Experimental schema fetch: default off when key absent. + if (root.TryGetProperty("experimental_schema_fetch", out var sf) && sf.ValueKind == JsonValueKind.True) + schemaFetch = true; } catch { } } private void LoadAbout() { + // Prefer the informational version (carries any pre-release suffix like + // "-TEST1"); strip build metadata after '+'. Fall back to the numeric + // assembly version formatted as X.Y.Z. + var informational = Assembly.GetExecutingAssembly() + .GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion; + if (!string.IsNullOrEmpty(informational)) + { + var plus = informational.IndexOf('+'); + VersionText.Text = plus >= 0 ? informational.Substring(0, plus) : informational; + return; + } + var version = Assembly.GetExecutingAssembly().GetName().Version; VersionText.Text = version != null ? S.Format("Settings_VersionFormat", version.Major, version.Minor, version.Build) @@ -350,7 +374,8 @@ private void SaveSyncToggles() var path = GetConfigPath(); Services.ConfigHelper.SaveConfig(path, new[] { "sync_achievements", "sync_playtime", "sync_luas", "auto_update_dll", - "show_non_steam_game", "parental_ignore_playtime", "parental_bypass_playtime" }, + "show_non_steam_game", "parental_ignore_playtime", "parental_bypass_playtime", + "experimental_schema_fetch" }, writer => { writer.WriteBoolean("sync_achievements", SyncAchievementsToggle.IsChecked == true); @@ -360,6 +385,7 @@ private void SaveSyncToggles() writer.WriteBoolean("show_non_steam_game", ShowNonSteamGameToggle.IsChecked == true); writer.WriteBoolean("parental_ignore_playtime", ParentalIgnorePlaytimeToggle.IsChecked == true); writer.WriteBoolean("parental_bypass_playtime", ParentalBypassPlaytimeToggle.IsChecked == true); + writer.WriteBoolean("experimental_schema_fetch", GetAchievementDataToggle.IsChecked == true); }); } diff --git a/ui/Resources/Strings.resx b/ui/Resources/Strings.resx index 63f7ee6f..5cea7c39 100644 --- a/ui/Resources/Strings.resx +++ b/ui/Resources/Strings.resx @@ -1050,6 +1050,12 @@ Skipped {0} file(s) (already exist at original location).</value> <data name="Cleanup_Description" xml:space="preserve"> <value>SteamTools redirected all lua apps' cloud saves through app 760, polluting every game's remote/ folder with every other game's files. Scan to see which apps are affected, then select files to remove.</value> </data> + <data name="Cleanup_OstBanner_Title" xml:space="preserve"> + <value>OpenSteamTool detected</value> + </data> + <data name="Cleanup_OstBanner_Description" xml:space="preserve"> + <value>OpenSteamTool does not route cloud saves through app 760. This page is for cleaning up files left by a prior SteamTools install.</value> + </data> <data name="Cleanup_ScanForContamination" xml:space="preserve"> <value>Scan for Contamination</value> </data> @@ -1617,11 +1623,29 @@ Are you sure?</value> <data name="Settings_AutoUpdateDllHint" xml:space="preserve"> <value>Automatically check for and install newer versions of the CloudRedirect DLL when Steam launches. The update takes effect on the next Steam restart.</value> </data> + <data name="Settings_Extra" xml:space="preserve"> + <value>Quality of Life Placeholder</value> + </data> + <data name="Settings_ExtraHint" xml:space="preserve"> + <value>Optional extras that aren't part of cloud sync. Changes take effect on next game launch.</value> + </data> <data name="Settings_ShowNonSteamGame" xml:space="preserve"> - <value>Show Game in Friends</value> + <value>Show Lua Game in Status</value> </data> <data name="Settings_ShowNonSteamGameHint" xml:space="preserve"> - <value>When playing a game unlocked by a Lua, show it as "Playing non-Steam game" in your friends list instead of appearing offline. Takes effect on next game launch.</value> + <value>When playing a game unlocked by a Lua, show it as your Steam status instead of appearing online but not actively in a game.</value> + </data> + <data name="Settings_Experimental" xml:space="preserve"> + <value>Experimental Features</value> + </data> + <data name="Settings_ExperimentalHint" xml:space="preserve"> + <value>PLACEHOLDER LOREM IPSUM</value> + </data> + <data name="Settings_GetAchievementData" xml:space="preserve"> + <value>Make Achievements Work</value> + </data> + <data name="Settings_GetAchievementDataHint" xml:space="preserve"> + <value>Grab achievement data from Steam, makes achievements appear for games that ST itself does not get achievement data for.</value> </data> <!-- ═══════════════════════════════════════════════════════════════════ --> @@ -1699,10 +1723,10 @@ Are you sure?</value> <!-- ═══════════════════════ Parental Controls ═════════════════════════ --> <data name="Settings_Parental" xml:space="preserve"> - <value>Tung Tung</value> + <value>Steam Family</value> </data> <data name="Settings_ParentalHint" xml:space="preserve"> - <value>Six...six seven?</value> + <value>Disable Steam Family restrictions</value> </data> <data name="Settings_ParentalIgnorePlaytime" xml:space="preserve"> <value>Ignore Playtime Restrictions</value> diff --git a/ui/Services/Dialog.cs b/ui/Services/Dialog.cs index 45ee3a8f..6000d25f 100644 --- a/ui/Services/Dialog.cs +++ b/ui/Services/Dialog.cs @@ -19,47 +19,52 @@ namespace CloudRedirect.Services; /// </summary> public static class Dialog { - public static Task ShowInfoAsync(string title, string message) + // Build a single-button acknowledgement dialog. WPF-UI's MessageBox always + // lays out all three buttons (primary/secondary/close); leaving a button's + // text empty renders a blank, unlabeled button next to the real one. We give + // only the close button a label ("OK") and, after the template is applied, + // collapse any footer button that has no visible text so exactly one button + // ("OK") remains. + private static Task ShowAcknowledgeAsync(string title, string message, ControlAppearance appearance) { var box = new MessageBox { Title = title, Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, - CloseButtonText = S.Get("Dialog_OK") + CloseButtonText = S.Get("Dialog_OK"), + CloseButtonAppearance = appearance, }; + + box.Loaded += (_, _) => CollapseEmptyFooterButtons(box); return box.ShowDialogAsync(); } - public static Task ShowWarningAsync(string title, string message) + // Scoped to Wpf.Ui.Controls.Button (the footer buttons) so the title-bar + // close (X) and other chrome are never touched. + private static void CollapseEmptyFooterButtons(DependencyObject root) { - // CloseButtonText defaults to "Close" in WPF-UI's MessageBox, so - // setting only PrimaryButtonText leaves a redundant second button - // that does the same thing as the primary "OK". Suppress it. - var box = new MessageBox + int count = VisualTreeHelper.GetChildrenCount(root); + for (int i = 0; i < count; i++) { - Title = title, - Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, - PrimaryButtonText = S.Get("Dialog_OK"), - PrimaryButtonAppearance = ControlAppearance.Caution, - IsPrimaryButtonEnabled = true, - CloseButtonText = string.Empty - }; - return box.ShowDialogAsync(); + var child = VisualTreeHelper.GetChild(root, i); + if (child is Button btn) + { + var text = (btn.Content as string) ?? (btn.Content as TextBlock)?.Text; + if (string.IsNullOrWhiteSpace(text) && btn.Icon is null) + btn.Visibility = Visibility.Collapsed; + } + CollapseEmptyFooterButtons(child); + } } + public static Task ShowInfoAsync(string title, string message) + => ShowAcknowledgeAsync(title, message, ControlAppearance.Primary); + + public static Task ShowWarningAsync(string title, string message) + => ShowAcknowledgeAsync(title, message, ControlAppearance.Caution); + public static Task ShowErrorAsync(string title, string message) - { - var box = new MessageBox - { - Title = title, - Content = new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap }, - PrimaryButtonText = S.Get("Dialog_OK"), - PrimaryButtonAppearance = ControlAppearance.Danger, - IsPrimaryButtonEnabled = true, - CloseButtonText = string.Empty - }; - return box.ShowDialogAsync(); - } + => ShowAcknowledgeAsync(title, message, ControlAppearance.Danger); public static async Task<bool> ConfirmAsync(string title, string message) { From 7e9cc7a07577edd43ff8ed69e0205ad80d24d99d Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Sun, 7 Jun 2026 16:59:48 -0400 Subject: [PATCH 09/24] Add native stats store and RPC handlers --- CMakeLists.txt | 10 +- src/common/bkv_stats.cpp | 122 ---- src/common/metadata_sync.cpp | 2 - src/common/metadata_sync.h | 6 +- src/common/rpc_handlers.cpp | 848 ------------------------- src/common/rpc_handlers.h | 5 - src/common/stats_handlers.cpp | 355 +++++++++++ src/common/stats_handlers.h | 53 ++ src/common/stats_store.cpp | 788 +++++++++++++++++++++++ src/common/stats_store.h | 100 +++ src/platform/linux/cloud_intercept.cpp | 13 - src/platform/linux/cloud_intercept.h | 2 - src/platform/win/cloud_intercept.cpp | 683 +++++--------------- src/platform/win/cloud_intercept.h | 3 - 14 files changed, 1464 insertions(+), 1526 deletions(-) delete mode 100644 src/common/bkv_stats.cpp create mode 100644 src/common/stats_handlers.cpp create mode 100644 src/common/stats_handlers.h create mode 100644 src/common/stats_store.cpp create mode 100644 src/common/stats_store.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 4633a81c..f0391848 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,7 +47,6 @@ set(COMMON_SOURCES src/common/protobuf.cpp src/common/json.cpp src/common/vdf.cpp - src/common/bkv_stats.cpp src/common/remotecache_repair.cpp src/common/manifest_store.cpp src/common/app_state.cpp @@ -67,6 +66,8 @@ set(COMMON_SOURCES src/common/steam_kv_injector.cpp src/common/parental_bypass.cpp src/common/metadata_sync.cpp + src/common/stats_store.cpp + src/common/stats_handlers.cpp src/common/miniz.c src/common/miniz_tdef.c src/common/miniz_tinfl.c @@ -234,13 +235,6 @@ if(EXISTS ${CMAKE_SOURCE_DIR}/tests/autocloud_native_tests.cpp) target_include_directories(remotecache_repair_tests PRIVATE src/common) add_test(NAME remotecache_repair_tests COMMAND remotecache_repair_tests) - add_executable(bkv_stats_tests - tests/bkv_stats_tests.cpp - src/common/bkv_stats.cpp - ) - target_include_directories(bkv_stats_tests PRIVATE src/common) - add_test(NAME bkv_stats_tests COMMAND bkv_stats_tests) - add_executable(rpc_handlers_tests tests/rpc_handlers_tests.cpp src/common/protobuf.cpp diff --git a/src/common/bkv_stats.cpp b/src/common/bkv_stats.cpp deleted file mode 100644 index 14982da1..00000000 --- a/src/common/bkv_stats.cpp +++ /dev/null @@ -1,122 +0,0 @@ -// Upload-side predicate: rejects empty cache{crc,PendingChanges}+END skeletons -// and all-zero-data blobs so UploadStatsOnExit can't clobber a richer cloud copy. -// Reader mirrors BkvRead in rpc_handlers.cpp; keep them in sync. - -#include "rpc_handlers.h" - -#include <cstdint> -#include <cstring> -#include <string> -#include <vector> - -namespace CloudIntercept { -namespace { - -enum BkvType : uint8_t { - BKV_SECTION = 0x00, - BKV_STRING = 0x01, - BKV_INT = 0x02, - BKV_FLOAT = 0x03, - BKV_UINT64 = 0x07, - BKV_END = 0x08, - BKV_INT64 = 0x0A, -}; - -struct BkvNode { - BkvType type; - std::string name; - uint32_t intVal = 0; - float floatVal = 0.0f; - uint64_t uint64Val = 0; - int64_t int64Val = 0; - std::string strVal; - std::vector<BkvNode> children; -}; - -constexpr int BKV_MAX_DEPTH = 128; -constexpr size_t BKV_MAX_NODES = 100000; - -bool BkvRead(const uint8_t* data, size_t len, size_t& pos, - std::vector<BkvNode>& out, int depth, size_t& totalNodes) { - if (depth > BKV_MAX_DEPTH) return false; - while (pos < len) { - uint8_t tag = data[pos++]; - if (tag == BKV_END) return true; - - BkvNode node; - node.type = static_cast<BkvType>(tag); - - const char* nameStart = reinterpret_cast<const char*>(data + pos); - size_t nameEnd = pos; - while (nameEnd < len && data[nameEnd] != 0) nameEnd++; - if (nameEnd >= len) return false; - node.name.assign(nameStart, nameEnd - pos); - pos = nameEnd + 1; - - switch (node.type) { - case BKV_SECTION: - if (!BkvRead(data, len, pos, node.children, depth + 1, totalNodes)) - return false; - break; - case BKV_STRING: { - const char* s = reinterpret_cast<const char*>(data + pos); - size_t end = pos; - while (end < len && data[end] != 0) end++; - if (end >= len) return false; - node.strVal.assign(s, end - pos); - pos = end + 1; - break; - } - case BKV_INT: - case BKV_FLOAT: - if (pos + 4 > len) return false; - if (node.type == BKV_INT) - std::memcpy(&node.intVal, data + pos, 4); - else - std::memcpy(&node.floatVal, data + pos, 4); - pos += 4; - break; - case BKV_UINT64: - if (pos + 8 > len) return false; - std::memcpy(&node.uint64Val, data + pos, 8); - pos += 8; - break; - case BKV_INT64: - if (pos + 8 > len) return false; - std::memcpy(&node.int64Val, data + pos, 8); - pos += 8; - break; - default: - return false; - } - if (++totalNodes > BKV_MAX_NODES) return false; - out.push_back(std::move(node)); - } - return depth == 0; -} - -bool HasNonZeroStatsData(const std::vector<BkvNode>& nodes) { - for (const auto& n : nodes) { - if (n.name == "data") { - if (n.type == BKV_INT && n.intVal != 0) return true; - if (n.type == BKV_FLOAT && n.floatVal != 0.0f) return true; - if (n.type == BKV_UINT64 && n.uint64Val != 0) return true; - if (n.type == BKV_INT64 && n.int64Val != 0) return true; - } - if (!n.children.empty() && HasNonZeroStatsData(n.children)) return true; - } - return false; -} - -} // namespace - -bool StatsBlobHasUnlocks(const uint8_t* data, size_t len) { - if (!data || len == 0) return false; - size_t pos = 0; - size_t nodeCount = 0; - std::vector<BkvNode> nodes; - if (!BkvRead(data, len, pos, nodes, 0, nodeCount)) return false; - return HasNonZeroStatsData(nodes); -} - -} // namespace CloudIntercept diff --git a/src/common/metadata_sync.cpp b/src/common/metadata_sync.cpp index a21ba5dd..554cb78c 100644 --- a/src/common/metadata_sync.cpp +++ b/src/common/metadata_sync.cpp @@ -3,8 +3,6 @@ namespace MetadataSync { std::atomic<bool> steamToolsPresent{false}; -std::atomic<bool> syncAchievements{false}; -std::atomic<bool> syncPlaytime{false}; std::atomic<bool> syncLuas{false}; } diff --git a/src/common/metadata_sync.h b/src/common/metadata_sync.h index bb4c0ffc..e4c60a48 100644 --- a/src/common/metadata_sync.h +++ b/src/common/metadata_sync.h @@ -5,15 +5,11 @@ namespace MetadataSync { extern std::atomic<bool> steamToolsPresent; -extern std::atomic<bool> syncAchievements; -extern std::atomic<bool> syncPlaytime; extern std::atomic<bool> syncLuas; inline bool IsEnabled() { return steamToolsPresent.load(std::memory_order_relaxed) && - (syncAchievements.load(std::memory_order_relaxed) || - syncPlaytime.load(std::memory_order_relaxed) || - syncLuas.load(std::memory_order_relaxed)); + syncLuas.load(std::memory_order_relaxed); } } diff --git a/src/common/rpc_handlers.cpp b/src/common/rpc_handlers.cpp index 8e96a2b6..d3f60126 100644 --- a/src/common/rpc_handlers.cpp +++ b/src/common/rpc_handlers.cpp @@ -40,16 +40,6 @@ extern "C" void CR_SetCrashContext(const char* hook, const char* method, uint32_ namespace CloudIntercept { -bool RestorePlaytimeState(uint32_t appId, uint64_t playtime, uint64_t playtime2wks); -bool RestoreLastPlayedState(uint32_t appId, uint64_t lastPlayed); - -static void RestoreInMemoryPlaytimeMetadata(uint32_t appId, uint64_t lastPlayed, - uint64_t playtime, uint64_t playtime2wks) { - RestoreLastPlayedState(appId, lastPlayed); - RestorePlaytimeState(appId, playtime, playtime2wks); -} - - // per-app upload batch tracking -- state lives in batch_tracker.cpp static std::mutex g_conflictMutex; @@ -127,8 +117,6 @@ static uint32_t ClampFileSizeToUint32(uint64_t rawSize, const char* fieldName, } -static void InvalidateTokenCaches(uint32_t accountId, uint32_t appId); - static void SetRpcCrashContext(const char* phase, const char* method, uint32_t appId) { #ifndef _WIN32 CR_SetCrashContext(phase, method, appId); @@ -144,52 +132,6 @@ static void SetRpcCrashContext(const char* phase, const char* method, uint32_t a void ShutdownRpcHandlers() { } -static uint64_t ParsePlaytimeField(const Json::Value& value) { - if (value.type == Json::Type::Number) { - return value.number() > 0 ? static_cast<uint64_t>(value.number()) : 0; - } - if (value.type == Json::Type::String) { - return strtoull(value.str().c_str(), nullptr, 10); - } - return 0; -} - -static void ParsePlaytimeBlob(const std::string& blob, uint64_t& lastPlayed, - uint64_t& playtime, uint64_t& playtime2wks) { - auto parsed = Json::Parse(blob); - if (parsed.type == Json::Type::Object) { - if (parsed.has("LastPlayed")) - lastPlayed = ParsePlaytimeField(parsed["LastPlayed"]); - if (parsed.has("Playtime")) - playtime = ParsePlaytimeField(parsed["Playtime"]); - if (parsed.has("Playtime2wks")) - playtime2wks = ParsePlaytimeField(parsed["Playtime2wks"]); - // 2wks > lifetime is corruption; zero rather than propagate. - // 2wks==lifetime past the 14-day window is the legacy default-to-total bug. - constexpr uint64_t kTwoWeeksMinutes = 14ULL * 24 * 60; - if (playtime2wks > playtime || - (playtime2wks == playtime && playtime > kTwoWeeksMinutes)) - playtime2wks = 0; - return; - } - - std::istringstream blobStream(blob); - std::string blobLine; - while (std::getline(blobStream, blobLine)) { - size_t tab = blobLine.find('\t'); - if (tab == std::string::npos) continue; - std::string key = blobLine.substr(0, tab); - std::string val = blobLine.substr(tab + 1); - if (key == "LastPlayed") lastPlayed = strtoull(val.c_str(), nullptr, 10); - else if (key == "Playtime") playtime = strtoull(val.c_str(), nullptr, 10); - else if (key == "Playtime2wks") playtime2wks = strtoull(val.c_str(), nullptr, 10); - } - constexpr uint64_t kTwoWeeksMinutes = 14ULL * 24 * 60; - if (playtime2wks > playtime || - (playtime2wks == playtime && playtime > kTwoWeeksMinutes)) - playtime2wks = 0; -} - // Per-app root tokens (e.g., "%GameInstall%") seen on uploads. static std::unordered_map<uint64_t, std::unordered_set<std::string>> g_appRootTokens; static std::mutex g_rootTokenMutex; @@ -574,454 +516,6 @@ static void ClearFileTokensDirty(uint32_t accountId, uint32_t appId) { g_fileTokensDirtyApps.erase(MakeAppAccountKey(accountId, appId)); } -static void InvalidateTokenCaches(uint32_t accountId, uint32_t appId) { - uint64_t key = MakeAppAccountKey(accountId, appId); - - // Clear local caches - { - std::lock_guard<std::mutex> lock(g_rootTokenMutex); - g_appRootTokens.erase(key); - } - { - std::lock_guard<std::mutex> lock(g_fileTokensMutex); - g_fileTokens.erase(key); - } - { - std::lock_guard<std::mutex> lock(g_batchCanonicalTokensMutex); - g_batchCanonicalTokens.erase(key); - } - { - std::lock_guard<std::mutex> lock(g_remotecacheRepairMutex); - g_remotecachePlantedRows.erase(key); - } - // Keep manifest/CN cache -- metadata restore doesn't change save CN, and clearing - // mid-session would break exit-sync's is_only_delta=1 path. -} - -static bool MergeStatsFile(uint32_t appId, uint32_t accountId, - const std::vector<uint8_t>& cloudData); - -static bool InsertPlaytimeFieldInSection(std::string& vdfContent, - const char* const* sections, - size_t sectionCount, - std::string_view fieldName, - const std::string& value) { - size_t sectionStart = 0; - size_t sectionEnd = 0; - if (!VdfUtil::FindVdfSectionRange(vdfContent, sections, sectionCount, sectionStart, sectionEnd)) { - return false; - } - - std::string indent = "\t"; - size_t lineStart = vdfContent.rfind('\n', sectionEnd); - if (lineStart != std::string::npos) { - ++lineStart; - size_t indentEnd = lineStart; - while (indentEnd < vdfContent.size() && (vdfContent[indentEnd] == '\t' || vdfContent[indentEnd] == ' ')) { - ++indentEnd; - } - indent.assign(vdfContent.data() + lineStart, indentEnd - lineStart); - indent.push_back('\t'); - } - - std::string insertion = indent + "\"" + std::string(fieldName) + "\"\t\t\"" + value + "\"\n"; - vdfContent.insert(sectionEnd, insertion); - return true; -} - -static bool EnsureVdfSectionPath(std::string& vdfContent, - const char* const* sections, - size_t sectionCount) { - if (sectionCount == 0) return true; - - size_t sectionStart = 0; - size_t sectionEnd = 0; - if (VdfUtil::FindVdfSectionRange(vdfContent, sections, sectionCount, sectionStart, sectionEnd)) { - return true; - } - - if (sectionCount == 1) { - const std::string snapshot = vdfContent; - if (!vdfContent.empty() && vdfContent.back() != '\n') vdfContent.push_back('\n'); - vdfContent += "\"" + std::string(sections[0]) + "\"\n{\n}\n"; - // Roll back if the insertion isn't parseable. - if (!VdfUtil::FindVdfSectionRange(vdfContent, sections, sectionCount, sectionStart, sectionEnd)) { - vdfContent = snapshot; - return false; - } - return true; - } - - if (!EnsureVdfSectionPath(vdfContent, sections, sectionCount - 1)) { - return false; - } - - size_t parentStart = 0; - size_t parentEnd = 0; - if (!VdfUtil::FindVdfSectionRange(vdfContent, sections, sectionCount - 1, parentStart, parentEnd)) { - return false; - } - - std::string parentIndent = "\t"; - size_t lineStart = vdfContent.rfind('\n', parentEnd); - if (lineStart != std::string::npos) { - ++lineStart; - size_t indentEnd = lineStart; - while (indentEnd < vdfContent.size() && (vdfContent[indentEnd] == '\t' || vdfContent[indentEnd] == ' ')) { - ++indentEnd; - } - parentIndent.assign(vdfContent.data() + lineStart, indentEnd - lineStart); - } - - const std::string childIndent = parentIndent + "\t"; - std::string insertion; - insertion += childIndent + "\"" + std::string(sections[sectionCount - 1]) + "\"\n"; - insertion += childIndent + "{\n"; - insertion += childIndent + "}\n"; - - const std::string snapshot = vdfContent; - vdfContent.insert(parentEnd, insertion); - - // Roll back if the new child isn't parseable. - if (!VdfUtil::FindVdfSectionRange(vdfContent, sections, sectionCount, sectionStart, sectionEnd)) { - vdfContent = snapshot; - return false; - } - return true; -} - -// Pre-seed remotecache.vdf rows so Steam doesn't default-construct them -// with persiststate=deleted. Add-only, atomic-write, stat-before/after. -static bool EnsureAndMarkRemotecacheRepaired( - uint32_t accountId, uint32_t appId, - const std::vector<RemotecacheCandidate>& candidates) { - const uint64_t appKey = MakeAppAccountKey(accountId, appId); - - // Mark attempted on the empty path; HandleDeleteFile distinguishes - // "no changelist yet" from "changelist ran, advertised nothing". - if (candidates.empty()) { - std::lock_guard<std::mutex> lock(g_remotecacheRepairMutex); - (void)g_remotecachePlantedRows[appKey]; - return true; - } - - { - std::lock_guard<std::mutex> lock(g_remotecacheRepairMutex); - auto it = g_remotecachePlantedRows.find(appKey); - if (it != g_remotecachePlantedRows.end()) { - const auto& planted = it->second; - bool allPlanted = true; - for (const auto& c : candidates) { - if (planted.count(c.cleanName) == 0) { - allPlanted = false; - break; - } - } - if (allPlanted) return true; - } - } - - std::string steamPath = CloudIntercept::GetSteamPath(); - if (steamPath.empty()) return false; - -#ifdef _WIN32 - std::string vdfPath = steamPath + "userdata\\" + std::to_string(accountId) - + "\\" + std::to_string(appId) + "\\remotecache.vdf"; -#else - std::string vdfPath = steamPath + "userdata/" + std::to_string(accountId) - + "/" + std::to_string(appId) + "/remotecache.vdf"; -#endif - - auto ioMutex = AcquireRemotecacheRepairIoMutex(appKey); - std::lock_guard<std::mutex> ioLock(*ioMutex); - - auto pathW = FileUtil::Utf8ToPath(vdfPath); - std::error_code ec; - - auto sizeBefore = std::filesystem::file_size(pathW, ec); - if (ec) { - LOG("[NS-RC] remotecache.vdf missing for app %u (%s), deferring repair", - appId, vdfPath.c_str()); - return false; - } - auto mtimeBefore = std::filesystem::last_write_time(pathW, ec); - if (ec) { - return false; - } - - std::ifstream in(pathW); - if (!in.is_open()) { - LOG("[NS-RC] remotecache.vdf unreadable for app %u (%s), deferring repair", - appId, vdfPath.c_str()); - return false; - } - std::string content((std::istreambuf_iterator<char>(in)), {}); - in.close(); - - std::string repaired; - size_t added = 0; - if (!ApplyRemotecacheRepair(content, appId, candidates, repaired, added)) { - LOG("[NS-RC] remotecache.vdf missing top section for app %u, skipping repair", appId); - return false; - } - - if (added == 0) { - LOG("[NS-RC] remotecache.vdf already covers all advertised files for app %u " - "(%zu entries already present)", appId, candidates.size()); - std::lock_guard<std::mutex> lock(g_remotecacheRepairMutex); - auto& planted = g_remotecachePlantedRows[appKey]; - for (const auto& c : candidates) planted.insert(c.cleanName); - return true; - } - - // Bail if Steam rewrote the file under us. - auto sizeAfter = std::filesystem::file_size(pathW, ec); - auto mtimeAfter = ec ? std::filesystem::file_time_type{} - : std::filesystem::last_write_time(pathW, ec); - if (ec || sizeAfter != sizeBefore || mtimeAfter != mtimeBefore) { - LOG("[NS-RC] remotecache.vdf changed under us for app %u (%s); deferring repair", - appId, vdfPath.c_str()); - return false; - } - - if (!FileUtil::AtomicWriteText(vdfPath, repaired)) { - LOG("[NS-RC] Failed to write repaired remotecache.vdf for app %u (%s)", - appId, vdfPath.c_str()); - return false; - } - - LOG("[NS-RC] Repaired remotecache.vdf for app %u: added %zu missing entries", - appId, added); - std::lock_guard<std::mutex> lock(g_remotecacheRepairMutex); - auto& planted = g_remotecachePlantedRows[appKey]; - for (const auto& c : candidates) planted.insert(c.cleanName); - return true; -} - - -static bool InsertPlaytimeAppSection(std::string& vdfContent, - const char* const* sections, - size_t sectionCount, - const std::string& lastPlayed, - const std::string& playtime, - const std::string& playtime2wks) { - if (!EnsureVdfSectionPath(vdfContent, sections, sectionCount)) { - return false; - } - - if (!InsertPlaytimeFieldInSection(vdfContent, sections, sectionCount, "LastPlayed", lastPlayed)) { - return false; - } - if (!InsertPlaytimeFieldInSection(vdfContent, sections, sectionCount, "Playtime", playtime)) { - return false; - } - if (!InsertPlaytimeFieldInSection(vdfContent, sections, sectionCount, "Playtime2wks", playtime2wks)) { - return false; - } - return true; -} - -static bool WriteLocalConfigWithRetry(const std::string& vdfPath, const std::string& vdfContent) { - for (int attempt = 0; attempt < 10; ++attempt) { - if (FileUtil::AtomicWriteText(vdfPath, vdfContent)) { - return true; - } -#ifdef _WIN32 - Sleep(200); -#else - std::this_thread::sleep_for(std::chrono::milliseconds(200)); -#endif - } - return false; -} - -static void RestorePlaytimeMetadata(uint32_t accountId, uint32_t appId, const std::vector<uint8_t>& ptData) { - if (ptData.empty()) return; - - std::string blob(reinterpret_cast<const char*>(ptData.data()), ptData.size()); - uint64_t cloudLastPlayed = 0, cloudPlaytime = 0, cloudPlaytime2wks = 0; - ParsePlaytimeBlob(blob, cloudLastPlayed, cloudPlaytime, cloudPlaytime2wks); - - if (cloudLastPlayed == 0 && cloudPlaytime == 0 && cloudPlaytime2wks == 0) { - LOG("[Playtime] Cloud blob empty/invalid for app %u, skipping merge", appId); - return; - } - -#ifdef _WIN32 - std::string vdfPath = GetSteamPath() + "userdata\\" + std::to_string(accountId) - + "\\config\\localconfig.vdf"; -#else - std::string vdfPath = GetSteamPath() + "userdata/" + std::to_string(accountId) - + "/config/localconfig.vdf"; -#endif - - // Shared read; Steam holds localconfig.vdf with no sharing during writes, - // and std::ifstream uses dwShareMode=0 -> ERROR_SHARING_VIOLATION. - std::string vdfContent; - { -#ifdef _WIN32 - auto vdfPathWide = FileUtil::Utf8ToPath(vdfPath).wstring(); - HANDLE hFile = CreateFileW(vdfPathWide.c_str(), GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, nullptr); - if (hFile == INVALID_HANDLE_VALUE) { - LOG("[Playtime] Cannot open localconfig.vdf for reading (app %u, err=%lu)", - appId, GetLastError()); - return; - } - DWORD fileSize = GetFileSize(hFile, nullptr); - if (fileSize != INVALID_FILE_SIZE && fileSize > 0) { - vdfContent.resize(fileSize); - DWORD bytesRead = 0; - if (!ReadFile(hFile, vdfContent.data(), fileSize, &bytesRead, nullptr)) { - LOG("[Playtime] ReadFile failed for localconfig.vdf (app %u, err=%lu)", - appId, GetLastError()); - CloseHandle(hFile); - return; - } - vdfContent.resize(bytesRead); - } - CloseHandle(hFile); -#else - // On Linux, no sharing violation issues with std::ifstream - std::ifstream f(vdfPath); - if (!f) { - LOG("[Playtime] Cannot open localconfig.vdf for reading (app %u)", appId); - return; - } - vdfContent = std::string(std::istreambuf_iterator<char>(f), {}); -#endif - } - - std::string appIdStr = std::to_string(appId); - const char* sections[] = { "UserLocalConfigStore", "Software", "Valve", "Steam", "Apps", appIdStr.c_str() }; - uint64_t localLastPlayed = 0, localPlaytime = 0, localPlaytime2wks = 0; - - struct FieldLoc { size_t valStart; size_t valEnd; }; - FieldLoc lpLoc = {0, 0}, ptLoc = {0, 0}, pt2Loc = {0, 0}; - - bool found = VdfUtil::ForEachFieldInSection(vdfContent, sections, 6, - [&](const VdfUtil::FieldInfo& fi) { - if (fi.key == "LastPlayed") { - localLastPlayed = strtoull(std::string(fi.value).c_str(), nullptr, 10); - lpLoc = { fi.valStart, fi.valEnd }; - } else if (fi.key == "Playtime") { - localPlaytime = strtoull(std::string(fi.value).c_str(), nullptr, 10); - ptLoc = { fi.valStart, fi.valEnd }; - } else if (fi.key == "Playtime2wks") { - localPlaytime2wks = strtoull(std::string(fi.value).c_str(), nullptr, 10); - pt2Loc = { fi.valStart, fi.valEnd }; - } - return true; - }); - - if (!found) { - // Don't fabricate playtime sections for apps the user doesn't own. - // Stale cloud blobs from prior installs / SteamTools-injected sessions - // would otherwise resurrect ghost playtime in localconfig.vdf every login. - if (!LocalStorage::IsAppInstalled(GetSteamPath(), appId)) { - LOG("[Playtime] Skipping VDF synthesis for app %u: not installed locally", appId); - return; - } - std::string newLP = std::to_string(cloudLastPlayed); - std::string newPT = std::to_string(cloudPlaytime); - std::string newPT2 = std::to_string(cloudPlaytime2wks); - if (!InsertPlaytimeAppSection(vdfContent, sections, 6, newLP, newPT, newPT2)) { - // Steam flushes in-memory state to localconfig.vdf on its own cycle. - LOG("[Playtime] App %u section synthesis failed; seeding in-memory only", appId); - RestoreInMemoryPlaytimeMetadata(appId, cloudLastPlayed, cloudPlaytime, cloudPlaytime2wks); - return; - } - - if (WriteLocalConfigWithRetry(vdfPath, vdfContent)) { - RestoreInMemoryPlaytimeMetadata(appId, cloudLastPlayed, cloudPlaytime, cloudPlaytime2wks); - LOG("[Playtime] Created playtime section for app %u: LastPlayed 0->%llu, Playtime 0->%llu, Playtime2wks 0->%llu", - appId, cloudLastPlayed, cloudPlaytime, cloudPlaytime2wks); - } else { - // VDF write failed but cloud values are valid; seed in-memory anyway. - RestoreInMemoryPlaytimeMetadata(appId, cloudLastPlayed, cloudPlaytime, cloudPlaytime2wks); - LOG("[Playtime] Failed to write localconfig.vdf for app %u; seeded in-memory only", appId); - } - return; - } - - uint64_t mergedLP = (cloudLastPlayed > localLastPlayed) ? cloudLastPlayed : localLastPlayed; - uint64_t mergedPT = (cloudPlaytime > localPlaytime) ? cloudPlaytime : localPlaytime; - uint64_t mergedPT2 = (cloudPlaytime2wks > localPlaytime2wks) ? cloudPlaytime2wks : localPlaytime2wks; - // Recent playtime cannot exceed lifetime; reject any value that would. - if (mergedPT2 > mergedPT) - mergedPT2 = localPlaytime2wks <= mergedPT ? localPlaytime2wks : 0; - if (mergedLP == localLastPlayed && mergedPT == localPlaytime && mergedPT2 == localPlaytime2wks) { - RestoreInMemoryPlaytimeMetadata(appId, mergedLP, mergedPT, mergedPT2); - LOG("[Playtime] Local playtime already up-to-date for app %u", appId); - return; - } - - std::string newLP = std::to_string(mergedLP); - std::string newPT = std::to_string(mergedPT); - std::string newPT2 = std::to_string(mergedPT2); - bool lpValid = lpLoc.valEnd > lpLoc.valStart; - bool ptValid = ptLoc.valEnd > ptLoc.valStart; - bool pt2Valid = pt2Loc.valEnd > pt2Loc.valStart; - - struct Replacement { size_t start; size_t len; std::string text; }; - std::vector<Replacement> reps; - if (lpValid) reps.push_back({lpLoc.valStart, lpLoc.valEnd - lpLoc.valStart, newLP}); - if (ptValid) reps.push_back({ptLoc.valStart, ptLoc.valEnd - ptLoc.valStart, newPT}); - if (pt2Valid) reps.push_back({pt2Loc.valStart, pt2Loc.valEnd - pt2Loc.valStart, newPT2}); - - if (!reps.empty()) { - std::sort(reps.begin(), reps.end(), - [](const Replacement& a, const Replacement& b) { return a.start > b.start; }); - for (auto& r : reps) - vdfContent.replace(r.start, r.len, r.text); - } - - bool inserted = false; - if (!lpValid) { - inserted = InsertPlaytimeFieldInSection(vdfContent, sections, 6, "LastPlayed", newLP) || inserted; - } - if (!ptValid) { - inserted = InsertPlaytimeFieldInSection(vdfContent, sections, 6, "Playtime", newPT) || inserted; - } - if (!pt2Valid) { - inserted = InsertPlaytimeFieldInSection(vdfContent, sections, 6, "Playtime2wks", newPT2) || inserted; - } - if (!lpValid && !ptValid && !pt2Valid && !inserted) { - LOG("[Playtime] App %u section has no playtime fields, skipping write", appId); - return; - } - - if (WriteLocalConfigWithRetry(vdfPath, vdfContent)) { - RestoreInMemoryPlaytimeMetadata(appId, mergedLP, mergedPT, mergedPT2); - LOG("[Playtime] Merged playtime for app %u: LastPlayed %llu->%llu, Playtime %llu->%llu, Playtime2wks %llu->%llu", - appId, localLastPlayed, mergedLP, localPlaytime, mergedPT, localPlaytime2wks, mergedPT2); - } else { - // Merge succeeded but VDF write failed; in-memory seed is still safe. - RestoreInMemoryPlaytimeMetadata(appId, mergedLP, mergedPT, mergedPT2); - LOG("[Playtime] Failed to write localconfig.vdf for app %u; seeded in-memory only", appId); - } -} - -void RestoreAppMetadata(uint32_t accountId, uint32_t appId) { - InvalidateTokenCaches(accountId, appId); - -#ifdef _WIN32 - if (MetadataSync::syncAchievements.load(std::memory_order_relaxed)) { - auto statsData = CloudStorage::RetrieveBlob( - accountId, kAccountScopeAppId, AccountStatsFilename(appId)); - if (!statsData.empty()) - MergeStatsFile(appId, accountId, statsData); - } - if (MetadataSync::syncPlaytime.load(std::memory_order_relaxed)) { - auto ptData = CloudStorage::RetrieveBlob( - accountId, kAccountScopeAppId, AccountPlaytimeFilename(appId)); - RestorePlaytimeMetadata(accountId, appId, ptData); - } -#endif -} - - static std::string GetMachineName() { #ifdef _WIN32 char buf[MAX_COMPUTERNAME_LENGTH + 1]; @@ -1526,343 +1020,11 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB return body; } -// Binary KV reader/writer for UserGameStats merge -enum BkvType : uint8_t { - BKV_SECTION = 0x00, - BKV_STRING = 0x01, - BKV_INT = 0x02, - BKV_FLOAT = 0x03, - BKV_UINT64 = 0x07, - BKV_END = 0x08, - BKV_INT64 = 0x0A, -}; - -struct BkvNode { - BkvType type; - std::string name; - // value storage (union-like, depends on type) - uint32_t intVal = 0; - float floatVal = 0.0f; - uint64_t uint64Val = 0; - int64_t int64Val = 0; - std::string strVal; - std::vector<BkvNode> children; // for BKV_SECTION -}; - -static constexpr int BKV_MAX_DEPTH = 128; -static constexpr size_t BKV_MAX_NODES = 100000; - -static bool BkvRead(const uint8_t* data, size_t len, size_t& pos, std::vector<BkvNode>& out, int depth, size_t& totalNodes) { - if (depth > BKV_MAX_DEPTH) { - LOG("[Stats] BKV nesting too deep (%d), aborting parse", depth); - return false; - } - while (pos < len) { - uint8_t tag = data[pos++]; - if (tag == BKV_END) - return true; - - BkvNode node; - node.type = static_cast<BkvType>(tag); - - // read null-terminated name - const char* nameStart = reinterpret_cast<const char*>(data + pos); - size_t nameEnd = pos; - while (nameEnd < len && data[nameEnd] != 0) nameEnd++; - if (nameEnd >= len) return false; - node.name.assign(nameStart, nameEnd - pos); - pos = nameEnd + 1; - - switch (node.type) { - case BKV_SECTION: - if (!BkvRead(data, len, pos, node.children, depth + 1, totalNodes)) - return false; - break; - case BKV_STRING: { - const char* s = reinterpret_cast<const char*>(data + pos); - size_t end = pos; - while (end < len && data[end] != 0) end++; - if (end >= len) return false; - node.strVal.assign(s, end - pos); - pos = end + 1; - break; - } - case BKV_INT: - case BKV_FLOAT: - if (pos + 4 > len) return false; - if (node.type == BKV_INT) - memcpy(&node.intVal, data + pos, 4); - else - memcpy(&node.floatVal, data + pos, 4); - pos += 4; - break; - case BKV_UINT64: - if (pos + 8 > len) return false; - memcpy(&node.uint64Val, data + pos, 8); - pos += 8; - break; - case BKV_INT64: - if (pos + 8 > len) return false; - memcpy(&node.int64Val, data + pos, 8); - pos += 8; - break; - default: - LOG("[Stats] Unknown BKV tag 0x%02X at offset %zu", tag, pos - 1); - return false; - } - if (++totalNodes > BKV_MAX_NODES) { - LOG("[Stats] BKV node limit exceeded (%zu), aborting parse", totalNodes); - return false; - } - out.push_back(std::move(node)); - } - return depth == 0; -} - -static void BkvWrite(const std::vector<BkvNode>& nodes, std::vector<uint8_t>& out) { - for (auto& n : nodes) { - out.push_back(static_cast<uint8_t>(n.type)); - out.insert(out.end(), n.name.begin(), n.name.end()); - out.push_back(0); - - switch (n.type) { - case BKV_SECTION: - BkvWrite(n.children, out); - out.push_back(BKV_END); - break; - case BKV_STRING: - out.insert(out.end(), n.strVal.begin(), n.strVal.end()); - out.push_back(0); - break; - case BKV_INT: - out.insert(out.end(), reinterpret_cast<const uint8_t*>(&n.intVal), - reinterpret_cast<const uint8_t*>(&n.intVal) + 4); - break; - case BKV_FLOAT: - out.insert(out.end(), reinterpret_cast<const uint8_t*>(&n.floatVal), - reinterpret_cast<const uint8_t*>(&n.floatVal) + 4); - break; - case BKV_UINT64: - out.insert(out.end(), reinterpret_cast<const uint8_t*>(&n.uint64Val), - reinterpret_cast<const uint8_t*>(&n.uint64Val) + 8); - break; - case BKV_INT64: - out.insert(out.end(), reinterpret_cast<const uint8_t*>(&n.int64Val), - reinterpret_cast<const uint8_t*>(&n.int64Val) + 8); - break; - default: - break; - } - } -} - -static BkvNode* BkvFind(std::vector<BkvNode>& nodes, const std::string& name) { - for (auto& n : nodes) - if (n.name == name) return &n; - return nullptr; -} - -// Merge cloud stats into local stats (monotonic: more achievements/stats wins). -// Returns merged node tree ready to write. -static std::vector<BkvNode> MergeStats( - std::vector<BkvNode>& local, std::vector<BkvNode>& cloud) -{ - // Top level should be a single "cache" section in each - BkvNode* localCache = BkvFind(local, "cache"); - BkvNode* cloudCache = BkvFind(cloud, "cache"); - if (!localCache || !cloudCache) { - if (cloudCache) return std::move(cloud); - return std::move(local); - } - - // Walk cloud stat sections and merge into local - for (auto& cloudStat : cloudCache->children) { - if (cloudStat.type != BKV_SECTION) continue; - // skip non-stat sections (crc, PendingChanges are INT not SECTION) - - BkvNode* localStat = BkvFind(localCache->children, cloudStat.name); - if (!localStat) { - // stat exists in cloud but not locally - take it - localCache->children.push_back(cloudStat); - continue; - } - - BkvNode* localData = BkvFind(localStat->children, "data"); - BkvNode* cloudData = BkvFind(cloudStat.children, "data"); - if (!localData || !cloudData) continue; - - BkvNode* cloudAchTimes = BkvFind(cloudStat.children, "AchievementTimes"); - BkvNode* localAchTimes = BkvFind(localStat->children, "AchievementTimes"); - - if (cloudAchTimes || localAchTimes) { - // Achievement bitfield OR; intVal only valid when type==BKV_INT. - if (localData->type != BKV_INT || cloudData->type != BKV_INT) { - LOG("[MergeStats] skipping achievement OR for %s: type mismatch " - "(local=%d cloud=%d)", cloudStat.name.c_str(), - (int)localData->type, (int)cloudData->type); - continue; - } - localData->intVal |= cloudData->intVal; - - // Create AchievementTimes section if missing - if (!localAchTimes) { - localStat->children.push_back(BkvNode{BKV_SECTION, "AchievementTimes"}); - localAchTimes = &localStat->children.back(); - } - - // Merge timestamps: for each bit index, keep earliest nonzero - if (cloudAchTimes) { - for (auto& ct : cloudAchTimes->children) { - if (ct.type != BKV_INT) continue; - BkvNode* lt = BkvFind(localAchTimes->children, ct.name); - if (!lt) { - localAchTimes->children.push_back(ct); - } else if (ct.intVal != 0 && (lt->intVal == 0 || ct.intVal < lt->intVal)) { - lt->intVal = ct.intVal; - } - } - } - } else { - // Regular stat: take max - if (localData->type == BKV_INT && cloudData->type == BKV_INT) { - if (cloudData->intVal > localData->intVal) - localData->intVal = cloudData->intVal; - } else if (localData->type == BKV_FLOAT && cloudData->type == BKV_FLOAT) { - if (cloudData->floatVal > localData->floatVal) - localData->floatVal = cloudData->floatVal; - } else if (localData->type == BKV_UINT64 && cloudData->type == BKV_UINT64) { - if (cloudData->uint64Val > localData->uint64Val) - localData->uint64Val = cloudData->uint64Val; - } else if (localData->type == BKV_INT64 && cloudData->type == BKV_INT64) { - if (cloudData->int64Val > localData->int64Val) - localData->int64Val = cloudData->int64Val; - } - } - } - - // Recalculate CRC: set to 0 so Steam recalculates on load - BkvNode* crc = BkvFind(localCache->children, "crc"); - if (crc && crc->type == BKV_INT) - crc->intVal = 0; - - return std::move(local); -} - -static bool HasNonZeroStatsData(const std::vector<BkvNode>& nodes) { - for (const auto& n : nodes) { - if (n.name == "data") { - if (n.type == BKV_INT && n.intVal != 0) return true; - if (n.type == BKV_FLOAT && n.floatVal != 0.0f) return true; - if (n.type == BKV_UINT64 && n.uint64Val != 0) return true; - if (n.type == BKV_INT64 && n.int64Val != 0) return true; - } - if (!n.children.empty() && HasNonZeroStatsData(n.children)) return true; - } - return false; -} - -// Merge cloud stats into the local stats file on disk. -static bool MergeStatsFile(uint32_t appId, uint32_t accountId, - const std::vector<uint8_t>& cloudData) -{ -#ifdef _WIN32 - std::string statsPath = GetSteamPath() + "appcache\\stats\\UserGameStats_" - + std::to_string(accountId) + "_" + std::to_string(appId) + ".bin"; -#else - std::string statsPath = GetSteamPath() + "appcache/stats/UserGameStats_" - + std::to_string(accountId) + "_" + std::to_string(appId) + ".bin"; -#endif - - // Parse cloud data - size_t cloudPos = 0; - size_t cloudNodeCount = 0; - std::vector<BkvNode> cloudNodes; - if (!BkvRead(cloudData.data(), cloudData.size(), cloudPos, cloudNodes, 0, cloudNodeCount)) { - LOG("[Stats] Failed to parse cloud stats for app %u, skipping merge", appId); - return false; - } - - std::ifstream localFile(FileUtil::Utf8ToPath(statsPath), std::ios::binary | std::ios::ate); - if (!localFile.is_open()) { - if (!HasNonZeroStatsData(cloudNodes)) { - LOG("[Stats] No local stats and cloud has no positive stats for app %u, skipping restore", appId); - return false; - } - // No local file: rewrite parsed cloud to strip junk. - std::vector<uint8_t> outBuf; - BkvWrite(cloudNodes, outBuf); - outBuf.push_back(BKV_END); - if (!FileUtil::AtomicWriteBinary(statsPath, outBuf.data(), outBuf.size())) { - LOG("[Stats] Failed to create stats file for app %u", appId); - return false; - } - LOG("[Stats] No local stats, wrote cloud stats for app %u (%zu bytes)", appId, outBuf.size()); - return true; - } - - auto localSize = localFile.tellg(); - if (localSize <= 0) { - localFile.close(); - if (!HasNonZeroStatsData(cloudNodes)) { - LOG("[Stats] Local stats empty and cloud has no positive stats for app %u, skipping restore", appId); - return false; - } - std::vector<uint8_t> outBuf; - BkvWrite(cloudNodes, outBuf); - outBuf.push_back(BKV_END); - if (!FileUtil::AtomicWriteBinary(statsPath, outBuf.data(), outBuf.size())) - return false; - LOG("[Stats] Local stats empty, wrote cloud stats for app %u (%zu bytes)", appId, outBuf.size()); - return true; - } - - std::vector<uint8_t> localData(static_cast<size_t>(localSize)); - localFile.seekg(0); - localFile.read(reinterpret_cast<char*>(localData.data()), localSize); - auto localGcount = localFile.gcount(); - bool localReadOk = !localFile.fail() && - static_cast<std::streamsize>(localGcount) == localSize; - localFile.close(); - - if (!localReadOk) { - LOG("[Stats] short read on local stats for app %u (expected %lld, got %lld), skipping restore", - appId, (long long)localSize, (long long)localGcount); - return false; - } - - // Parse local data - size_t localPos = 0; - size_t localNodeCount = 0; - std::vector<BkvNode> localNodes; - if (!BkvRead(localData.data(), localData.size(), localPos, localNodes, 0, localNodeCount)) { - LOG("[Stats] Failed to parse local stats for app %u, skipping restore", appId); - return false; - } - - // Merge - auto merged = MergeStats(localNodes, cloudNodes); - - // Serialize - std::vector<uint8_t> outBuf; - BkvWrite(merged, outBuf); - outBuf.push_back(BKV_END); - - if (!FileUtil::AtomicWriteBinary(statsPath, outBuf.data(), outBuf.size())) { - LOG("[Stats] Failed to write merged stats for app %u", appId); - return false; - } - - LOG("[Stats] Merged stats for app %u (local=%zu cloud=%zu merged=%zu bytes)", - appId, localData.size(), cloudData.size(), outBuf.size()); - return true; -} - // SignalAppLaunchIntent: return pending_remote_operations. // Also pre-restores cloud files to game folders so Steam's sync finds them on disk. RpcResult HandleLaunchIntent(uint32_t appId, const std::vector<PB::Field>& reqBody) { LOG("[NS] SignalAppLaunchIntent app=%u", appId); - RecordLaunchTime(appId); uint32_t accountId = 0; if (!RequireAccountId("SignalAppLaunchIntent", appId, accountId)) { PB::Writer body; @@ -1879,16 +1041,6 @@ RpcResult HandleLaunchIntent(uint32_t appId, const std::vector<PB::Field>& reqBo ConsumeConflictLocalChoice(appId); - if (CloudStorage::IsCloudActive()) { - uint32_t asyncAcct = accountId; - uint32_t asyncApp = appId; - std::thread([asyncAcct, asyncApp] { - CloudStorage::InflightSyncScope guard; - if (!guard.entered) return; - RestoreAppMetadata(asyncAcct, asyncApp); - }).detach(); - } - PendingOpsJournal::Entry currentSession; currentSession.machineName = GetMachineName(); currentSession.timeLastUpdated = static_cast<uint32_t>(time(nullptr)); diff --git a/src/common/rpc_handlers.h b/src/common/rpc_handlers.h index e05f716b..4f490ab2 100644 --- a/src/common/rpc_handlers.h +++ b/src/common/rpc_handlers.h @@ -53,7 +53,6 @@ RpcResult HandleCompleteBatch(uint32_t appId, const std::vector<PB::Field>& reqB RpcResult HandleFileDownload(uint32_t appId, const std::vector<PB::Field>& reqBody); RpcResult HandleDeleteFile(uint32_t appId, const std::vector<PB::Field>& reqBody); -void RestoreAppMetadata(uint32_t accountId, uint32_t appId); void ShutdownRpcHandlers(); void RecordConflictResolution(uint32_t appId, bool choseLocal); @@ -62,8 +61,4 @@ bool ConsumeConflictLocalChoice(uint32_t appId); // Flush pending sync icon states to registry.vdf (Linux, called from OnUnload). void FlushPendingSyncStates(); -// True if the UserGameStats blob has any non-zero stat/achievement data. -// Empty stubs and unparseable input return false. -bool StatsBlobHasUnlocks(const uint8_t* data, size_t len); - } // namespace CloudIntercept diff --git a/src/common/stats_handlers.cpp b/src/common/stats_handlers.cpp new file mode 100644 index 00000000..c9ef14fa --- /dev/null +++ b/src/common/stats_handlers.cpp @@ -0,0 +1,355 @@ +#include "stats_handlers.h" +#include "stats_store.h" +#include "protobuf.h" +#include "log.h" + +#include <cstring> +#include <mutex> +#include <unordered_set> + +namespace StatsHandlers { + +// Track which apps have active game sessions for playtime +static std::unordered_set<uint32_t> g_activeApps; +static std::mutex g_sessionMutex; + +// Namespace-app predicate (installed by the platform layer). When unset, we +// fail CLOSED -- track nothing -- so real games are never accidentally synced. +static NamespacePredicate g_isNamespaceApp; + +void SetNamespacePredicate(NamespacePredicate pred) { + g_isNamespaceApp = std::move(pred); +} + +static bool IsNamespaceApp(uint32_t appId) { + return g_isNamespaceApp && g_isNamespaceApp(appId); +} + +void Init() { + LOG("[Stats] Handlers initialized"); +} + +// Player.GetUserStats#1 handler +// Request: steamid(1), appid(2), sha_schema(3), crc_stats(4) +// Response: sha_schema(1), crc_stats(2), schema(3), stats[](4) +// Stats sub: stat_id(1), stat_value(2), unlock_times[](3) +// Unlock_Time sub: achievement_bit(1), unlock_time(2) +CloudIntercept::RpcResult HandleGetUserStats(uint32_t appId, const std::vector<PB::Field>& reqBody) { + uint32_t clientCrc = 0; + auto* crcField = PB::FindField(reqBody, 4); // crc_stats + if (crcField) clientCrc = (uint32_t)crcField->varintVal; + + LOG("[Stats] GetUserStats app=%u clientCrc=%u", appId, clientCrc); + + // GetOrCreate seeds our store from Steam's native UserGameStats blob on + // first access, so for a real app `stats` now holds the authoritative data. + auto& stats = StatsStore::GetOrCreate(appId); + + PB::Writer resp; + + // Authoritative server contract (IDA-verified: CAPIJobRequestUserStats @ + // steamclient!0x138A45A20 / legacy sub_138A44F70): + // * crc_stats is a server-owned opaque token. The client stores whatever + // crc we last sent and echoes it in the request (field 4). It never + // recomputes it from data. + // * The client adopts our stats ONLY when our crc_stats (response field 2) + // DIFFERS from the client's current crc (request field 4). When they + // match and we send no stats, the client logs "we must be up to date" + // and leaves its local stats untouched. + // So we are the source of truth: always report OUR crc. Send schema + the + // full stats array only when the client is stale (clientCrc != ourCrc), + // which makes the client adopt our (cloud-restored) data. When they match, + // send crc only -> client no-ops. We hold the authoritative copy even if + // empty, so we never clobber: an empty store only happens when Steam itself + // had no stats blob for this app. + + // Field 2: crc_stats (always our authoritative token) + resp.WriteVarint(2, stats.crcStats); + + if (clientCrc == stats.crcStats) { + // Client already in sync with us -> no-op response (crc only). + LOG("[Stats] app=%u up-to-date (crc=%u); sending crc-only no-op", appId, stats.crcStats); + return CloudIntercept::RpcResult(std::move(resp)); + } + + // Client is stale -> push our authoritative schema + full stats so it adopts + // them. Schema is REQUIRED by the client when stats are present (else it + // logs "missing schema in response" and discards). + if (!stats.schema.empty()) { + resp.WriteBytes(3, stats.schema.data(), stats.schema.size()); + LOG("[Stats] Sending schema (%zu bytes)", stats.schema.size()); + } else if (!stats.stats.empty()) { + // We have stats but no schema -> the client would reject the stats. + // Safer to send crc only (no stats) so the client keeps its own data + // rather than discarding everything. This should be rare (import always + // grabs the schema when the stats blob exists). + LOG("[Stats] app=%u WARNING: have %zu stats but no schema; sending crc-only to avoid client-side discard", + appId, stats.stats.size()); + return CloudIntercept::RpcResult(std::move(resp)); + } + + // Field 4: stats (repeated) + for (auto& s : stats.stats) { + PB::Writer statMsg; + statMsg.WriteVarint(1, s.statId); // stat_id + statMsg.WriteVarint(2, s.value); // stat_value + + for (auto& a : stats.achievements) { + if (a.statId == s.statId) { + for (uint32_t bit = 0; bit < 32; bit++) { + if (a.unlockTimes[bit] != 0) { + PB::Writer unlockMsg; + unlockMsg.WriteVarint(1, bit); // achievement_bit + unlockMsg.WriteFixed32(2, a.unlockTimes[bit]); // unlock_time + statMsg.WriteSubmessage(3, unlockMsg); // unlock_times + } + } + break; + } + } + + resp.WriteSubmessage(4, statMsg); + } + + LOG("[Stats] Returning %zu stats, crc=%u", stats.stats.size(), stats.crcStats); + return CloudIntercept::RpcResult(std::move(resp)); +} + +// Player.ClientGetLastPlayedTimes#1 handler +// +// Wire format reversed from steamclient64.dll (CPlayer_GetLastPlayedTimes_* +// FileDescriptor + the client consumer sub_1389C7930 -> CUser::GetAppMinutesPlayedData): +// Request: min_last_played(1, uint32) +// Response: games(1, repeated Game) +// Game: appid(1, int32), last_playtime(2, uint32), +// playtime_2weeks(3, int32), playtime_forever(4, int32), +// first_playtime(5, uint32), +// playtime_windows_forever(6), playtime_mac_forever(7), +// playtime_linux_forever(8) +// Returning this response makes Steam populate its own in-memory minutes-played +// record (the same one the library UI reads), so playtime shows natively. +CloudIntercept::RpcResult HandleGetLastPlayedTimes(const std::vector<PB::Field>& reqBody) { + uint32_t minLastPlayed = 0; + auto* minField = PB::FindField(reqBody, 1); + if (minField) minLastPlayed = (uint32_t)minField->varintVal; + + PB::Writer resp; + size_t emitted = 0; + + for (uint32_t appId : StatsStore::GetTrackedApps()) { + StatsStore::PlaytimeData pt = StatsStore::GetPlaytime(appId); + + // min_last_played is the client's watermark: skip games it already has + // data at/after. Always emit if we have no last-played stamp. + if (minLastPlayed != 0 && pt.lastPlayedTime != 0 && + pt.lastPlayedTime < minLastPlayed) + continue; + if (pt.minutesForever == 0 && pt.lastPlayedTime == 0) + continue; + + PB::Writer game; + game.WriteVarint(1, appId); // appid (int32) + game.WriteVarint(2, pt.lastPlayedTime); // last_playtime (uint32) + game.WriteVarint(3, pt.minutesLastTwoWeeks); // playtime_2weeks (int32) + game.WriteVarint(4, pt.minutesForever); // playtime_forever (int32) + // Per-platform forever fields so the native record is internally consistent. + if (pt.playtimeWindows) game.WriteVarint(6, pt.playtimeWindows); + if (pt.playtimeMac) game.WriteVarint(7, pt.playtimeMac); + if (pt.playtimeLinux) game.WriteVarint(8, pt.playtimeLinux); + + resp.WriteSubmessage(1, game); // games (repeated) + ++emitted; + } + + LOG("[Stats] GetLastPlayedTimes: returned %zu game(s) (min_last_played=%u)", + emitted, minLastPlayed); + return CloudIntercept::RpcResult(std::move(resp)); +} + +// Legacy EMsg 818: CMsgClientGetUserStats +// Request: game_id(1,fixed64), crc_stats(2,uint32), schema_local_version(3,int32), steam_id_for_user(4,fixed64) +// Response: game_id(1,fixed64), eresult(2,int32), crc_stats(3,uint32), schema(4,bytes), +// stats[](5), achievement_blocks[](6) +std::optional<std::vector<uint8_t>> HandleLegacyGetUserStats( + const uint8_t* body, size_t bodyLen, uint64_t steamId) { + (void)steamId; + + auto fields = PB::Parse(body, bodyLen); + + // Extract game_id (field 1, fixed64) + uint64_t gameId = 0; + auto* f1 = PB::FindField(fields, 1); + if (f1) gameId = f1->varintVal; + + // AppID is lower 24 bits of game_id + uint32_t appId = (uint32_t)(gameId & 0xFFFFFF); + if (appId == 0) return std::nullopt; // pass through + + uint32_t clientCrc = 0; + auto* f2 = PB::FindField(fields, 2); + if (f2) clientCrc = (uint32_t)f2->varintVal; + + int32_t schemaVersion = -1; + auto* f3 = PB::FindField(fields, 3); + if (f3) schemaVersion = (int32_t)f3->varintVal; + + LOG("[Stats] Legacy GetUserStats app=%u gameId=%llu clientCrc=%u schemaVer=%d", + appId, (unsigned long long)gameId, clientCrc, schemaVersion); + + auto& stats = StatsStore::GetOrCreate(appId); + + // If client has no schema (version=-1) and we don't have one either, + // pass through to let the real server provide the schema. + if (schemaVersion == -1 && stats.schema.empty()) { + LOG("[Stats] No schema available, passing through to server"); + return std::nullopt; + } + + PB::Writer resp; + resp.WriteFixed64(1, gameId); // game_id + resp.WriteVarint(2, 1); // eresult = OK + resp.WriteVarint(3, stats.crcStats); // crc_stats + + // schema (field 4) - send if client CRC differs + if (clientCrc != stats.crcStats && !stats.schema.empty()) { + resp.WriteBytes(4, stats.schema.data(), stats.schema.size()); + } + + // stats (field 5, repeated submessage): stat_id(1), stat_value(2) + for (auto& s : stats.stats) { + PB::Writer statMsg; + statMsg.WriteVarint(1, s.statId); + statMsg.WriteVarint(2, s.value); + resp.WriteSubmessage(5, statMsg); + } + + // achievement_blocks (field 6, repeated): achievement_id(1,uint32), unlock_time[](2, repeated fixed32) + for (auto& a : stats.achievements) { + PB::Writer achMsg; + achMsg.WriteVarint(1, a.statId); + for (int i = 0; i < 32; i++) { + achMsg.WriteFixed32(2, a.unlockTimes[i]); + } + resp.WriteSubmessage(6, achMsg); + } + + return resp.Data(); +} + +// Legacy EMsg 820: CMsgClientStoreUserStats2 +// Request: game_id(1,fixed64), settor_steam_id(2,fixed64), settee_steam_id(3,fixed64), +// crc_stats(4,uint32), explicit_reset(5,bool), stats[](6) +// Stats sub: stat_id(1), stat_value(2) +// Response (EMsg 821): game_id(1), eresult(2), crc_stats(3), stats_failed_validation[](4), stats_out_of_date(5) +std::optional<std::vector<uint8_t>> HandleLegacyStoreUserStats2( + const uint8_t* body, size_t bodyLen, uint64_t steamId) { + (void)steamId; + + auto fields = PB::Parse(body, bodyLen); + + uint64_t gameId = 0; + auto* f1 = PB::FindField(fields, 1); + if (f1) gameId = f1->varintVal; + + uint32_t appId = (uint32_t)(gameId & 0xFFFFFF); + if (appId == 0) return std::nullopt; + + bool explicitReset = false; + auto* f5 = PB::FindField(fields, 5); + if (f5) explicitReset = (f5->varintVal != 0); + + LOG("[Stats] Legacy StoreUserStats2 app=%u gameId=%llu reset=%d", + appId, (unsigned long long)gameId, explicitReset); + + if (explicitReset) { + auto& stats = StatsStore::GetOrCreate(appId); + stats.stats.clear(); + stats.achievements.clear(); + stats.crcStats = 0; + } + + std::vector<StatsStore::StatEntry> entries; + for (auto& f : fields) { + if (f.fieldNum == 6 && f.wireType == PB::LengthDelimited) { + auto sub = PB::Parse(f.data, f.dataLen); + uint32_t statId = 0, statVal = 0; + auto* sid = PB::FindField(sub, 1); + auto* sval = PB::FindField(sub, 2); + if (sid) statId = (uint32_t)sid->varintVal; + if (sval) statVal = (uint32_t)sval->varintVal; + entries.push_back({statId, statVal}); + } + } + + uint32_t newCrc = StatsStore::SetStats(appId, entries); + LOG("[Stats] Stored %zu stats, newCrc=%u", entries.size(), newCrc); + + PB::Writer resp; + resp.WriteFixed64(1, gameId); // game_id + resp.WriteVarint(2, 1); // eresult = OK + resp.WriteVarint(3, newCrc); // crc_stats + // No failed validations - we accept everything + + StatsStore::FlushAll(); + + return resp.Data(); +} + +// Observe CMsgClientGamesPlayed (EMsg 5410) to track playtime. +// We don't intercept this - just peek at it as it passes through. +// Message: games_played[](1) -> game_id(2, fixed64) +void ObserveGamesPlayed(const uint8_t* body, size_t bodyLen) { + auto fields = PB::Parse(body, bodyLen); + + std::unordered_set<uint32_t> currentApps; + + for (auto& f : fields) { + if (f.fieldNum == 1 && f.wireType == PB::LengthDelimited) { + auto sub = PB::Parse(f.data, f.dataLen); + auto* gameIdField = PB::FindField(sub, 2); // game_id (fixed64) + if (gameIdField) { + uint64_t gameId = gameIdField->varintVal; + uint32_t appId = (uint32_t)(gameId & 0xFFFFFF); + // Only track namespace/lua apps. Real owned games keep their + // server-side playtime; we must never record or sync theirs. + if (appId != 0 && IsNamespaceApp(appId)) { + currentApps.insert(appId); + } + } + } + } + + std::lock_guard<std::mutex> lock(g_sessionMutex); + + for (uint32_t appId : currentApps) { + if (g_activeApps.find(appId) == g_activeApps.end()) { + StatsStore::StartSession(appId); + g_activeApps.insert(appId); + } + } + + std::vector<uint32_t> ended; + for (uint32_t appId : g_activeApps) { + if (currentApps.find(appId) == currentApps.end()) { + StatsStore::EndSession(appId); + ended.push_back(appId); + } + } + for (uint32_t appId : ended) { + g_activeApps.erase(appId); + } +} + +void Shutdown() { + { + std::lock_guard<std::mutex> lock(g_sessionMutex); + for (uint32_t appId : g_activeApps) { + StatsStore::EndSession(appId); + } + g_activeApps.clear(); + } + StatsStore::FlushAll(); + LOG("[Stats] Shutdown complete"); +} + +} // namespace StatsHandlers diff --git a/src/common/stats_handlers.h b/src/common/stats_handlers.h new file mode 100644 index 00000000..d0be7d4e --- /dev/null +++ b/src/common/stats_handlers.h @@ -0,0 +1,53 @@ +#pragma once +#include "protobuf.h" +#include "rpc_handlers.h" +#include <vector> +#include <cstdint> +#include <optional> +#include <functional> + +namespace StatsHandlers { + +// Service RPC method names +inline constexpr const char* RPC_GET_USER_STATS = "Player.GetUserStats#1"; +inline constexpr const char* RPC_GET_LAST_PLAYED = "Player.ClientGetLastPlayedTimes#1"; + +// Namespace-app predicate. The platform layer installs this so playtime +// session tracking (and any persistence) is restricted to namespace/lua apps +// only -- real owned games must NOT have their playtime tracked or synced. +// Returns true iff appId is a namespace app we own. +using NamespacePredicate = std::function<bool(uint32_t appId)>; +void SetNamespacePredicate(NamespacePredicate pred); + +// Legacy EMsg numbers +inline constexpr uint32_t EMSG_CLIENT_GET_USER_STATS = 818; +inline constexpr uint32_t EMSG_CLIENT_GET_USER_STATS_RESP = 819; +inline constexpr uint32_t EMSG_CLIENT_STORE_USER_STATS2 = 820; +inline constexpr uint32_t EMSG_CLIENT_STORE_USER_STATS_RESP = 821; +inline constexpr uint32_t EMSG_CLIENT_GAMES_PLAYED = 5410; + +// Initialize stats system (call after StatsStore::Init) +void Init(); + +// Service RPC handler for Player.GetUserStats#1 +CloudIntercept::RpcResult HandleGetUserStats(uint32_t appId, const std::vector<PB::Field>& reqBody); + +// Service RPC handler for Player.ClientGetLastPlayedTimes#1 +CloudIntercept::RpcResult HandleGetLastPlayedTimes(const std::vector<PB::Field>& reqBody); + +// Legacy EMsg handlers - return response body bytes +// Returns nullopt if this EMsg should pass through to real server +std::optional<std::vector<uint8_t>> HandleLegacyGetUserStats( + const uint8_t* body, size_t bodyLen, uint64_t steamId); + +std::optional<std::vector<uint8_t>> HandleLegacyStoreUserStats2( + const uint8_t* body, size_t bodyLen, uint64_t steamId); + +// Called when we see CMsgClientGamesPlayed (EMsg 5410) pass through. +// We don't intercept it - just observe it to track playtime. +void ObserveGamesPlayed(const uint8_t* body, size_t bodyLen); + +// Shutdown - flush and cleanup +void Shutdown(); + +} // namespace StatsHandlers diff --git a/src/common/stats_store.cpp b/src/common/stats_store.cpp new file mode 100644 index 00000000..2cc85376 --- /dev/null +++ b/src/common/stats_store.cpp @@ -0,0 +1,788 @@ +#include "stats_store.h" +#include "json.h" +#include "vdf.h" +#include "log.h" + +#include <fstream> +#include <sstream> +#include <filesystem> +#include <chrono> +#include <algorithm> +#include <cstring> + +namespace fs = std::filesystem; + +namespace StatsStore { + +static std::string g_storageRoot; +static std::string g_steamPath; // e.g. "C:\Games\Steam\" (used to locate native blobs) +static std::mutex g_mutex; + +// Forward decl: deterministic CRC over an AppStats (caller holds g_mutex). +uint32_t ComputeCrcLocked(const AppStats& stats); +static std::unordered_map<uint32_t, AppStats> g_cache; +static std::unordered_map<uint32_t, bool> g_dirty; + +// Cloud-backing provider (installed by the platform layer; see SetCloudProvider). +static CloudPullFn g_cloudPull; +static CloudPushFn g_cloudPush; + +// Resolves the current Steam accountId for locating native UserGameStats blobs. +static AccountIdProvider g_accountIdProvider; + +void SetCloudProvider(CloudPullFn pull, CloudPushFn push) { + std::lock_guard<std::mutex> lock(g_mutex); + g_cloudPull = std::move(pull); + g_cloudPush = std::move(push); +} + +void SetAccountIdProvider(AccountIdProvider provider) { + std::lock_guard<std::mutex> lock(g_mutex); + g_accountIdProvider = std::move(provider); +} + +// ── Native UserGameStats (BKV) reader ──────────────────────────────────── +// Steam stores per-user stats as a binary-KV tree in +// appcache\stats\UserGameStats_<accountId>_<appId>.bin +// Tree shape: +// cache (SECTION) +// ├── crc (INT) -- Steam's own token (we recompute our own) +// ├── PendingChanges (INT) +// └── <statId> (SECTION) -- decimal-string name +// ├── data (INT/FLOAT/UINT64/INT64) -- stat value (achievement: bitfield) +// └── AchievementTimes (SECTION) -- optional +// └── <bit> (INT) -- unlock unix timestamp per bit index +// Parser mirrors the (now-removed) bkv_stats.cpp reader. +namespace { + +enum BkvType : uint8_t { + BKV_SECTION = 0x00, + BKV_STRING = 0x01, + BKV_INT = 0x02, + BKV_FLOAT = 0x03, + BKV_UINT64 = 0x07, + BKV_END = 0x08, + BKV_INT64 = 0x0A, +}; + +struct BkvNode { + BkvType type{}; + std::string name; + uint32_t intVal = 0; + float floatVal = 0.0f; + uint64_t uint64Val = 0; + int64_t int64Val = 0; + std::string strVal; + std::vector<BkvNode> children; +}; + +constexpr int BKV_MAX_DEPTH = 128; +constexpr size_t BKV_MAX_NODES = 100000; + +bool BkvRead(const uint8_t* data, size_t len, size_t& pos, + std::vector<BkvNode>& out, int depth, size_t& totalNodes) { + if (depth > BKV_MAX_DEPTH) return false; + while (pos < len) { + uint8_t tag = data[pos++]; + if (tag == BKV_END) return true; + + BkvNode node; + node.type = static_cast<BkvType>(tag); + + const char* nameStart = reinterpret_cast<const char*>(data + pos); + size_t nameEnd = pos; + while (nameEnd < len && data[nameEnd] != 0) nameEnd++; + if (nameEnd >= len) return false; + node.name.assign(nameStart, nameEnd - pos); + pos = nameEnd + 1; + + switch (node.type) { + case BKV_SECTION: + if (!BkvRead(data, len, pos, node.children, depth + 1, totalNodes)) + return false; + break; + case BKV_STRING: { + const char* s = reinterpret_cast<const char*>(data + pos); + size_t end = pos; + while (end < len && data[end] != 0) end++; + if (end >= len) return false; + node.strVal.assign(s, end - pos); + pos = end + 1; + break; + } + case BKV_INT: + case BKV_FLOAT: + if (pos + 4 > len) return false; + if (node.type == BKV_INT) std::memcpy(&node.intVal, data + pos, 4); + else std::memcpy(&node.floatVal, data + pos, 4); + pos += 4; + break; + case BKV_UINT64: + if (pos + 8 > len) return false; + std::memcpy(&node.uint64Val, data + pos, 8); + pos += 8; + break; + case BKV_INT64: + if (pos + 8 > len) return false; + std::memcpy(&node.int64Val, data + pos, 8); + pos += 8; + break; + default: + return false; + } + if (++totalNodes > BKV_MAX_NODES) return false; + out.push_back(std::move(node)); + } + return depth == 0; +} + +const BkvNode* BkvFind(const std::vector<BkvNode>& nodes, const std::string& name) { + for (const auto& n : nodes) + if (n.name == name) return &n; + return nullptr; +} + +// Coerce a "data" node's numeric value to uint32 (stat values and achievement +// bitfields are stored as INT; we only need the 32-bit payload for the wire). +uint32_t BkvDataAsU32(const BkvNode& dataNode) { + switch (dataNode.type) { + case BKV_INT: return dataNode.intVal; + case BKV_UINT64: return (uint32_t)dataNode.uint64Val; + case BKV_INT64: return (uint32_t)dataNode.int64Val; + case BKV_FLOAT: { uint32_t v; std::memcpy(&v, &dataNode.floatVal, 4); return v; } + default: return 0; + } +} + +} // namespace + +// Active play sessions: appId -> session start (unix time) +static std::unordered_map<uint32_t, uint32_t> g_activeSessions; + +static uint32_t NowUnix() { + return (uint32_t)std::chrono::duration_cast<std::chrono::seconds>( + std::chrono::system_clock::now().time_since_epoch()).count(); +} + +static std::string StatsPath(uint32_t appId) { + return g_storageRoot + "/" + std::to_string(appId) + ".json"; +} + +static std::string SchemaPath(uint32_t appId) { + return g_storageRoot + "/schemas/" + std::to_string(appId) + ".bin"; +} + +// Simple CRC32 over stat id/value pairs +static uint32_t Crc32(const uint8_t* data, size_t len) { + uint32_t crc = 0xFFFFFFFF; + for (size_t i = 0; i < len; i++) { + crc ^= data[i]; + for (int j = 0; j < 8; j++) { + crc = (crc >> 1) ^ (0xEDB88320 & (-(int32_t)(crc & 1))); + } + } + return ~crc; +} + +// Reconcile playtime from Steam's localconfig.vdf. +// Steam writes Playtime/Playtime2wks/LastPlayed under +// UserLocalConfigStore > Software > Valve > Steam > Apps > {appid} +// If localconfig has more playtime than our stats JSON, update ours. +// This catches sessions where the user played without CloudRedirect loaded. +static void ReconcileLocalConfig(const std::string& cloudRoot, const std::string& steamPath) { + std::error_code ec; + fs::path userdataDir = fs::path(steamPath) / "userdata"; + if (!fs::exists(userdataDir, ec)) return; + + // Only reconcile accounts that have storage in our cloud_redirect directory + fs::path storageDir = fs::path(cloudRoot) / "storage"; + std::vector<std::string> ourAccounts; + if (fs::exists(storageDir, ec)) { + for (auto& entry : fs::directory_iterator(storageDir, ec)) { + if (entry.is_directory()) + ourAccounts.push_back(entry.path().filename().string()); + } + } + if (ourAccounts.empty()) return; + + int reconciled = 0; + for (auto& acctId : ourAccounts) { + fs::path acctDir = userdataDir / acctId; + if (!fs::is_directory(acctDir, ec)) continue; + + fs::path lcPath = acctDir / "config" / "localconfig.vdf"; + if (!fs::exists(lcPath, ec)) continue; + + std::ifstream f(lcPath); + if (!f.good()) continue; + std::string vdf((std::istreambuf_iterator<char>(f)), + std::istreambuf_iterator<char>()); + f.close(); + if (vdf.empty()) continue; + + // Find the "Apps" section: UserLocalConfigStore > Software > Valve > Steam > Apps + const char* basePath[] = {"UserLocalConfigStore", "Software", "Valve", "Steam", "Apps"}; + size_t appsStart = 0, appsEnd = 0; + if (!VdfUtil::FindVdfSectionRange(vdf, basePath, 5, appsStart, appsEnd)) + continue; + + // Enumerate child sections (each is an appid) + VdfUtil::ForEachChildInSection(vdf, basePath, 5, [&](std::string_view name) -> bool { + uint32_t appId = 0; + // Parse appid from section name + for (char c : name) { + if (c < '0' || c > '9') return true; // skip non-numeric + appId = appId * 10 + (c - '0'); + } + if (appId == 0) return true; + + // Only reconcile apps we already track (namespace apps with stats JSON) + if (!fs::exists(StatsPath(appId), ec)) return true; + + // Read Playtime/Playtime2wks/LastPlayed from this app's VDF section + std::string appIdStr = std::to_string(appId); + const char* appPath[] = {"UserLocalConfigStore", "Software", "Valve", "Steam", "Apps", appIdStr.c_str()}; + uint32_t vdfPlaytime = 0, vdfPlaytime2wks = 0, vdfLastPlayed = 0; + + VdfUtil::ForEachFieldInSection(vdf, appPath, 6, [&](const VdfUtil::FieldInfo& fi) -> bool { + if (fi.key == "Playtime") + try { vdfPlaytime = (uint32_t)std::stoul(std::string(fi.value)); } catch (...) {} + else if (fi.key == "Playtime2wks") + try { vdfPlaytime2wks = (uint32_t)std::stoul(std::string(fi.value)); } catch (...) {} + else if (fi.key == "LastPlayed") + try { vdfLastPlayed = (uint32_t)std::stoul(std::string(fi.value)); } catch (...) {} + return true; + }); + + if (vdfPlaytime == 0) return true; + + auto cacheIt = g_cache.find(appId); + if (cacheIt == g_cache.end()) { + LoadAppStats(appId, g_cache[appId]); + cacheIt = g_cache.find(appId); + } + AppStats& stats = cacheIt->second; + + if (stats.playtime.minutesForever >= vdfPlaytime) return true; + + // Local has more playtime -- update + uint32_t delta = vdfPlaytime - stats.playtime.minutesForever; + stats.playtime.minutesForever = vdfPlaytime; + stats.playtime.minutesLastTwoWeeks = (std::max)(stats.playtime.minutesLastTwoWeeks, vdfPlaytime2wks); + if (vdfLastPlayed > stats.playtime.lastPlayedTime) + stats.playtime.lastPlayedTime = vdfLastPlayed; +#ifdef _WIN32 + stats.playtime.playtimeWindows += delta; +#elif defined(__APPLE__) + stats.playtime.playtimeMac += delta; +#else + stats.playtime.playtimeLinux += delta; +#endif + SaveAppStats(appId, stats); + reconciled++; + LOG("[Stats] Reconciled app %u from localconfig: %u -> %u min (+%u)", + appId, vdfPlaytime - delta, vdfPlaytime, delta); + return true; + }); + } + if (reconciled > 0) { + LOG("[Stats] Reconciled %d apps from localconfig.vdf", reconciled); + } +} + +void Init(const std::string& storageRoot, const std::string& steamPath) { + std::lock_guard<std::mutex> lock(g_mutex); + g_storageRoot = storageRoot + "/stats"; + g_steamPath = steamPath; + fs::create_directories(g_storageRoot); + fs::create_directories(g_storageRoot + "/schemas"); + + ReconcileLocalConfig(storageRoot, steamPath); + + LOG("[Stats] Store initialized at %s", g_storageRoot.c_str()); +} + +// Import Steam's native UserGameStats + schema blobs for an app into `out`. +// Returns true if real stat data was imported. Used to seed our authoritative +// store on first access (so we can answer GetUserStats with real data). +// Caller must hold g_mutex (reads g_steamPath / g_accountIdProvider). +static bool ImportNativeStats(uint32_t appId, AppStats& out) { + if (g_steamPath.empty() || !g_accountIdProvider) return false; + uint32_t accountId = g_accountIdProvider(); + if (accountId == 0) return false; + + // Schema: appcache\stats\UserGameStatsSchema_<appId>.bin + { + std::string schemaPath = g_steamPath + "appcache\\stats\\UserGameStatsSchema_" + + std::to_string(appId) + ".bin"; + std::ifstream sf(schemaPath, std::ios::binary); + if (sf.good()) { + out.schema.assign(std::istreambuf_iterator<char>(sf), + std::istreambuf_iterator<char>()); + } + } + + // Stats: appcache\stats\UserGameStats_<accountId>_<appId>.bin + std::string statsPath = g_steamPath + "appcache\\stats\\UserGameStats_" + + std::to_string(accountId) + "_" + std::to_string(appId) + ".bin"; + std::ifstream f(statsPath, std::ios::binary); + if (!f.good()) return false; + std::vector<uint8_t> blob((std::istreambuf_iterator<char>(f)), + std::istreambuf_iterator<char>()); + f.close(); + if (blob.empty()) return false; + + size_t pos = 0, nodeCount = 0; + std::vector<BkvNode> root; + if (!BkvRead(blob.data(), blob.size(), pos, root, 0, nodeCount)) { + LOG("[Stats] ImportNativeStats app=%u: BKV parse failed (%zu bytes)", appId, blob.size()); + return false; + } + + const BkvNode* cache = BkvFind(root, "cache"); + if (!cache) return false; + + size_t importedStats = 0, importedAch = 0; + for (const auto& stat : cache->children) { + if (stat.type != BKV_SECTION) continue; // skip crc / PendingChanges + // Section name is the decimal stat id. + uint32_t statId = 0; + bool numeric = !stat.name.empty(); + for (char c : stat.name) { if (c < '0' || c > '9') { numeric = false; break; } } + if (!numeric) continue; + statId = (uint32_t)strtoul(stat.name.c_str(), nullptr, 10); + + const BkvNode* dataNode = BkvFind(stat.children, "data"); + if (!dataNode) continue; + uint32_t value = BkvDataAsU32(*dataNode); + + out.stats.push_back(StatEntry{statId, value}); + ++importedStats; + + // Achievement unlock times -> AchievementBlock. The 'data' INT is the + // unlocked-bit bitfield; AchievementTimes holds per-bit timestamps. + const BkvNode* achTimes = BkvFind(stat.children, "AchievementTimes"); + if (achTimes) { + AchievementBlock blk{}; + blk.statId = statId; + blk.bits = value; + for (const auto& bitNode : achTimes->children) { + if (bitNode.type != BKV_INT) continue; + uint32_t bit = (uint32_t)strtoul(bitNode.name.c_str(), nullptr, 10); + if (bit < 32) blk.unlockTimes[bit] = bitNode.intVal; + } + out.achievements.push_back(blk); + ++importedAch; + } + } + + LOG("[Stats] ImportNativeStats app=%u: imported %zu stat(s), %zu achievement block(s), schema=%zu bytes", + appId, importedStats, importedAch, out.schema.size()); + return importedStats > 0; +} + +// Parse the JSON document (stats/achievements/playtime) into `out`. +// Does NOT touch the separate on-disk schema blob. +static bool ParseAppStatsJson(const std::string& content, AppStats& out) { + auto root = Json::Parse(content); + if (root.isNull()) return false; + + out.crcStats = (uint32_t)root["crc_stats"].integer(); + out.stats.clear(); + out.achievements.clear(); + out.playtime = {}; + + const auto& statsArr = root["stats"]; + if (statsArr.type == Json::Type::Array) { + for (size_t i = 0; i < statsArr.size(); i++) { + const auto& item = statsArr[i]; + StatEntry e; + e.statId = (uint32_t)item["id"].integer(); + e.value = (uint32_t)item["value"].integer(); + out.stats.push_back(e); + } + } + + const auto& achArr = root["achievements"]; + if (achArr.type == Json::Type::Array) { + for (size_t i = 0; i < achArr.size(); i++) { + const auto& item = achArr[i]; + AchievementBlock blk = {}; + blk.statId = (uint32_t)item["stat_id"].integer(); + blk.bits = (uint32_t)item["bits"].integer(); + const auto& times = item["unlock_times"]; + if (times.type == Json::Type::Array) { + for (size_t j = 0; j < times.size() && j < 32; j++) { + blk.unlockTimes[j] = (uint32_t)times[j].integer(); + } + } + out.achievements.push_back(blk); + } + } + + const auto& pt = root["playtime"]; + if (pt.type == Json::Type::Object) { + out.playtime.minutesForever = (uint32_t)pt["minutes_forever"].integer(); + out.playtime.minutesLastTwoWeeks = (uint32_t)pt["minutes_2weeks"].integer(); + out.playtime.lastPlayedTime = (uint32_t)pt["last_played"].integer(); + out.playtime.playtimeWindows = (uint32_t)pt["windows"].integer(); + out.playtime.playtimeMac = (uint32_t)pt["mac"].integer(); + out.playtime.playtimeLinux = (uint32_t)pt["linux"].integer(); + } + + return true; +} + +// Serialize the stats document (everything except the raw schema blob) to JSON. +static std::string BuildAppStatsJson(const AppStats& stats) { + Json::Value root = Json::Object(); + root.objVal["crc_stats"] = Json::Number(stats.crcStats); + + Json::Value statsArr = Json::Array(); + for (auto& s : stats.stats) { + Json::Value item = Json::Object(); + item.objVal["id"] = Json::Number(s.statId); + item.objVal["value"] = Json::Number(s.value); + statsArr.arrVal.push_back(std::move(item)); + } + root.objVal["stats"] = std::move(statsArr); + + Json::Value achArr = Json::Array(); + for (auto& a : stats.achievements) { + Json::Value item = Json::Object(); + item.objVal["stat_id"] = Json::Number(a.statId); + item.objVal["bits"] = Json::Number(a.bits); + Json::Value times = Json::Array(); + for (int i = 0; i < 32; i++) { + times.arrVal.push_back(Json::Number(a.unlockTimes[i])); + } + item.objVal["unlock_times"] = std::move(times); + achArr.arrVal.push_back(std::move(item)); + } + root.objVal["achievements"] = std::move(achArr); + + Json::Value pt = Json::Object(); + pt.objVal["minutes_forever"] = Json::Number(stats.playtime.minutesForever); + pt.objVal["minutes_2weeks"] = Json::Number(stats.playtime.minutesLastTwoWeeks); + pt.objVal["last_played"] = Json::Number(stats.playtime.lastPlayedTime); + pt.objVal["windows"] = Json::Number(stats.playtime.playtimeWindows); + pt.objVal["mac"] = Json::Number(stats.playtime.playtimeMac); + pt.objVal["linux"] = Json::Number(stats.playtime.playtimeLinux); + root.objVal["playtime"] = std::move(pt); + + return Json::Stringify(root); +} + +bool LoadAppStats(uint32_t appId, AppStats& out) { + std::string path = StatsPath(appId); + std::string content; + + std::ifstream f(path); + if (f.good()) { + content.assign((std::istreambuf_iterator<char>(f)), + std::istreambuf_iterator<char>()); + f.close(); + } else if (g_cloudPull && g_cloudPull(appId, content) && !content.empty()) { + // No local copy; pull the cloud blob and materialize it locally so + // subsequent reads hit disk and FlushAll round-trips correctly. + std::ofstream wf(path, std::ios::trunc); + wf << content; + wf.close(); + LOG("[Stats] Pulled app %u stats from cloud (%zu bytes)", appId, content.size()); + } + + if (content.empty()) return false; + if (!ParseAppStatsJson(content, out)) return false; + + // Load schema blob if exists (separate binary sidecar). + std::string schemaPath = SchemaPath(appId); + std::ifstream sf(schemaPath, std::ios::binary); + if (sf.good()) { + out.schema.assign(std::istreambuf_iterator<char>(sf), + std::istreambuf_iterator<char>()); + } + return true; +} + +void SaveAppStats(uint32_t appId, const AppStats& stats) { + std::string path = StatsPath(appId); + std::string json = BuildAppStatsJson(stats); + + std::ofstream f(path, std::ios::trunc); + f << json; + f.close(); + + // Save schema blob separately if present + if (!stats.schema.empty()) { + std::string schemaPath = SchemaPath(appId); + std::ofstream sf(schemaPath, std::ios::binary | std::ios::trunc); + sf.write(reinterpret_cast<const char*>(stats.schema.data()), stats.schema.size()); + } + + // Cloud-back the stats document (fire-and-forget; provider queues upload). + if (g_cloudPush) g_cloudPush(appId, json); +} + +// Apps for which native import has been successfully attempted (imported real +// data OR confirmed Steam genuinely has none). Distinct from a cache entry, +// because reconcile/session-tracking can create an empty cache entry before any +// import runs -- we must still import on first stats access in that case. +static std::unordered_map<uint32_t, bool> g_importAttempted; + +// Seed `stats` from Steam's native UserGameStats blob if we hold no stat data +// yet. Retries across calls while accountId is unavailable (returns 0); only +// marks "attempted" once we had a real accountId to look with. Caller holds mutex. +static void EnsureNativeImportLocked(uint32_t appId, AppStats& stats) { + if (!stats.stats.empty()) return; // already have data + if (g_importAttempted.count(appId)) return; // already tried with a valid acct + if (!g_accountIdProvider || g_accountIdProvider() == 0) { + // accountId not ready yet (not logged in) -- don't mark attempted; retry later. + return; + } + + AppStats native; + native.playtime = stats.playtime; // preserve any playtime already loaded + bool imported = ImportNativeStats(appId, native); + g_importAttempted[appId] = true; // accountId was valid; this is a definitive attempt + if (imported) { + stats.stats = std::move(native.stats); + stats.achievements = std::move(native.achievements); + if (!native.schema.empty()) stats.schema = std::move(native.schema); + stats.crcStats = ComputeCrcLocked(stats); + g_dirty[appId] = true; + SaveAppStats(appId, stats); + } +} + +AppStats& GetOrCreate(uint32_t appId) { + std::lock_guard<std::mutex> lock(g_mutex); + auto it = g_cache.find(appId); + if (it != g_cache.end()) { + // Cache hit, but a reconcile/session path may have created an empty + // entry before import ran. Ensure native stats are imported on first + // actual stats access (and on later retries once accountId is ready). + EnsureNativeImportLocked(appId, it->second); + return it->second; + } + + AppStats& stats = g_cache[appId]; + bool loaded = LoadAppStats(appId, stats); + if (!loaded) { + stats.crcStats = 0; + stats.playtime = {}; + } + EnsureNativeImportLocked(appId, stats); + return stats; +} + +// Deterministic, order-independent CRC over stat values AND achievement +// unlock state. This is our opaque sync token (per IDA: the client just stores +// and echoes whatever crc we send; it never recomputes). It MUST be stable +// (same data -> same crc) and change when stats/achievements change. +static void AppendU32(std::vector<uint8_t>& buf, uint32_t v) { + buf.push_back(v & 0xFF); + buf.push_back((v >> 8) & 0xFF); + buf.push_back((v >> 16) & 0xFF); + buf.push_back((v >> 24) & 0xFF); +} + +uint32_t ComputeCrcLocked(const AppStats& stats) { + // Sort stat ids so insertion order can't change the token. + std::vector<const StatEntry*> sortedStats; + sortedStats.reserve(stats.stats.size()); + for (auto& s : stats.stats) sortedStats.push_back(&s); + std::sort(sortedStats.begin(), sortedStats.end(), + [](const StatEntry* a, const StatEntry* b) { return a->statId < b->statId; }); + + std::vector<uint8_t> buf; + for (auto* s : sortedStats) { + AppendU32(buf, s->statId); + AppendU32(buf, s->value); + } + + // Fold achievement unlock times (sorted by statId, then bit). + std::vector<const AchievementBlock*> sortedAch; + sortedAch.reserve(stats.achievements.size()); + for (auto& a : stats.achievements) sortedAch.push_back(&a); + std::sort(sortedAch.begin(), sortedAch.end(), + [](const AchievementBlock* a, const AchievementBlock* b) { return a->statId < b->statId; }); + for (auto* a : sortedAch) { + AppendU32(buf, a->statId); + AppendU32(buf, a->bits); + for (int bit = 0; bit < 32; ++bit) + if (a->unlockTimes[bit]) { AppendU32(buf, (uint32_t)bit); AppendU32(buf, a->unlockTimes[bit]); } + } + + return buf.empty() ? 0 : Crc32(buf.data(), buf.size()); +} + +uint32_t ComputeCrc(uint32_t appId) { + return ComputeCrcLocked(g_cache[appId]); // caller holds g_mutex +} + +uint32_t SetStat(uint32_t appId, uint32_t statId, uint32_t value) { + std::lock_guard<std::mutex> lock(g_mutex); + auto& stats = g_cache[appId]; + + bool found = false; + for (auto& s : stats.stats) { + if (s.statId == statId) { + s.value = value; + found = true; + break; + } + } + if (!found) { + stats.stats.push_back({statId, value}); + } + + g_dirty[appId] = true; + stats.crcStats = ComputeCrc(appId); + return stats.crcStats; +} + +uint32_t SetStats(uint32_t appId, const std::vector<StatEntry>& entries) { + std::lock_guard<std::mutex> lock(g_mutex); + auto& stats = g_cache[appId]; + + for (auto& e : entries) { + bool found = false; + for (auto& s : stats.stats) { + if (s.statId == e.statId) { + s.value = e.value; + found = true; + break; + } + } + if (!found) { + stats.stats.push_back(e); + } + } + + g_dirty[appId] = true; + stats.crcStats = ComputeCrc(appId); + return stats.crcStats; +} + +uint32_t SetAchievement(uint32_t appId, uint32_t statId, uint32_t bit, uint32_t unlockTime) { + std::lock_guard<std::mutex> lock(g_mutex); + auto& stats = g_cache[appId]; + + AchievementBlock* blk = nullptr; + for (auto& a : stats.achievements) { + if (a.statId == statId) { blk = &a; break; } + } + if (!blk) { + stats.achievements.push_back({}); + blk = &stats.achievements.back(); + blk->statId = statId; + blk->bits = 0; + memset(blk->unlockTimes, 0, sizeof(blk->unlockTimes)); + } + + if (bit < 32) { + blk->bits |= (1u << bit); + blk->unlockTimes[bit] = unlockTime; + } + + g_dirty[appId] = true; + stats.crcStats = ComputeCrc(appId); + return stats.crcStats; +} + +void SetSchema(uint32_t appId, const uint8_t* data, size_t len) { + std::lock_guard<std::mutex> lock(g_mutex); + auto& stats = g_cache[appId]; + stats.schema.assign(data, data + len); + g_dirty[appId] = true; +} + +const std::vector<uint8_t>& GetSchema(uint32_t appId) { + std::lock_guard<std::mutex> lock(g_mutex); + return g_cache[appId].schema; +} + +void StartSession(uint32_t appId) { + std::lock_guard<std::mutex> lock(g_mutex); + g_activeSessions[appId] = NowUnix(); + auto& stats = g_cache[appId]; + if (stats.playtime.lastPlayedTime == 0) { + LoadAppStats(appId, stats); + } + stats.playtime.lastPlayedTime = NowUnix(); + g_dirty[appId] = true; + LOG("[Stats] Session started for app %u", appId); +} + +void EndSession(uint32_t appId) { + std::lock_guard<std::mutex> lock(g_mutex); + auto it = g_activeSessions.find(appId); + if (it == g_activeSessions.end()) return; + + uint32_t now = NowUnix(); + uint32_t elapsed = (now > it->second) ? (now - it->second) : 0; + uint32_t minutes = elapsed / 60; + g_activeSessions.erase(it); + + auto& stats = g_cache[appId]; + stats.playtime.minutesForever += minutes; + stats.playtime.minutesLastTwoWeeks += minutes; + stats.playtime.lastPlayedTime = now; + +#ifdef _WIN32 + stats.playtime.playtimeWindows += minutes; +#elif defined(__APPLE__) + stats.playtime.playtimeMac += minutes; +#else + stats.playtime.playtimeLinux += minutes; +#endif + + g_dirty[appId] = true; + SaveAppStats(appId, stats); + g_dirty[appId] = false; + LOG("[Stats] Session ended for app %u: +%u min (total %u)", + appId, minutes, stats.playtime.minutesForever); +} + +PlaytimeData GetPlaytime(uint32_t appId) { + std::lock_guard<std::mutex> lock(g_mutex); + auto& stats = g_cache[appId]; + PlaytimeData pt = stats.playtime; + + auto it = g_activeSessions.find(appId); + if (it != g_activeSessions.end()) { + uint32_t now = NowUnix(); + uint32_t elapsed = (now > it->second) ? (now - it->second) : 0; + uint32_t minutes = elapsed / 60; + pt.minutesForever += minutes; + pt.minutesLastTwoWeeks += minutes; + } + return pt; +} + +std::vector<uint32_t> GetTrackedApps() { + std::lock_guard<std::mutex> lock(g_mutex); + std::vector<uint32_t> out; + out.reserve(g_cache.size()); + for (const auto& [appId, stats] : g_cache) { + if (stats.playtime.minutesForever > 0 || stats.playtime.lastPlayedTime > 0) + out.push_back(appId); + } + return out; +} + +void FlushAll() { + std::lock_guard<std::mutex> lock(g_mutex); + for (auto& [appId, dirty] : g_dirty) { + if (dirty) { + auto it = g_cache.find(appId); + if (it != g_cache.end()) { + SaveAppStats(appId, it->second); + LOG("[Stats] Flushed app %u to disk", appId); + } + dirty = false; + } + } +} + +} // namespace StatsStore diff --git a/src/common/stats_store.h b/src/common/stats_store.h new file mode 100644 index 00000000..3f19f298 --- /dev/null +++ b/src/common/stats_store.h @@ -0,0 +1,100 @@ +#pragma once +#include <cstdint> +#include <string> +#include <vector> +#include <unordered_map> +#include <mutex> +#include <functional> + +namespace StatsStore { + +// Cloud-backing provider callbacks. The store stays decoupled from the +// platform CloudStorage / account-id plumbing: the platform layer installs +// these so per-app stats blobs are pulled on first access and pushed on save. +// pull: return true and fill outJson if a cloud blob exists for appId. +// push: persist the JSON blob for appId to the cloud (fire-and-forget). +using CloudPullFn = std::function<bool(uint32_t appId, std::string& outJson)>; +using CloudPushFn = std::function<void(uint32_t appId, const std::string& json)>; +void SetCloudProvider(CloudPullFn pull, CloudPushFn push); + +struct StatEntry { + uint32_t statId; + uint32_t value; +}; + +struct AchievementUnlock { + uint32_t bit; // 0-31 within the achievement stat + uint32_t unlockTime; // unix timestamp, 0 = locked +}; + +struct AchievementBlock { + uint32_t statId; // the achievement stat ID (type 4) + uint32_t bits; // bitmask of unlocked achievements + uint32_t unlockTimes[32]; // per-bit unlock timestamps +}; + +struct PlaytimeData { + uint32_t minutesForever; + uint32_t minutesLastTwoWeeks; + uint32_t lastPlayedTime; // unix timestamp + uint32_t playtimeWindows; + uint32_t playtimeMac; + uint32_t playtimeLinux; +}; + +struct AppStats { + uint32_t crcStats; // CRC of the stats data, must match client + std::vector<StatEntry> stats; + std::vector<AchievementBlock> achievements; + PlaytimeData playtime; + std::vector<uint8_t> schema; // raw KV binary blob +}; + +// Initialize the store with a root directory for JSON files. +// steamPath: e.g. "C:\Games\Steam\" -- used to read localconfig.vdf for playtime reconciliation +// and to import Steam's native UserGameStats / schema blobs from appcache\stats. +void Init(const std::string& storageRoot, const std::string& steamPath); + +// Install a provider that resolves the current Steam accountId (32-bit). Used +// to locate native appcache\stats\UserGameStats_<accountId>_<appId>.bin blobs. +// Returns 0 if not yet known (e.g. not logged in). Resolved lazily on access. +using AccountIdProvider = std::function<uint32_t()>; +void SetAccountIdProvider(AccountIdProvider provider); + +// Load stats for an app. Returns true if data exists on disk. +bool LoadAppStats(uint32_t appId, AppStats& out); + +// Save stats for an app to disk. +void SaveAppStats(uint32_t appId, const AppStats& stats); + +// Get or create stats entry for an app (thread-safe, cached in memory). +AppStats& GetOrCreate(uint32_t appId); + +// Update a single stat value. Returns the new CRC. +uint32_t SetStat(uint32_t appId, uint32_t statId, uint32_t value); + +// Update multiple stat values at once. Returns the new CRC. +uint32_t SetStats(uint32_t appId, const std::vector<StatEntry>& entries); + +// Set an achievement bit and record unlock time. Returns the new CRC. +uint32_t SetAchievement(uint32_t appId, uint32_t statId, uint32_t bit, uint32_t unlockTime); + +// Store/retrieve the schema blob for an app. +void SetSchema(uint32_t appId, const uint8_t* data, size_t len); +const std::vector<uint8_t>& GetSchema(uint32_t appId); + +// Compute CRC32 over current stat values for an app. +uint32_t ComputeCrc(uint32_t appId); + +// Playtime tracking +void StartSession(uint32_t appId); +void EndSession(uint32_t appId); +PlaytimeData GetPlaytime(uint32_t appId); + +// Enumerate appIds that have any tracked playtime (for GetLastPlayedTimes). +std::vector<uint32_t> GetTrackedApps(); + +// Flush all dirty apps to disk. +void FlushAll(); + +} // namespace StatsStore diff --git a/src/platform/linux/cloud_intercept.cpp b/src/platform/linux/cloud_intercept.cpp index 714fe9b2..62cb49ca 100644 --- a/src/platform/linux/cloud_intercept.cpp +++ b/src/platform/linux/cloud_intercept.cpp @@ -290,23 +290,10 @@ void SetSteamPath(const std::string& path) { g_steamPath += '/'; } -void RecordLaunchTime(uint32_t /*appId*/) { - // TODO: implement playtime tracking on Linux -} - void Shutdown() { int fd = g_watcherFd.exchange(-1, std::memory_order_acq_rel); if (fd != -1) close(fd); LOG("[Linux] CloudIntercept shutdown"); } -// Playtime restoration stubs (called from rpc_handlers.cpp) -bool RestorePlaytimeState(uint32_t /*appId*/, uint64_t /*playtime*/, uint64_t /*playtime2wks*/) { - return false; -} - -bool RestoreLastPlayedState(uint32_t /*appId*/, uint64_t /*lastPlayed*/) { - return false; -} - } // namespace CloudIntercept diff --git a/src/platform/linux/cloud_intercept.h b/src/platform/linux/cloud_intercept.h index b1af6aa6..f994ee55 100644 --- a/src/platform/linux/cloud_intercept.h +++ b/src/platform/linux/cloud_intercept.h @@ -28,8 +28,6 @@ void SetAccountId(uint32_t id); // Set the Steam path (called by Linux hook layer during init) void SetSteamPath(const std::string& path); -void RecordLaunchTime(uint32_t appId); - // Signal shutdown void Shutdown(); diff --git a/src/platform/win/cloud_intercept.cpp b/src/platform/win/cloud_intercept.cpp index 2842885b..b60a7698 100644 --- a/src/platform/win/cloud_intercept.cpp +++ b/src/platform/win/cloud_intercept.cpp @@ -1,6 +1,8 @@ #include "cloud_intercept.h" #include "metadata_sync.h" #include "rpc_handlers.h" +#include "stats_handlers.h" +#include "stats_store.h" #include "app_state.h" #include "protobuf.h" #include "parental_bypass.h" @@ -120,10 +122,6 @@ static constexpr uintptr_t SC_RVA_SERVICE_TRANSPORT_VT = 0x1247A70; static constexpr uintptr_t SC_RVA_PARSE_FROM_ARRAY = 0xBC42F0; // sub_138BE7A40 = protobuf SerializeToArray (writes body to raw bytes) static constexpr uintptr_t SC_RVA_SERIALIZE_TO_ARRAY = 0xBC4700; -// CUser playtime state helpers -static constexpr uintptr_t SC_RVA_GET_APP_MINUTES_PLAYED_DATA = 0x9BB320; -static constexpr uintptr_t SC_RVA_FLUSH_APP_MINUTES_PLAYED = 0x9CB7D0; -static constexpr uintptr_t SC_RVA_SET_APP_LAST_PLAYED_TIME = 0x9CE600; // CSteamEngine layout offsets static constexpr uint32_t ENGINE_OFF_JOBMGR = 592; // CJobMgr embedded at CSteamEngine+592 static constexpr uint32_t ENGINE_OFF_GLOBAL_HANDLE = 3144; // uint32_t: global user handle @@ -189,9 +187,6 @@ using ParseFromArrayFn = char(__fastcall*)(void* msgBody, const char* data, int // r8 = buffer size (int) // returns end pointer on old Steam, flag on Steam >= 1778281814 using SerializeToArrayFn = uintptr_t(__fastcall*)(void* msgBody, void* outBuf, int size); -using GetAppMinutesPlayedDataFn = unsigned int*(__fastcall*)(int64_t userPtr, unsigned int appId, char create); -using FlushAppMinutesPlayedFn = int64_t(__fastcall*)(int64_t userPtr, unsigned int appId, unsigned int* record); -using SetAppLastPlayedTimeFn = void(__fastcall*)(int64_t userPtr, unsigned int appId, unsigned int lastPlayed); // Job routing info struct passed as 4th arg to BRouteMsgToJob // Layout from RecvPkt assembly (naturally aligned, 24 bytes, no padding needed): @@ -306,8 +301,6 @@ static void ScheduleStartupMetadataSync() { LOG("[StartupSync] Cloud active; metadata will be fetched on-demand per app"); } -#define g_syncAchievements MetadataSync::syncAchievements -#define g_syncPlaytime MetadataSync::syncPlaytime #define g_syncLuas MetadataSync::syncLuas static std::atomic<bool> g_parentalBypassPlaytime{false}; @@ -479,41 +472,8 @@ void SetNamespaceApps(const uint32_t* appIds, uint32_t count, g_namespaceApps = std::move(next); } -// per-app launch timestamp for internal playtime tracking -static std::mutex g_launchTimeMutex; -static std::unordered_map<uint32_t, time_t> g_launchTimes; -static std::unordered_map<uint32_t, uint64_t> g_launchVdfPlaytime; -static std::unordered_map<uint32_t, uint64_t> g_launchVdfPlaytime2wks; - -static uint32_t ClampToUint32(uint64_t value) { - return value > (std::numeric_limits<uint32_t>::max)() - ? (std::numeric_limits<uint32_t>::max)() - : static_cast<uint32_t>(value); -} - static uintptr_t FindCurrentUser(); -static uintptr_t ResolveCurrentUserForRestore(const char* featureTag, uint32_t appId) { - if (!g_steamClientBase) { - HMODULE hSC = GetModuleHandleA("steamclient64.dll"); - if (!hSC) { - LOG("[%s] In-memory restore skipped for app %u: steamclient64.dll not loaded", featureTag, appId); - return 0; - } - g_steamClientBase = (uintptr_t)hSC; - } - - uintptr_t userPtr = 0; - for (int attempt = 0; attempt < 20 && !userPtr && !g_shuttingDown.load(); ++attempt) { - userPtr = FindCurrentUser(); - if (!userPtr) Sleep(100); - } - if (!userPtr) { - LOG("[%s] In-memory restore skipped for app %u: current CUser not available", featureTag, appId); - } - return userPtr; -} - static uintptr_t FindCurrentUser() { if (!g_steamClientBase) { HMODULE hSC = GetModuleHandleA("steamclient64.dll"); @@ -556,143 +516,6 @@ static uintptr_t FindCurrentUser() { return userPtr; } -void RecordLaunchTime(uint32_t appId) { - std::lock_guard<std::mutex> lock(g_launchTimeMutex); - g_launchTimes[appId] = time(nullptr); - - // Snapshot VDF playtime at launch while the file is stable - uint64_t vdfPT = 0; - uint64_t vdfPT2wks = 0; - uint32_t accountId = GetAccountId(); - if (accountId) { - std::string vdfPath = g_steamPath + "userdata\\" + std::to_string(accountId) - + "\\config\\localconfig.vdf"; - // Wide-API: CreateFileA's ACP narrowing breaks non-ASCII profile paths (Cyrillic/CJK), which would silently skip the playtime baseline. - auto vdfPathWide = FileUtil::Utf8ToPath(vdfPath).wstring(); - HANDLE hFile = CreateFileW(vdfPathWide.c_str(), GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, nullptr); - if (hFile != INVALID_HANDLE_VALUE) { - DWORD fileSize = GetFileSize(hFile, nullptr); - std::string vdfContent; - if (fileSize != INVALID_FILE_SIZE && fileSize > 0) { - vdfContent.resize(fileSize); - DWORD bytesRead = 0; - ReadFile(hFile, (LPVOID)vdfContent.data(), fileSize, &bytesRead, nullptr); - vdfContent.resize(bytesRead); - } - CloseHandle(hFile); - - std::string appIdStr = std::to_string(appId); - const char* sections[] = { "UserLocalConfigStore", "Software", "Valve", "Steam", "Apps", appIdStr.c_str() }; - VdfUtil::ForEachFieldInSection(vdfContent, sections, 6, - [&](const VdfUtil::FieldInfo& fi) { - if (fi.key == "Playtime") - vdfPT = strtoull(std::string(fi.value).c_str(), nullptr, 10); - else if (fi.key == "Playtime2wks") - vdfPT2wks = strtoull(std::string(fi.value).c_str(), nullptr, 10); - return true; - }); - } - } - g_launchVdfPlaytime[appId] = vdfPT; - g_launchVdfPlaytime2wks[appId] = vdfPT2wks; - LOG("[Playtime] Recorded launch time for app %u (vdfBaseline=%llu min, vdf2wks=%llu min)", - appId, vdfPT, vdfPT2wks); -} - -struct LaunchInfo { time_t launchTime; uint64_t vdfBaseline; uint64_t vdfBaseline2wks; }; -static LaunchInfo PopLaunchInfo(uint32_t appId) { - std::lock_guard<std::mutex> lock(g_launchTimeMutex); - LaunchInfo info = {0, 0, 0}; - auto it = g_launchTimes.find(appId); - if (it != g_launchTimes.end()) { - info.launchTime = it->second; - g_launchTimes.erase(it); - } - auto it2 = g_launchVdfPlaytime.find(appId); - if (it2 != g_launchVdfPlaytime.end()) { - info.vdfBaseline = it2->second; - g_launchVdfPlaytime.erase(it2); - } - auto it3 = g_launchVdfPlaytime2wks.find(appId); - if (it3 != g_launchVdfPlaytime2wks.end()) { - info.vdfBaseline2wks = it3->second; - g_launchVdfPlaytime2wks.erase(it3); - } - return info; -} - -bool RestorePlaytimeState(uint32_t appId, uint64_t playtime, uint64_t playtime2wks) { - if (!playtime && !playtime2wks) return false; - - uintptr_t userPtr = ResolveCurrentUserForRestore("Playtime", appId); - if (!userPtr) return false; - - auto getData = (GetAppMinutesPlayedDataFn)(g_steamClientBase + SC_RVA_GET_APP_MINUTES_PLAYED_DATA); - auto flushData = (FlushAppMinutesPlayedFn)(g_steamClientBase + SC_RVA_FLUSH_APP_MINUTES_PLAYED); - - unsigned int* record = nullptr; - __try { - record = getData((int64_t)userPtr, appId, 1); - } __except(EXCEPTION_EXECUTE_HANDLER) { - LOG("[Playtime] In-memory restore exception creating record for app %u: code=0x%08lX", - appId, GetExceptionCode()); - return false; - } - if (!record) { - LOG("[Playtime] In-memory restore failed for app %u: no playtime record", appId); - return false; - } - - uint32_t total32 = ClampToUint32(playtime); - uint32_t twoWks32 = ClampToUint32(playtime2wks ? playtime2wks : playtime); - uint32_t oldTotal = 0; - uint32_t oldTwoWks = 0; - - __try { - oldTotal = record[1]; - oldTwoWks = record[2]; - if (oldTotal > total32) total32 = oldTotal; - if (oldTwoWks > twoWks32) twoWks32 = oldTwoWks; - record[1] = total32; - record[2] = twoWks32; - record[3] = 0; - record[4] = 0; - record[5] = 0; - record[6] = 0; - flushData((int64_t)userPtr, appId, record); - } __except(EXCEPTION_EXECUTE_HANDLER) { - LOG("[Playtime] In-memory restore exception applying record for app %u: code=0x%08lX", - appId, GetExceptionCode()); - return false; - } - - LOG("[Playtime] Seeded in-memory playtime for app %u: total %u->%u, 2wks %u->%u", - appId, oldTotal, total32, oldTwoWks, twoWks32); - return true; -} - -bool RestoreLastPlayedState(uint32_t appId, uint64_t lastPlayed) { - if (!lastPlayed) return false; - - uintptr_t userPtr = ResolveCurrentUserForRestore("Playtime", appId); - if (!userPtr) return false; - - auto setLastPlayed = (SetAppLastPlayedTimeFn)(g_steamClientBase + SC_RVA_SET_APP_LAST_PLAYED_TIME); - - uint32_t lastPlayed32 = ClampToUint32(lastPlayed); - __try { - setLastPlayed((int64_t)userPtr, appId, lastPlayed32); - } __except(EXCEPTION_EXECUTE_HANDLER) { - LOG("[Playtime] In-memory LastPlayed restore exception for app %u: code=0x%08lX", - appId, GetExceptionCode()); - return false; - } - - LOG("[Playtime] Seeded in-memory LastPlayed for app %u: %u", appId, lastPlayed32); - return true; -} // cave replacement buffer globals (still needed for passthrough SteamTools hook) @@ -1373,6 +1196,33 @@ static bool __fastcall ServiceMethodDirectHook(void* thisptr, const char* method return result; } + // Native Player.GetUserStats#1 (per IDA, this lands on slot 4 / vtable+32). + // Bodies here are RAW protobuf objects (no CProtoBufMsg +48 wrapper). + if (strcmp(methodName, StatsHandlers::RPC_GET_USER_STATS) == 0) { + if (requestBody && responseBody && g_serializeToArray) { + auto reqBytes = SerializeBodyToBytes(requestBody); + auto reqFields = PB::Parse(reqBytes.data(), reqBytes.size()); + uint32_t appId = 0; + if (auto* f = PB::FindField(reqFields, 2)) appId = (uint32_t)f->varintVal; // appid #2 + LOG("[Stats] slot4 GetUserStats seen: app=%u namespace=%d", appId, IsNamespaceApp(appId) ? 1 : 0); + if (appId != 0 && IsNamespaceApp(appId)) { + auto res = StatsHandlers::HandleGetUserStats(appId, reqFields); + if (res.body.Size() > 0 && + ParseBytesToBody(responseBody, res.body.Data().data(), res.body.Size())) { + if (flags) { flags[2] = 1; flags[3] = res.eresult; } + LOG("[Stats] GetUserStats app=%u handled locally via slot4 (%zu bytes)", + appId, res.body.Size()); + return true; + } + LOG("[Stats] GetUserStats app=%u slot4 NOT handled (bodySize=%zu) -> passthrough", + appId, res.body.Size()); + } + } else { + LOG("[Stats] slot4 GetUserStats: missing req/resp/serializer -> passthrough"); + } + return g_originalSlot4(thisptr, methodName, requestBody, responseBody, flags); + } + if (strncmp(methodName, "Cloud.", 6) != 0) { return g_originalSlot4(thisptr, methodName, requestBody, responseBody, flags); } @@ -1493,6 +1343,93 @@ static bool __fastcall ServiceMethodHook(void* thisptr, const char* methodName, return result; } + // ---- Native stats / playtime service RPCs -------------------------------- + // Player.GetUserStats#1 (per-app): for namespace apps, answer from our store. + // Player.ClientGetLastPlayedTimes#1 (account-wide): let the real server reply, + // then APPEND our namespace apps' playtime so Steam shows it. Real owned games + // keep their server playtime (the client merges per-appid). See IDA notes. + if (strcmp(methodName, StatsHandlers::RPC_GET_USER_STATS) == 0) { + if (request && response) { + void* reqBody = *(void**)((uintptr_t)request + 48); + if (reqBody) { + auto reqBytes = SerializeBodyToBytes(reqBody); + auto reqFields = PB::Parse(reqBytes.data(), reqBytes.size()); + // appid is field 2 in CPlayer_GetUserStats_Request (IDA-verified) + uint32_t appId = 0; + if (auto* f = PB::FindField(reqFields, 2)) appId = (uint32_t)f->varintVal; + LOG("[Stats] slot5 GetUserStats seen: app=%u namespace=%d", appId, IsNamespaceApp(appId) ? 1 : 0); + if (appId != 0 && IsNamespaceApp(appId)) { + auto res = StatsHandlers::HandleGetUserStats(appId, reqFields); + void* respBody = *(void**)((uintptr_t)response + 48); + if (respBody && res.body.Size() > 0 && + ParseBytesToBody(respBody, res.body.Data().data(), res.body.Size())) { + if (flags) *flags = 0; + LOG("[Stats] GetUserStats app=%u handled locally (%zu bytes)", + appId, res.body.Size()); + return true; + } + LOG("[Stats] GetUserStats app=%u NOT handled (respBody=%p bodySize=%zu) -> passthrough", + appId, *(void**)((uintptr_t)response + 48), res.body.Size()); + } + } else { + LOG("[Stats] slot5 GetUserStats: null reqBody -> passthrough"); + } + } + return g_originalSlot5(thisptr, methodName, request, response, flags); + } + + if (strcmp(methodName, StatsHandlers::RPC_GET_LAST_PLAYED) == 0) { + bool result = g_originalSlot5(thisptr, methodName, request, response, flags); + LOG("[Stats] slot5 GetLastPlayedTimes seen: serverResult=%d", result ? 1 : 0); + if (result && response) { + void* respBody = *(void**)((uintptr_t)response + 48); + if (respBody) { + // Parse the request to honor min_last_played. + std::vector<PB::Field> reqFields; + if (request) { + void* reqBody = *(void**)((uintptr_t)request + 48); + if (reqBody) { + auto reqBytes = SerializeBodyToBytes(reqBody); + reqFields = PB::Parse(reqBytes.data(), reqBytes.size()); + } + } + auto ours = StatsHandlers::HandleGetLastPlayedTimes(reqFields); + if (ours.body.Size() > 0) { + // Append our games[] (field 1) to the server's response. + auto respBytes = SerializeBodyToBytes(respBody); + PB::Writer merged; + // keep all existing fields verbatim + auto existing = PB::Parse(respBytes.data(), respBytes.size()); + for (const auto& f : existing) { + if (f.wireType == PB::Varint) merged.WriteVarint(f.fieldNum, f.varintVal); + else if (f.wireType == PB::Fixed64) merged.WriteFixed64(f.fieldNum, f.varintVal); + else if (f.wireType == PB::Fixed32) merged.WriteFixed32(f.fieldNum, (uint32_t)f.varintVal); + else if (f.wireType == PB::LengthDelimited) merged.WriteBytes(f.fieldNum, f.data, f.dataLen); + } + // append our games (each game is a length-delimited field 1) + auto ourFields = PB::Parse(ours.body.Data().data(), ours.body.Size()); + size_t added = 0; + for (const auto& f : ourFields) { + if (f.fieldNum == 1 && f.wireType == PB::LengthDelimited) { + merged.WriteBytes(1, f.data, f.dataLen); + ++added; + } + } + if (added > 0 && + ParseBytesToBody(respBody, merged.Data().data(), merged.Size())) { + LOG("[Stats] GetLastPlayedTimes: appended %zu local game(s) to server response", added); + } else { + LOG("[Stats] GetLastPlayedTimes: nothing appended (added=%zu)", added); + } + } else { + LOG("[Stats] GetLastPlayedTimes: store had no local games to append"); + } + } + } + return result; + } + // -------------------------------------------------------------------------- + if (strncmp(methodName, "Cloud.", 6) != 0) { return g_originalSlot5(thisptr, methodName, request, response, flags); } @@ -1675,244 +1612,6 @@ static uint32_t CheckNotificationNamespaceApp(const char* methodName, void* body return 0; } -// On namespace-app exit: read appcache/stats/UserGameStats_{account}_{app}.bin and store as a cross-machine restore blob. -static void UploadStatsOnExit(uint32_t appId) { - if (!CloudStorage::IsCloudActive()) return; - - uint32_t accountId = GetAccountId(); - if (!accountId) return; - - std::string statsFile = g_steamPath + "appcache\\stats\\UserGameStats_" - + std::to_string(accountId) + "_" + std::to_string(appId) + ".bin"; - - // Wide-API: CreateFileA narrows via ACP and fails for non-ASCII Steam - // install roots, silently skipping stats upload for affected users. - auto statsFileWide = FileUtil::Utf8ToPath(statsFile).wstring(); - HANDLE hFile = CreateFileW(statsFileWide.c_str(), GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, nullptr); - if (hFile == INVALID_HANDLE_VALUE) { - LOG("[Stats] No stats file for app %u, skipping upload", appId); - return; - } - - LARGE_INTEGER fileSize; - if (!GetFileSizeEx(hFile, &fileSize) || fileSize.QuadPart <= 0 || fileSize.QuadPart > 50 * 1024 * 1024) { - LOG("[Stats] Stats file empty or too large for app %u (%lld bytes), skipping upload", - appId, fileSize.QuadPart); - CloseHandle(hFile); - return; - } - - std::vector<uint8_t> data(static_cast<size_t>(fileSize.QuadPart)); - DWORD bytesRead = 0; - BOOL readOk = ReadFile(hFile, data.data(), static_cast<DWORD>(data.size()), &bytesRead, nullptr); - CloseHandle(hFile); - - if (!readOk || bytesRead != data.size()) { - LOG("[Stats] Failed to read stats file for app %u", appId); - return; - } - - // Steam writes a 38-byte cache{crc,PendingChanges}+END skeleton when no stats - // are loaded; uploading it clobbers a richer cloud blob. 64-byte floor matches - // PreStage threshold. - if (data.size() <= 64) { - LOG("[Stats] Skipping upload for app %u: file too small (%zu bytes), likely empty stub", - appId, data.size()); - return; - } - if (!StatsBlobHasUnlocks(data.data(), data.size())) { - LOG("[Stats] Skipping upload for app %u: blob has no unlocked stats/achievements (%zu bytes)", - appId, data.size()); - return; - } - - // Account-scoped sentinel (appId=0): keeps blob out of per-app namespace so Steam never resolves it under an AutoCloud root. See cloud_metadata_paths.h. - bool ok = CloudStorage::StoreBlob(accountId, kAccountScopeAppId, - AccountStatsFilename(appId), data.data(), data.size()); - LOG("[Stats] Uploaded stats for app %u (%zu bytes, ok=%d)", appId, data.size(), ok); - - if (ok) { - CloudStorage::DeleteBlob(accountId, appId, kLegacyStatsMetadataPath); - } -} - -// Upload playtime on namespace-app exit. Internal launch->exit delta + launch-time VDF baseline (exit-side VDF is unreliable - Steam may not have written it yet). -static void UploadPlaytimeOnExit(uint32_t appId) { - if (!CloudStorage::IsCloudActive()) return; - - uint32_t accountId = GetAccountId(); - if (!accountId) return; - - auto info = PopLaunchInfo(appId); - time_t now = time(nullptr); - - uint64_t trackedMinutes = 0; - uint64_t trackedLastPlayed = (uint64_t)now; - - if (info.launchTime > 0 && now > info.launchTime) { - trackedMinutes = (uint64_t)(now - info.launchTime) / 60; - LOG("[Playtime] Internal tracking for app %u: %llu minutes (baseline=%llu)", appId, trackedMinutes, info.vdfBaseline); - } else { - LOG("[Playtime] No internal launch time for app %u, relying on VDF", appId); - } - - // Read Steam's cumulative playtime from localconfig.vdf (if available). - // Use Win32 API with shared access since Steam may have the file open. - uint64_t vdfLastPlayed = 0, vdfPlaytime = 0, vdfPlaytime2wks = 0; - { - std::string vdfPath = g_steamPath + "userdata\\" + std::to_string(accountId) - + "\\config\\localconfig.vdf"; - // Wide-API parity with the launch-time reader above; see UploadStatsOnExit. - auto vdfPathWide = FileUtil::Utf8ToPath(vdfPath).wstring(); - HANDLE hFile = CreateFileW(vdfPathWide.c_str(), GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, nullptr); - if (hFile != INVALID_HANDLE_VALUE) { - DWORD fileSize = GetFileSize(hFile, nullptr); - std::string vdfContent; - if (fileSize != INVALID_FILE_SIZE && fileSize > 0) { - vdfContent.resize(fileSize); - DWORD bytesRead = 0; - ReadFile(hFile, (LPVOID)vdfContent.data(), fileSize, &bytesRead, nullptr); - vdfContent.resize(bytesRead); - } - CloseHandle(hFile); - - std::string appIdStr = std::to_string(appId); - const char* sections[] = { "UserLocalConfigStore", "Software", "Valve", "Steam", "Apps", appIdStr.c_str() }; - bool sectionFound = VdfUtil::ForEachFieldInSection(vdfContent, sections, 6, - [&](const VdfUtil::FieldInfo& fi) { - if (fi.key == "LastPlayed") - vdfLastPlayed = strtoull(std::string(fi.value).c_str(), nullptr, 10); - else if (fi.key == "Playtime") - vdfPlaytime = strtoull(std::string(fi.value).c_str(), nullptr, 10); - else if (fi.key == "Playtime2wks") - vdfPlaytime2wks = strtoull(std::string(fi.value).c_str(), nullptr, 10); - return true; - }); - LOG("[Playtime] VDF for app %u: found=%d Playtime=%llu Playtime2wks=%llu LastPlayed=%llu (read %lu bytes)", - appId, sectionFound, vdfPlaytime, vdfPlaytime2wks, vdfLastPlayed, (unsigned long)vdfContent.size()); - } else { - LOG("[Playtime] Cannot open localconfig.vdf for app %u (err=%lu, path=%s)", - appId, GetLastError(), vdfPath.c_str()); - } - } - - // Use the launch-time VDF baseline if exit-side read came back empty. - // Steam may not have flushed playtime to disk yet at exit time. - if (vdfPlaytime == 0 && info.vdfBaseline > 0) { - vdfPlaytime = info.vdfBaseline; - LOG("[Playtime] Using launch-time VDF baseline for app %u: %llu min", appId, vdfPlaytime); - } - if (vdfPlaytime2wks == 0 && info.vdfBaseline2wks > 0) { - vdfPlaytime2wks = info.vdfBaseline2wks; - LOG("[Playtime] Using launch-time 2wks VDF baseline for app %u: %llu min", appId, vdfPlaytime2wks); - } - - uint64_t lastPlayed = (trackedLastPlayed > vdfLastPlayed) ? trackedLastPlayed : vdfLastPlayed; - - // Merge with existing blob; CheckBlobExists distinguishes Missing (merge with empty) from Error (abort) - RetrieveBlob alone would silently overwrite on transient failure. - uint64_t cloudLastPlayed = 0, cloudPlaytime = 0, cloudPlaytime2wks = 0; - auto acctScopeStatus = CloudStorage::CheckBlobExists( - accountId, kAccountScopeAppId, AccountPlaytimeFilename(appId)); - if (acctScopeStatus == ICloudProvider::ExistsStatus::Error) { - LOG("[Playtime] account-scope existence check returned Error for app %u; " - "aborting upload to avoid stale-merge rollback", appId); - return; - } - std::vector<uint8_t> ptData; - if (acctScopeStatus == ICloudProvider::ExistsStatus::Exists) { - ptData = CloudStorage::RetrieveBlob(accountId, kAccountScopeAppId, - AccountPlaytimeFilename(appId)); - // Empty after Exists means the download itself failed; abort - // matches the Error path above (our writer never emits empty). - if (ptData.empty()) { - LOG("[Playtime] account-scope retrieve returned empty after Exists for app %u; " - "aborting upload to avoid stale-merge rollback", appId); - return; - } - } - if (!ptData.empty()) { - std::string blob(reinterpret_cast<const char*>(ptData.data()), ptData.size()); - auto parsed = Json::Parse(blob); - if (parsed.type == Json::Type::Object) { - if (parsed.has("LastPlayed")) - cloudLastPlayed = (parsed["LastPlayed"].type == Json::Type::Number) - ? (parsed["LastPlayed"].number() > 0 ? (uint64_t)parsed["LastPlayed"].number() : 0) - : strtoull(parsed["LastPlayed"].str().c_str(), nullptr, 10); - if (parsed.has("Playtime")) - cloudPlaytime = (parsed["Playtime"].type == Json::Type::Number) - ? (parsed["Playtime"].number() > 0 ? (uint64_t)parsed["Playtime"].number() : 0) - : strtoull(parsed["Playtime"].str().c_str(), nullptr, 10); - if (parsed.has("Playtime2wks")) - cloudPlaytime2wks = (parsed["Playtime2wks"].type == Json::Type::Number) - ? (parsed["Playtime2wks"].number() > 0 ? (uint64_t)parsed["Playtime2wks"].number() : 0) - : strtoull(parsed["Playtime2wks"].str().c_str(), nullptr, 10); - } else { - std::istringstream blobStream(blob); - std::string blobLine; - while (std::getline(blobStream, blobLine)) { - size_t tab = blobLine.find('\t'); - if (tab == std::string::npos) continue; - std::string key = blobLine.substr(0, tab); - std::string val = blobLine.substr(tab + 1); - if (key == "LastPlayed") cloudLastPlayed = strtoull(val.c_str(), nullptr, 10); - else if (key == "Playtime") cloudPlaytime = strtoull(val.c_str(), nullptr, 10); - else if (key == "Playtime2wks") cloudPlaytime2wks = strtoull(val.c_str(), nullptr, 10); - } - } - } - // Steam decays Playtime2wks; never default it to lifetime total. - // Clamp corrupt blobs (2wks > total) by zeroing 2wks. - // 2wks==total at non-trivial lifetimes is the signature of the prior - // "default 2wks to lifetime" bug; recover by zeroing. - constexpr uint64_t kTwoWeeksMinutes = 14ULL * 24 * 60; - if (cloudPlaytime2wks > cloudPlaytime || - (cloudPlaytime2wks == cloudPlaytime && cloudPlaytime > kTwoWeeksMinutes)) - cloudPlaytime2wks = 0; - - // Playtime merge: baseline + session, but never less than VDF or cloud - uint64_t mergedPlaytime = cloudPlaytime + trackedMinutes; - if (vdfPlaytime > mergedPlaytime) - mergedPlaytime = vdfPlaytime; - if (info.vdfBaseline + trackedMinutes > mergedPlaytime) - mergedPlaytime = info.vdfBaseline + trackedMinutes; - uint64_t mergedPlaytime2wks = cloudPlaytime2wks + trackedMinutes; - if (vdfPlaytime2wks > mergedPlaytime2wks) - mergedPlaytime2wks = vdfPlaytime2wks; - if (info.vdfBaseline2wks + trackedMinutes > mergedPlaytime2wks) - mergedPlaytime2wks = info.vdfBaseline2wks + trackedMinutes; - // Never let recent exceed lifetime; safer to under-report than poison cloud. - if (mergedPlaytime2wks > mergedPlaytime) - mergedPlaytime2wks = mergedPlaytime; - uint64_t mergedLastPlayed = (lastPlayed > cloudLastPlayed) ? lastPlayed : cloudLastPlayed; - - if (mergedPlaytime == 0 && mergedPlaytime2wks == 0 && mergedLastPlayed == 0) { - LOG("[Playtime] No playtime data for app %u (no tracking, no VDF, no cloud)", appId); - return; - } - - Json::Value obj = Json::Object(); - obj.objVal["LastPlayed"] = Json::String(std::to_string(mergedLastPlayed)); - obj.objVal["Playtime"] = Json::String(std::to_string(mergedPlaytime)); - obj.objVal["Playtime2wks"] = Json::String(std::to_string(mergedPlaytime2wks)); - std::string blobStr = Json::Stringify(obj); - - // Account-scoped sentinel (appId=0): keeps blob out of per-app namespace so Steam never resolves it under an AutoCloud root. See cloud_metadata_paths.h. - bool ok = CloudStorage::StoreBlob(accountId, kAccountScopeAppId, - AccountPlaytimeFilename(appId), - reinterpret_cast<const uint8_t*>(blobStr.data()), blobStr.size()); - LOG("[Playtime] Uploaded playtime for app %u (session=%llu min, baseline=%llu min, baseline2wks=%llu min, vdf=%llu min, vdf2wks=%llu min, cloud=%llu min, cloud2wks=%llu min, total=%llu min, 2wks=%llu min, LastPlayed=%llu, ok=%d)", - appId, trackedMinutes, info.vdfBaseline, info.vdfBaseline2wks, vdfPlaytime, vdfPlaytime2wks, - cloudPlaytime, cloudPlaytime2wks, mergedPlaytime, mergedPlaytime2wks, mergedLastPlayed, ok); - - if (ok) { - CloudStorage::DeleteBlob(accountId, appId, kLegacyPlaytimeMetadataPath); - } -} - // Slot 8 hook - Notification wrapper (e.g. SignalAppExitSyncDone) // request is a CProtoBufMsg* with body at +48, header at +40 static bool __fastcall NotificationWrapperHook(void* thisptr, const char* methodName, void* request) { @@ -1992,19 +1691,6 @@ static bool __fastcall NotificationWrapperHook(void* thisptr, const char* method // Release cloud session lock -- server-faithful: sync done, release ownership. CloudStorage::ReleaseCloudSession(accountId, realAppId, clientId); } - if (!g_shuttingDown.load(std::memory_order_acquire) && MetadataSync::IsEnabled()) { - uint32_t capturedAppId = realAppId; - std::thread t([capturedAppId] { - if (g_syncAchievements) UploadStatsOnExit(capturedAppId); - if (g_syncPlaytime) UploadPlaytimeOnExit(capturedAppId); - }); - std::lock_guard<std::mutex> lock(g_bgThreadsMutex); - if (g_shuttingDown.load(std::memory_order_acquire)) { - t.detach(); - } else { - g_bgThreads.push_back(std::move(t)); - } - } LOG("[VtHook-Notif] %s app=%u: letting Steam process internally", methodName, realAppId); return g_originalSlot8(thisptr, methodName, request); } @@ -3302,81 +2988,6 @@ static bool IsSelfUnlockingLua(const std::string& filePath, uint32_t appId) { return false; } -// Stage cached UserGameStats into appcache/stats/ before Steam's one-shot startup load. -static void PreStageStatsFromLocalCache(const std::string& steamPath) { - std::string storageRoot = steamPath + "cloud_redirect\\storage\\"; - std::string statsRoot = steamPath + "appcache\\stats\\"; - - auto storageRootWide = FileUtil::Utf8ToPath(storageRoot).wstring(); - DWORD attrs = GetFileAttributesW(storageRootWide.c_str()); - if (attrs == INVALID_FILE_ATTRIBUTES || !(attrs & FILE_ATTRIBUTE_DIRECTORY)) - return; - - auto statsRootWide = FileUtil::Utf8ToPath(statsRoot).wstring(); - if (GetFileAttributesW(statsRootWide.c_str()) == INVALID_FILE_ATTRIBUTES) { - std::error_code ec; - std::filesystem::create_directories(FileUtil::Utf8ToPath(statsRoot), ec); - } - - int staged = 0; - int skipped = 0; - WIN32_FIND_DATAW acctFd; - HANDLE hAcct = FindFirstFileW((storageRootWide + L"*").c_str(), &acctFd); - if (hAcct == INVALID_HANDLE_VALUE) return; - do { - if (!(acctFd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) continue; - std::wstring acctName = acctFd.cFileName; - if (acctName == L"." || acctName == L"..") continue; - bool allDigits = !acctName.empty(); - for (wchar_t c : acctName) { if (c < L'0' || c > L'9') { allDigits = false; break; } } - if (!allDigits) continue; - - std::wstring statsDir = storageRootWide + acctName + L"\\0\\UserGameStats\\"; - if (GetFileAttributesW(statsDir.c_str()) == INVALID_FILE_ATTRIBUTES) continue; - - WIN32_FIND_DATAW appFd; - HANDLE hApp = FindFirstFileW((statsDir + L"*.bin").c_str(), &appFd); - if (hApp == INVALID_HANDLE_VALUE) continue; - do { - if (appFd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue; - std::wstring appBin = appFd.cFileName; - if (appBin.size() < 5) continue; - std::wstring appStem = appBin.substr(0, appBin.size() - 4); - bool appDigits = !appStem.empty(); - for (wchar_t c : appStem) { if (c < L'0' || c > L'9') { appDigits = false; break; } } - if (!appDigits) continue; - - std::wstring srcPath = statsDir + appBin; - std::wstring dstPath = statsRootWide + L"UserGameStats_" + acctName + L"_" + appStem + L".bin"; - - // Steam writes a 38 B empty stub for unloaded apps; skip anything bigger. - WIN32_FILE_ATTRIBUTE_DATA dstAttr{}; - bool dstExists = GetFileAttributesExW(dstPath.c_str(), - GetFileExInfoStandard, &dstAttr) != 0; - if (dstExists) { - ULARGE_INTEGER dstSize; - dstSize.LowPart = dstAttr.nFileSizeLow; - dstSize.HighPart = dstAttr.nFileSizeHigh; - if (dstSize.QuadPart > 64) { - skipped++; - continue; - } - } - - if (CopyFileW(srcPath.c_str(), dstPath.c_str(), FALSE)) { - staged++; - } else { - LOG("[PreStage] CopyFile failed: %ls -> %ls (err=%lu)", - srcPath.c_str(), dstPath.c_str(), GetLastError()); - } - } while (FindNextFileW(hApp, &appFd)); - FindClose(hApp); - } while (FindNextFileW(hAcct, &acctFd)); - FindClose(hAcct); - - if (staged > 0 || skipped > 0) - LOG("[PreStage] Staged %d UserGameStats files from local cache (skipped %d non-empty)", staged, skipped); -} // DLL auto-update: check GitHub for a newer cloud_redirect.dll, replace on disk. @@ -3787,9 +3398,6 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa else LOG("Steam version: UNKNOWN (manifest unreadable)"); - if (MetadataSync::steamToolsPresent.load(std::memory_order_relaxed)) - PreStageStatsFromLocalCache(g_steamPath); - // Auto-detect namespace apps from {steamPath}\config\stplug-in\*.lua, restricted to self-unlocking luas (base-game addappid for own id). std::string pluginDir = g_steamPath + "config\\stplug-in\\*"; std::string pluginBase = g_steamPath + "config\\stplug-in\\"; @@ -4220,12 +3828,8 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa HttpServer::SetMaxUploadMB(mb); } - // Stats/playtime/lua sync requires SteamTools. + // Lua sync requires SteamTools. if (MetadataSync::steamToolsPresent.load(std::memory_order_relaxed)) { - if (cfg["sync_achievements"].type == Json::Type::Bool) - MetadataSync::syncAchievements = cfg["sync_achievements"].boolean(); - if (cfg["sync_playtime"].type == Json::Type::Bool) - MetadataSync::syncPlaytime = cfg["sync_playtime"].boolean(); if (cfg["sync_luas"].type == Json::Type::Bool) MetadataSync::syncLuas = cfg["sync_luas"].boolean(); } @@ -4263,6 +3867,36 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa CloudStorage::Init(cloudRoot, std::move(provider)); g_startupMetadataScheduled.store(false); + // Native stats / playtime store (cloud-backed). Must come after CloudStorage. + // Install cloud-backing callbacks so per-app stats blobs ride each app's + // Steam Cloud sync (same mechanism as CN/root-token metadata blobs). + StatsStore::SetCloudProvider( + // pull: download the per-app stats blob as text + [](uint32_t appId, std::string& outJson) -> bool { + uint32_t accountId = GetAccountId(); + if (accountId == 0) return false; + std::vector<uint8_t> data; + if (!CloudStorage::DownloadCloudMetadataWithLegacyFallback( + accountId, appId, "stats.json", nullptr, data) || data.empty()) + return false; + outJson.assign(reinterpret_cast<const char*>(data.data()), data.size()); + return true; + }, + // push: upload the per-app stats blob (fire-and-forget) + [](uint32_t appId, const std::string& json) { + uint32_t accountId = GetAccountId(); + if (accountId == 0) return; + CloudStorage::UploadCloudMetadataText(accountId, appId, "stats.json", json); + }); + // Restrict all playtime/stats tracking to namespace/lua apps only -- real + // owned games must never have their playtime recorded or synced. + StatsHandlers::SetNamespacePredicate([](uint32_t appId) { return IsNamespaceApp(appId); }); + // Resolve current accountId lazily so the store can import Steam's native + // UserGameStats blobs (appcache\stats\UserGameStats_<accountId>_<appId>.bin). + StatsStore::SetAccountIdProvider([]() -> uint32_t { return GetAccountId(); }); + StatsStore::Init(cloudRoot, g_steamPath); + StatsHandlers::Init(); + SteamKvInjector::Init(); @@ -4452,7 +4086,17 @@ static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { void* bodyObj = *(void**)((uintptr_t)pMsg + CPROTOBUFMSG_OFF_BODY); if (bodyObj) { - RewriteGamesPlayedBody(bodyObj); + // Observe games-played to track native playtime sessions, then + // rewrite for the non-Steam-game spoof. Observation reads only; + // it starts/ends StatsStore sessions by appid. + auto observeBytes = SerializeBodyToBytes(bodyObj); + if (!observeBytes.empty()) { + LOG("[Stats] GamesPlayed observed (emsg=%u, %zu bytes) -> session tracking", + emsg, observeBytes.size()); + StatsHandlers::ObserveGamesPlayed(observeBytes.data(), observeBytes.size()); + } + if (g_showNonSteamGame.load(std::memory_order_relaxed)) + RewriteGamesPlayedBody(bodyObj); } } } @@ -4462,10 +4106,9 @@ static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { void InstallGamesPlayedHook() { if (!HasNamespaceApps()) return; - if (!g_showNonSteamGame.load(std::memory_order_relaxed)) { - LOG("[GamesPlayed] Disabled by config (show_non_steam_game=false)"); - return; - } + // NOTE: always install. The hook serves two purposes: (1) playtime session + // tracking (ObserveGamesPlayed) which must run regardless of config, and + // (2) the non-Steam-game spoof, which is gated per-call on g_showNonSteamGame. HMODULE hSteamClient = GetModuleHandleA("steamclient64.dll"); if (!hSteamClient) { @@ -4994,6 +4637,10 @@ static void ShutdownImpl() { } } + // Flush native stats / playtime to cloud now that hook calls are drained + // (ObserveGamesPlayed and the stats handlers can no longer touch the store). + StatsHandlers::Shutdown(); + // Restore vtable pointers before DLL unload, but skip if steamclient64 // is gone (Steam's clean exit FreeLibrarys it before ExitProcess; the // cached base then points at unmapped memory and VirtualProtect 487s). diff --git a/src/platform/win/cloud_intercept.h b/src/platform/win/cloud_intercept.h index d6a0c01a..52e26e61 100644 --- a/src/platform/win/cloud_intercept.h +++ b/src/platform/win/cloud_intercept.h @@ -50,9 +50,6 @@ void SetAccountId(uint32_t accountId); // get the Steam installation path (with trailing backslash) const std::string& GetSteamPath(); -// record the launch timestamp for internal playtime tracking -void RecordLaunchTime(uint32_t appId); - void AddNamespaceApp(uint32_t appId); void RemoveNamespaceApp(uint32_t appId); bool IsNamespaceApp(uint32_t appId); From 427a69fecdb1ab9b2b307f5de6dac8fe2d3db100 Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Sun, 7 Jun 2026 19:21:33 -0400 Subject: [PATCH 10/24] Add Stats and Playtime page with cloud-backed data --- .gitignore | 1 + src/common/cli.cpp | 120 +++++ src/common/cli.h | 3 + src/common/cloud_provider.h | 17 + src/providers/google_drive.cpp | 78 +++ src/providers/google_drive_provider.h | 2 + src/providers/onedrive.cpp | 68 +++ src/providers/onedrive_provider.h | 2 + ui/MainWindow.xaml | 3 + ui/Pages/StatsPage.xaml | 246 +++++++++ ui/Pages/StatsPage.xaml.cs | 469 ++++++++++++++++++ ui/Resources/Strings.resx | 66 +++ ui/Services/AchievementSchema.cs | 164 ++++++ ui/Services/CloudProviderClient.cs | 68 +++ ui/Services/EmbeddedCli.cs | 28 +- ui/Services/IUiCloudProvider.cs | 12 + ui/Services/Providers/CliUiCloudProvider.cs | 66 +++ .../Providers/FolderUiCloudProvider.cs | 74 +++ 18 files changed, 1482 insertions(+), 5 deletions(-) create mode 100644 ui/Pages/StatsPage.xaml create mode 100644 ui/Pages/StatsPage.xaml.cs create mode 100644 ui/Services/AchievementSchema.cs diff --git a/.gitignore b/.gitignore index ee60c1b3..2dfbe2ed 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ build/ build-*/ ui/bin/ ui/obj/ +ui/publish/ ui-linux/build/ flatpak/.flatpak-builder/ flatpak/build-dir/ diff --git a/src/common/cli.cpp b/src/common/cli.cpp index a1f07772..eb848818 100644 --- a/src/common/cli.cpp +++ b/src/common/cli.cpp @@ -17,6 +17,11 @@ #include <map> #include <memory> #include <sstream> +#include <vector> +#include <thread> +#include <atomic> +#include <algorithm> +#include <unordered_set> #ifdef _WIN32 #include <Windows.h> @@ -438,6 +443,105 @@ std::string CmdListBlobs(const std::string& provider, const std::string& account }); } +// Search the cloud directly for every stats.json and return each one's content. +// Uses the provider's native filename search (Drive) -- one query, no per-account +// or per-app enumeration. The UI just renders the result. +// Output: {"success":true,"apps":[{"account_id":"..","app_id":"..","content":"<json>"},...]} +std::string CmdListAllStats(const std::string& provider) { + std::string tokenPath = GetTokenPath(provider); + if (tokenPath.empty()) { + return JsonError("Cannot determine config directory"); + } + + auto prov = CreateCloudProvider(provider); + if (!prov) { + return JsonError("Unknown provider: " + provider); + } + if (!prov->Init(tokenPath)) { + return JsonError("Failed to initialize provider"); + } + if (!prov->IsAuthenticated()) { + prov->Shutdown(); + return JsonError("Not authenticated"); + } + + bool supported = false; + auto hits = prov->SearchByName("stats.json", &supported); + prov->Shutdown(); + + if (!supported) { + return JsonError("Search not supported for provider: " + provider); + } + + std::ostringstream apps; + apps << "["; + bool first = true; + for (const auto& h : hits) { + // h.path is "<accountId>/<appId>/stats.json". + size_t s1 = h.path.find('/'); + if (s1 == std::string::npos) continue; + size_t s2 = h.path.find('/', s1 + 1); + if (s2 == std::string::npos) continue; + std::string accountId = h.path.substr(0, s1); + std::string appId = h.path.substr(s1 + 1, s2 - s1 - 1); + std::string content(reinterpret_cast<const char*>(h.content.data()), h.content.size()); + + if (!first) apps << ","; + apps << JsonObject({ + {"account_id", JsonString(accountId)}, + {"app_id", JsonString(appId)}, + {"content", JsonString(content)} + }); + first = false; + } + apps << "]"; + return std::string("{\"success\":true,\"apps\":") + apps.str() + "}"; +} + +std::string CmdDownloadBlob(const std::string& provider, const std::string& accountId, + const std::string& appId, const std::string& blobName) { + std::string tokenPath = GetTokenPath(provider); + if (tokenPath.empty()) { + return JsonError("Cannot determine config directory"); + } + + auto prov = CreateCloudProvider(provider); + if (!prov) { + return JsonError("Unknown provider: " + provider); + } + + if (!prov->Init(tokenPath)) { + return JsonError("Failed to initialize provider"); + } + + if (!prov->IsAuthenticated()) { + prov->Shutdown(); + return JsonError("Not authenticated"); + } + + // Metadata-text blobs (e.g. stats.json) live at <accountId>/<appId>/<name> + // and are stored uncompressed, so the raw download is the literal content. + std::string path = accountId + "/" + appId + "/" + blobName; + std::vector<uint8_t> data; + bool ok = prov->Download(path, data); + prov->Shutdown(); + + if (!ok) { + return JsonObject({ + {"success", JsonBool(false)}, + {"found", JsonBool(false)}, + {"error", JsonString("Blob not found")} + }); + } + + std::string content(reinterpret_cast<const char*>(data.data()), data.size()); + return JsonObject({ + {"success", JsonBool(true)}, + {"found", JsonBool(true)}, + {"content", JsonString(content)} + }); +} + std::string CmdDeleteBlobs(const std::string& provider, const std::string& accountId, const std::string& appId, const std::vector<std::string>& blobNames) { std::string tokenPath = GetTokenPath(provider); @@ -775,6 +879,8 @@ static void PrintUsage() { fprintf(stderr, " list-remote-app-files <provider> <account_id> <app_id> List every file path in one remote app\n"); fprintf(stderr, " delete-remote-app <provider> <account_id> <app_id> Delete app from cloud\n"); fprintf(stderr, " list-blobs <provider> <account_id> <app_id> List blob files in app\n"); + fprintf(stderr, " download-blob <provider> <account_id> <app_id> <blob> Download a single blob's content\n"); + fprintf(stderr, " list-all-stats <provider> Search the cloud for every app's stats.json\n"); fprintf(stderr, " delete-blobs <provider> <account_id> <app_id> <blob>... Delete specific blobs\n"); fprintf(stderr, " sync-remote-app <provider> <account_id> <app_id> <cloud_root> Run SyncFromCloud for one app\n"); fprintf(stderr, " sync-all-remote-apps <provider> <account_id> <cloud_root> Run SyncAllFromCloud for one account\n"); @@ -836,6 +942,20 @@ int RunCli(int argc, char** argv) { } result = CmdListBlobs(argv[3], argv[4], argv[5]); } + else if (strcmp(command, "download-blob") == 0) { + if (argc < 7) { + fprintf(stderr, "Error: download-blob requires <provider> <account_id> <app_id> <blob>\n"); + return 1; + } + result = CmdDownloadBlob(argv[3], argv[4], argv[5], argv[6]); + } + else if (strcmp(command, "list-all-stats") == 0) { + if (argc < 4) { + fprintf(stderr, "Error: list-all-stats requires <provider>\n"); + return 1; + } + result = CmdListAllStats(argv[3]); + } else if (strcmp(command, "delete-blobs") == 0) { if (argc < 7) { fprintf(stderr, "Error: delete-blobs requires <provider> <account_id> <app_id> <blob>...\n"); diff --git a/src/common/cli.h b/src/common/cli.h index e97897df..0662e146 100644 --- a/src/common/cli.h +++ b/src/common/cli.h @@ -29,6 +29,9 @@ std::string CmdListRemoteAppIds(const std::string& provider, const std::string& std::string CmdListRemoteAppFiles(const std::string& provider, const std::string& accountId, const std::string& appId); std::string CmdDeleteRemoteApp(const std::string& provider, const std::string& accountId, const std::string& appId); std::string CmdListBlobs(const std::string& provider, const std::string& accountId, const std::string& appId); +std::string CmdDownloadBlob(const std::string& provider, const std::string& accountId, + const std::string& appId, const std::string& blobName); +std::string CmdListAllStats(const std::string& provider); std::string CmdDeleteBlobs(const std::string& provider, const std::string& accountId, const std::string& appId, const std::vector<std::string>& blobNames); std::string CmdSyncRemoteApp(const std::string& provider, const std::string& accountId, const std::string& appId, diff --git a/src/common/cloud_provider.h b/src/common/cloud_provider.h index c13b6394..003e4117 100644 --- a/src/common/cloud_provider.h +++ b/src/common/cloud_provider.h @@ -80,6 +80,23 @@ class ICloudProvider { if (outComplete) *outComplete = true; return true; } + + // A blob found by a global filename search: its full relative path + // ("{accountId}/{appId}/{filename}") and content. + struct SearchHit { + std::string path; + std::vector<uint8_t> content; + }; + + // Search the whole account for files with the given exact name (e.g. + // "stats.json") and return each match's path + content. Default: not + // supported (empty result, supported=false) -- callers fall back to listing. + // Providers that support a native search (Drive) override this for speed. + virtual std::vector<SearchHit> SearchByName(const std::string& /*filename*/, + bool* outSupported = nullptr) { + if (outSupported) *outSupported = false; + return {}; + } }; std::unique_ptr<ICloudProvider> CreateCloudProvider(const std::string& name); diff --git a/src/providers/google_drive.cpp b/src/providers/google_drive.cpp index f52b0860..c64843ce 100644 --- a/src/providers/google_drive.cpp +++ b/src/providers/google_drive.cpp @@ -558,6 +558,84 @@ GoogleDriveProvider::DownloadFileById(const std::string& fileId) { return std::vector<uint8_t>(r.body.begin(), r.body.end()); } +std::vector<ICloudProvider::SearchHit> +GoogleDriveProvider::SearchByName(const std::string& filename, bool* outSupported) { + if (outSupported) *outSupported = true; + std::vector<SearchHit> hits; + + // Per-folder-id name resolution with a tiny local cache (account/app + // folders repeat across hits). Returns "" on failure. + std::unordered_map<std::string, std::pair<std::string, std::string>> folderInfo; // id -> {name, parentId} + auto getFolder = [&](const std::string& id) -> std::pair<std::string, std::string> { + auto it = folderInfo.find(id); + if (it != folderInfo.end()) return it->second; + auto r = ApiGet("/drive/v3/files/" + id + "?fields=name,parents"); + std::pair<std::string, std::string> info; + if (r.status == 200) { + auto j = Json::Parse(r.body); + info.first = j["name"].str(); + auto& parents = j["parents"]; + if (parents.size() > 0) info.second = parents[(size_t)0].str(); + } + folderInfo[id] = info; + return info; + }; + + // Global search for the exact filename. + std::string q = "name='" + EscapeQuery(filename) + "'" + " and mimeType!='application/vnd.google-apps.folder'" + " and trashed=false"; + std::string baseUrl = "/drive/v3/files?q=" + UrlEncode(q) + + "&fields=nextPageToken,files(id,name,parents)&pageSize=1000"; + std::string pageToken; + + do { + std::string url = baseUrl; + if (!pageToken.empty()) url += "&pageToken=" + UrlEncode(pageToken); + + auto r = ApiGet(url); + if (r.status != 200) { + LOG("[GDrive] SearchByName('%s'): HTTP %d", filename.c_str(), r.status); + if (hits.empty() && outSupported) *outSupported = (r.status == 404); + return hits; + } + + auto j = Json::Parse(r.body); + auto& files = j["files"]; + for (size_t i = 0; i < files.size(); ++i) { + std::string fileId = files[i]["id"].str(); + auto& parents = files[i]["parents"]; + if (parents.size() == 0) continue; + + // parent = appId folder, grandparent = accountId folder. + std::string appFolderId = parents[(size_t)0].str(); + auto appInfo = getFolder(appFolderId); // {appId, accountFolderId} + if (appInfo.first.empty() || appInfo.second.empty()) continue; + auto acctInfo = getFolder(appInfo.second); // {accountId, rootFolderId} + if (acctInfo.first.empty()) continue; + + // Only accept numeric account/app folder names (our layout). + bool ok = !appInfo.first.empty() && !acctInfo.first.empty(); + for (char c : appInfo.first) if (c < '0' || c > '9') { ok = false; break; } + for (char c : acctInfo.first) if (c < '0' || c > '9') { ok = false; break; } + if (!ok) continue; + + auto content = DownloadFileById(fileId); + if (!content || content->empty()) continue; + + SearchHit hit; + hit.path = acctInfo.first + "/" + appInfo.first + "/" + filename; + hit.content = std::move(*content); + hits.push_back(std::move(hit)); + } + + pageToken = j["nextPageToken"].str(); + } while (!pageToken.empty()); + + LOG("[GDrive] SearchByName('%s'): %zu match(es)", filename.c_str(), hits.size()); + return hits; +} + GoogleDriveProvider::LookupStatus GoogleDriveProvider::FindFileInFolderStatus( const std::string& name, const std::string& folderId, std::string* outId) { std::string q = "name='" + EscapeQuery(name) + "'" diff --git a/src/providers/google_drive_provider.h b/src/providers/google_drive_provider.h index 905d747d..a3c5f75f 100644 --- a/src/providers/google_drive_provider.h +++ b/src/providers/google_drive_provider.h @@ -23,6 +23,8 @@ class GoogleDriveProvider : public CloudProviderBase { std::vector<std::string> ListSubfolders(const std::string& prefix) override; bool ListChecked(const std::string& prefix, std::vector<FileInfo>& outFiles, bool* outComplete = nullptr) override; + std::vector<SearchHit> SearchByName(const std::string& filename, + bool* outSupported = nullptr) override; protected: // CloudProviderBase hooks diff --git a/src/providers/onedrive.cpp b/src/providers/onedrive.cpp index 1207636d..f9d99bf0 100644 --- a/src/providers/onedrive.cpp +++ b/src/providers/onedrive.cpp @@ -230,6 +230,74 @@ OneDriveProvider::DownloadFileById(const std::string& itemId) { return std::nullopt; } +std::vector<ICloudProvider::SearchHit> +OneDriveProvider::SearchByName(const std::string& filename, bool* outSupported) { + if (outSupported) *outSupported = true; + std::vector<SearchHit> hits; + + // Graph search. Each hit's parentReference.path is like + // "/drive/root:/CloudRedirect/{accountId}/{appId}" + // so account/app come straight from the path -- no extra lookups. + std::string url = "/v1.0/me/drive/root/search(q='" + EncodePath(filename) + "')" + "?$select=id,name,parentReference&$top=200"; + + while (!url.empty()) { + auto r = ApiGet(url); + if (r.status != 200) { + LOG("[OneDrive] SearchByName('%s'): HTTP %d", filename.c_str(), r.status); + if (hits.empty() && outSupported) *outSupported = (r.status == 404); + return hits; + } + + auto j = Json::Parse(r.body); + auto& items = j["value"]; + for (size_t i = 0; i < items.size(); ++i) { + auto& item = items[i]; + std::string name = UrlDecode(item["name"].str()); + if (name != filename) continue; // search is fuzzy; require exact name + + std::string parentPath = item["parentReference"]["path"].str(); + // Find the segment after "CloudRedirect/". + const std::string marker = "CloudRedirect/"; + size_t pos = parentPath.find(marker); + if (pos == std::string::npos) continue; + std::string rest = parentPath.substr(pos + marker.size()); // "{acct}/{app}" (maybe more) + size_t slash = rest.find('/'); + if (slash == std::string::npos) continue; + std::string accountId = rest.substr(0, slash); + std::string appId = rest.substr(slash + 1); + // appId may have a trailing "/sub" -- keep only the first segment. + size_t slash2 = appId.find('/'); + if (slash2 != std::string::npos) appId = appId.substr(0, slash2); + + // Our layout uses numeric account/app folder names. + bool ok = !accountId.empty() && !appId.empty(); + for (char c : accountId) if (c < '0' || c > '9') { ok = false; break; } + for (char c : appId) if (c < '0' || c > '9') { ok = false; break; } + if (!ok) continue; + + auto content = DownloadFileById(item["id"].str()); + if (!content || content->empty()) continue; + + SearchHit hit; + hit.path = accountId + "/" + appId + "/" + filename; + hit.content = std::move(*content); + hits.push_back(std::move(hit)); + } + + // Pagination. + auto nextLink = j["@odata.nextLink"].str(); + url.clear(); + if (!nextLink.empty()) { + size_t pathStart = nextLink.find("/v1.0/"); + if (pathStart != std::string::npos) url = nextLink.substr(pathStart); + } + } + + LOG("[OneDrive] SearchByName('%s'): %zu match(es)", filename.c_str(), hits.size()); + return hits; +} + // simple upload (<=4MB): PUT content to path-based address bool OneDriveProvider::SimpleUpload(uint32_t accountId, uint32_t appId, const std::string& filename, diff --git a/src/providers/onedrive_provider.h b/src/providers/onedrive_provider.h index 3a37c476..a7272ba9 100644 --- a/src/providers/onedrive_provider.h +++ b/src/providers/onedrive_provider.h @@ -22,6 +22,8 @@ class OneDriveProvider : public CloudProviderBase { std::vector<std::string> ListSubfolders(const std::string& prefix) override; bool ListChecked(const std::string& prefix, std::vector<FileInfo>& outFiles, bool* outComplete = nullptr) override; + std::vector<SearchHit> SearchByName(const std::string& filename, + bool* outSupported = nullptr) override; protected: // CloudProviderBase hooks diff --git a/ui/MainWindow.xaml b/ui/MainWindow.xaml index 8da9a9c6..3f4847b0 100644 --- a/ui/MainWindow.xaml +++ b/ui/MainWindow.xaml @@ -114,6 +114,9 @@ <ui:NavigationViewItem Content="{res:Loc Nav_ManifestPinning}" Icon="{ui:SymbolIcon Pin24}" TargetPageType="{x:Type pages:ManifestPinningPage}" /> + <ui:NavigationViewItem Content="{res:Loc Nav_Stats}" + Icon="{ui:SymbolIcon Trophy24}" + TargetPageType="{x:Type pages:StatsPage}" /> </ui:NavigationView.MenuItems> <ui:NavigationView.FooterMenuItems> diff --git a/ui/Pages/StatsPage.xaml b/ui/Pages/StatsPage.xaml new file mode 100644 index 00000000..cf0a2b12 --- /dev/null +++ b/ui/Pages/StatsPage.xaml @@ -0,0 +1,246 @@ +<Page x:Class="CloudRedirect.Pages.StatsPage" + xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" + xmlns:res="clr-namespace:CloudRedirect.Resources" + xmlns:conv="clr-namespace:CloudRedirect.Converters" + ScrollViewer.CanContentScroll="False"> + + <Page.Resources> + <conv:UrlToImageSourceConverter x:Key="UrlToImageSource" /> + </Page.Resources> + + <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="24"> + <StackPanel MaxWidth="800"> + <Grid Margin="0,0,0,4"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" + Text="{res:Loc Nav_Stats}" + FontSize="28" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <ui:Button Grid.Column="1" + VerticalAlignment="Center" + Appearance="Secondary" + Icon="{ui:SymbolIcon ArrowSync24}" + Content="{res:Loc Stats_Refresh}" + Click="Refresh_Click" /> + </Grid> + + <TextBlock Text="{res:Loc Stats_Hint}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" + Margin="0,0,0,24" /> + + <!-- Search --> + <ui:TextBox x:Name="SearchBox" + PlaceholderText="{res:Loc Search_Placeholder}" + Width="250" + HorizontalAlignment="Left" + TextChanged="SearchBox_TextChanged" + Margin="0,0,0,16" /> + + <!-- Empty state --> + <TextBlock x:Name="EmptyText" + Text="{res:Loc Stats_NoData}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" + Margin="0,0,0,8" + Visibility="Collapsed" /> + + <!-- No cloud configured --> + <TextBlock x:Name="CloudUnavailableText" + Text="{res:Loc Stats_NoCloud}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" + Margin="0,0,0,8" + Visibility="Collapsed" /> + + <!-- Transient status (loading / errors) --> + <TextBlock x:Name="StatusText" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" + Margin="0,0,0,8" + Visibility="Collapsed" /> + + <ItemsControl x:Name="AppList"> + <ItemsControl.GroupStyle> + <GroupStyle> + <GroupStyle.HeaderTemplate> + <DataTemplate> + <TextBlock Text="{Binding Name}" + FontSize="16" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,16,0,8" /> + </DataTemplate> + </GroupStyle.HeaderTemplate> + </GroupStyle> + </ItemsControl.GroupStyle> + <ItemsControl.ItemTemplate> + <DataTemplate> + <Border Background="{DynamicResource CardBackgroundFillColorDefaultBrush}" + BorderBrush="{DynamicResource CardStrokeColorDefaultBrush}" + BorderThickness="1" + CornerRadius="4" + Padding="12,10" + Margin="0,0,0,8"> + <StackPanel> + <!-- Header row --> + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + + <StackPanel Grid.Column="0" Orientation="Horizontal"> + <Button Click="ExpandCollapse_Click" + Tag="{Binding}" + Background="Transparent" + BorderBrush="Transparent" + Padding="4,0" + VerticalAlignment="Center" + Margin="0,0,4,0"> + <ui:SymbolIcon Symbol="{Binding ChevronSymbol}" + FontSize="14" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" /> + </Button> + <Border Width="92" Height="43" + CornerRadius="4" + Margin="0,0,12,0" + VerticalAlignment="Center" + ClipToBounds="True"> + <Border.Background> + <ImageBrush ImageSource="{Binding HeaderUrl, Converter={StaticResource UrlToImageSource}}" + Stretch="UniformToFill" + RenderOptions.BitmapScalingMode="HighQuality" /> + </Border.Background> + </Border> + </StackPanel> + + <StackPanel Grid.Column="1" VerticalAlignment="Center"> + <TextBlock Text="{Binding DisplayName}" + FontSize="15" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + TextTrimming="CharacterEllipsis" /> + <StackPanel Orientation="Horizontal"> + <TextBlock Text="{Binding AppId}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + FontSize="12" /> + <TextBlock Text="{Binding Summary}" + Foreground="{DynamicResource TextFillColorTertiaryBrush}" + FontSize="12" Margin="8,0,0,0" + TextTrimming="CharacterEllipsis" /> + </StackPanel> + </StackPanel> + </Grid> + + <!-- Collapsible details --> + <StackPanel Visibility="{Binding DetailsVisibility}" + Margin="36,12,0,0"> + + <!-- Playtime block --> + <Grid Margin="0,0,0,12"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="*" /> + </Grid.ColumnDefinitions> + <StackPanel Grid.Column="0"> + <TextBlock Text="{res:Loc Stats_PlaytimeForever}" + FontSize="11" + Foreground="{DynamicResource TextFillColorTertiaryBrush}" /> + <TextBlock Text="{Binding PlaytimeForeverText}" + FontSize="14" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + </StackPanel> + <StackPanel Grid.Column="1"> + <TextBlock Text="{res:Loc Stats_Playtime2Weeks}" + FontSize="11" + Foreground="{DynamicResource TextFillColorTertiaryBrush}" /> + <TextBlock Text="{Binding Playtime2WeeksText}" + FontSize="14" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + </StackPanel> + <StackPanel Grid.Column="2"> + <TextBlock Text="{res:Loc Stats_LastPlayed}" + FontSize="11" + Foreground="{DynamicResource TextFillColorTertiaryBrush}" /> + <TextBlock Text="{Binding LastPlayedText}" + FontSize="14" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + </StackPanel> + </Grid> + + <!-- Achievements --> + <StackPanel Visibility="{Binding AchievementsVisibility}" Margin="0,0,0,8"> + <TextBlock Text="{res:Loc Stats_AchievementsHeader}" + FontSize="15" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,0,0,6" /> + <ItemsControl ItemsSource="{Binding Achievements}"> + <ItemsControl.ItemTemplate> + <DataTemplate> + <Grid Margin="0,0,0,6"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="Auto" /> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + <ui:SymbolIcon Grid.Column="0" + Symbol="{Binding StatusSymbol}" + FontSize="18" + Foreground="{Binding StatusBrush}" + Margin="0,0,10,0" + VerticalAlignment="Center" /> + <TextBlock Grid.Column="1" + Text="{Binding Label}" + FontSize="14" + TextTrimming="CharacterEllipsis" + VerticalAlignment="Center" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <TextBlock Grid.Column="2" + Text="{Binding UnlockText}" + FontSize="13" + Margin="12,0,0,0" + VerticalAlignment="Center" + Foreground="{DynamicResource TextFillColorTertiaryBrush}" /> + </Grid> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </StackPanel> + + <!-- Raw stats --> + <StackPanel Visibility="{Binding StatsVisibility}"> + <TextBlock Text="{res:Loc Stats_StatsHeader}" + FontSize="15" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,8,0,6" /> + <ItemsControl ItemsSource="{Binding Stats}"> + <ItemsControl.ItemTemplate> + <DataTemplate> + <StackPanel Orientation="Horizontal" Margin="0,0,0,2"> + <TextBlock Text="{Binding IdText}" + FontFamily="Consolas" + FontSize="12" Margin="0,0,8,0" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" /> + <TextBlock Text="{Binding ValueText}" + FontFamily="Consolas" + FontSize="12" + Foreground="{DynamicResource TextFillColorTertiaryBrush}" /> + </StackPanel> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </StackPanel> + </StackPanel> + </StackPanel> + </Border> + </DataTemplate> + </ItemsControl.ItemTemplate> + </ItemsControl> + </StackPanel> + </ScrollViewer> +</Page> diff --git a/ui/Pages/StatsPage.xaml.cs b/ui/Pages/StatsPage.xaml.cs new file mode 100644 index 00000000..f6c88331 --- /dev/null +++ b/ui/Pages/StatsPage.xaml.cs @@ -0,0 +1,469 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using CloudRedirect.Resources; +using CloudRedirect.Services; +using Wpf.Ui.Controls; + +namespace CloudRedirect.Pages; + +/// <summary> +/// Diagnostics view for the native stats/playtime store. Reads each app's +/// stats.json directly from the configured CLOUD provider (Google Drive / +/// OneDrive / folder) via <see cref="CloudProviderClient"/>, so it shows what is +/// actually synced rather than the local on-disk copy. Read-only. +/// </summary> +public partial class StatsPage : Page +{ + private bool _loading; + private readonly List<StatApp> _apps = new(); + private readonly SteamStoreClient _storeClient = SteamStoreClient.Shared; + private static readonly string LogPath = Path.Combine( + SteamDetector.GetConfigDir(), "stats_page.log"); + private readonly CloudProviderClient _cloud = new(Log); + private System.Windows.Data.ListCollectionView? _appsView; + + private static void Log(string msg) + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!); + File.AppendAllText(LogPath, $"[{DateTime.Now:HH:mm:ss}] {msg}{Environment.NewLine}"); + } + catch { } + } + + public StatsPage() + { + InitializeComponent(); + + Loaded += async (_, _) => + { + _loading = true; + try + { + await LoadStatsAsync(); + await ResolveAppNamesAsync(); + } + catch (Exception ex) + { + Log($"FATAL in load: {ex}"); + ShowStatus($"Error loading stats: {ex.Message}"); + } + finally + { + _loading = false; + } + }; + } + + private void ShowStatus(string text) + { + StatusText.Text = text; + StatusText.Visibility = string.IsNullOrEmpty(text) ? Visibility.Collapsed : Visibility.Visible; + } + + private async Task LoadStatsAsync() + { + ShowStatus("Reading stats from cloud…"); + CloudUnavailableText.Visibility = Visibility.Collapsed; + EmptyText.Visibility = Visibility.Collapsed; + + // Discover the synced account ids (folders under cloud_redirect\storage). + // Everything else -- which apps have stats, and their content -- comes + // from the cloud via the native CLI in a single call. + var cfg = SteamDetector.ReadConfig(); + Log($"Provider config: provider={cfg?.Provider ?? "(null)"} tokenPath={cfg?.TokenPath ?? "(null)"}"); + + if (!IsCloudConfigured()) + { + Log("No cloud provider configured."); + ShowStatus(""); + _apps.Clear(); + RefreshList(); + CloudUnavailableText.Visibility = Visibility.Visible; + return; + } + + // The native CLI searches the cloud directly for every stats.json. + var result = await _cloud.ListAllStatsAsync(); + Log($"ListAllStats returned {result.Entries.Count} entr(ies); error={result.Error ?? "(none)"}"); + if (!string.IsNullOrEmpty(result.Error)) + Log($" error detail: {result.Error}"); + + var apps = new List<StatApp>(); + foreach (var entry in result.Entries) + { + if (!uint.TryParse(entry.AppId, out var appId)) continue; + var app = TryParse(appId, entry.Content); + if (app == null) { Log($" parse failed for {entry.AccountId}/{entry.AppId}"); continue; } + app.AccountId = entry.AccountId; + apps.Add(app); + } + Log($"Parsed {apps.Count} app(s) into view."); + + // Group/sort by account, then most-recently-played, then appId. + apps.Sort((a, b) => + { + int c = string.CompareOrdinal(a.AccountId, b.AccountId); + if (c != 0) return c; + c = b.LastPlayedUnix.CompareTo(a.LastPlayedUnix); + return c != 0 ? c : a.AppId.CompareTo(b.AppId); + }); + + _apps.Clear(); + _apps.AddRange(apps); + RefreshList(); + + ShowStatus(""); + if (_apps.Count == 0) + { + if (!string.IsNullOrEmpty(result.Error)) + ShowStatus($"Cloud error: {result.Error}"); + else + CloudUnavailableText.Visibility = + !IsCloudConfigured() ? Visibility.Visible : Visibility.Collapsed; + } + } + + private static bool IsCloudConfigured() + { + try + { + var cfg = SteamDetector.ReadConfig(); + var p = cfg?.Provider; + return !string.IsNullOrEmpty(p) && p != "local" && p != "none"; + } + catch { return false; } + } + + private static StatApp? TryParse(uint appId, string json) + { + try + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + var app = new StatApp + { + AppId = appId, + DisplayName = S.Format("Stats_AppFallbackName", appId), + CrcStats = root.TryGetProperty("crc_stats", out var crcEl) && crcEl.TryGetUInt32(out var crc) ? crc : 0, + }; + + if (root.TryGetProperty("playtime", out var pt) && pt.ValueKind == JsonValueKind.Object) + { + app.MinutesForever = GetU32(pt, "minutes_forever"); + app.Minutes2Weeks = GetU32(pt, "minutes_2weeks"); + app.LastPlayedUnix = GetU32(pt, "last_played"); + } + + // Local schema names (read from appcache\stats) take precedence over + // any names baked into the cloud blob, so real achievement titles + // show immediately without waiting for the DLL to re-import. + var schemaNames = AchievementSchema.LoadNames(appId); + + // Achievements (each block = one achievement-typed stat whose "value" + // is a bitfield of unlocked achievements, with per-bit unlock times). + // Collect these stat ids so we can hide them from the raw Stats list + // below -- a bitfield int (e.g. 1832827) is meaningless as a "stat". + var achievementStatIds = new HashSet<uint>(); + if (root.TryGetProperty("achievements", out var achs) && achs.ValueKind == JsonValueKind.Array) + { + foreach (var a in achs.EnumerateArray()) + { + uint statId = GetU32(a, "stat_id"); + achievementStatIds.Add(statId); + + uint bits = GetU32(a, "bits"); + var unlockTimes = new uint[32]; + if (a.TryGetProperty("unlock_times", out var times) && times.ValueKind == JsonValueKind.Array) + { + int i = 0; + foreach (var t in times.EnumerateArray()) + { + if (i >= 32) break; + unlockTimes[i++] = t.TryGetUInt32(out var v) ? v : 0; + } + } + + // Human-readable per-bit names from the schema (optional). + var names = new string[32]; + if (a.TryGetProperty("names", out var nameArr) && nameArr.ValueKind == JsonValueKind.Array) + { + int i = 0; + foreach (var n in nameArr.EnumerateArray()) + { + if (i >= 32) break; + names[i++] = n.GetString() ?? ""; + } + } + + for (int bit = 0; bit < 32; bit++) + { + bool unlocked = (bits & (1u << bit)) != 0; + if (!unlocked && unlockTimes[bit] == 0) continue; + + // Prefer the local schema name; fall back to the cloud + // blob's baked-in name, then to the bit identifier. + string name = ""; + if (schemaNames.TryGetValue(((ulong)statId << 32) | (uint)bit, out var sn)) + name = sn; + if (string.IsNullOrEmpty(name)) name = names[bit] ?? ""; + + app.Achievements.Add(new AchievementEntry + { + StatId = statId, + Bit = bit, + Unlocked = unlocked, + UnlockUnix = unlockTimes[bit], + Name = name, + }); + } + } + app.Achievements.Sort((x, y) => y.UnlockUnix.CompareTo(x.UnlockUnix)); + } + + // Stats -- the real gameplay stats only. Skip ids that are actually + // achievement bitfields (already shown in the Achievements section). + if (root.TryGetProperty("stats", out var stats) && stats.ValueKind == JsonValueKind.Array) + { + foreach (var s in stats.EnumerateArray()) + { + uint id = GetU32(s, "id"); + if (achievementStatIds.Contains(id)) continue; + app.Stats.Add(new StatEntry + { + Id = id, + Value = GetU32(s, "value"), + }); + } + } + + return app; + } + catch + { + return null; + } + } + + private static uint GetU32(JsonElement obj, string name) => + obj.TryGetProperty(name, out var el) && el.TryGetUInt32(out var v) ? v : 0; + + private void RefreshList() + { + if (_appsView == null) + { + _appsView = (System.Windows.Data.ListCollectionView) + System.Windows.Data.CollectionViewSource.GetDefaultView(_apps); + _appsView.Filter = AppFilter; + // Group by account so each account's apps are shown together under + // an account header. + _appsView.GroupDescriptions.Add( + new System.Windows.Data.PropertyGroupDescription(nameof(StatApp.AccountLabel))); + AppList.ItemsSource = _appsView; + } + else + { + _appsView.Refresh(); + } + + EmptyText.Visibility = _apps.Count == 0 ? Visibility.Visible : Visibility.Collapsed; + } + + private bool AppFilter(object item) + { + var query = SearchBox?.Text?.Trim() ?? ""; + if (string.IsNullOrEmpty(query)) return true; + if (item is not StatApp a) return false; + return a.DisplayName.Contains(query, StringComparison.OrdinalIgnoreCase) + || a.AppId.ToString().Contains(query, StringComparison.OrdinalIgnoreCase); + } + + private async Task ResolveAppNamesAsync() + { + var ids = _apps.Select(a => a.AppId).Distinct().ToList(); + if (ids.Count == 0) return; + + try + { + var infos = await _storeClient.GetAppInfoAsync(ids); + foreach (var app in _apps) + { + if (infos.TryGetValue(app.AppId, out var info)) + { + if (!string.IsNullOrEmpty(info.Name)) + app.Name = info.Name; + app.HeaderUrl = info.HeaderUrl; + } + } + RefreshList(); + } + catch { } + } + + private void ExpandCollapse_Click(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement { Tag: StatApp app }) return; + app.IsExpanded = !app.IsExpanded; + } + + private void SearchBox_TextChanged(object sender, TextChangedEventArgs e) => RefreshList(); + + private async void Refresh_Click(object sender, RoutedEventArgs e) + { + if (_loading) return; + _loading = true; + try + { + await LoadStatsAsync(); + await ResolveAppNamesAsync(); + } + finally { _loading = false; } + } + + // ── View models ────────────────────────────────────────────────────── + + internal static string FormatPlaytime(uint minutes) + { + if (minutes == 0) return S.Get("Stats_NoPlaytime"); + if (minutes < 60) return S.Format("Stats_Minutes", minutes); + double hours = minutes / 60.0; + return S.Format("Stats_Hours", hours.ToString("0.#", CultureInfo.CurrentCulture)); + } + + internal static string FormatUnixTime(uint unix) + { + if (unix == 0) return S.Get("Stats_Never"); + try + { + var dt = DateTimeOffset.FromUnixTimeSeconds(unix).ToLocalTime(); + return dt.ToString("g", CultureInfo.CurrentCulture); + } + catch { return S.Get("Stats_Never"); } + } + + internal class StatApp : INotifyPropertyChanged + { + public uint AppId { get; set; } + public string AccountId { get; set; } = ""; + public string AccountLabel => S.Format("Stats_AccountHeader", AccountId); + public uint CrcStats { get; set; } + + public uint MinutesForever { get; set; } + public uint Minutes2Weeks { get; set; } + public uint LastPlayedUnix { get; set; } + + public List<StatEntry> Stats { get; } = new(); + public List<AchievementEntry> Achievements { get; } = new(); + + private string _name = ""; + public string Name + { + get => _name; + set { _name = value; Notify(nameof(Name)); Notify(nameof(DisplayName)); } + } + + private string? _headerUrl; + public string? HeaderUrl + { + get => _headerUrl; + set { _headerUrl = value; Notify(nameof(HeaderUrl)); } + } + + private bool _isExpanded; + public bool IsExpanded + { + get => _isExpanded; + set + { + _isExpanded = value; + Notify(nameof(IsExpanded)); + Notify(nameof(ChevronSymbol)); + Notify(nameof(DetailsVisibility)); + } + } + + public string DisplayName + { + get => !string.IsNullOrEmpty(Name) ? Name : _displayName; + set => _displayName = value; + } + private string _displayName = ""; + + public int UnlockedAchievements => Achievements.Count(a => a.Unlocked); + public int TotalAchievementsSeen => Achievements.Count; + + public string PlaytimeForeverText => FormatPlaytime(MinutesForever); + public string Playtime2WeeksText => FormatPlaytime(Minutes2Weeks); + public string LastPlayedText => FormatUnixTime(LastPlayedUnix); + + public string Summary + { + get + { + var parts = new List<string> + { + S.Format("Stats_SummaryPlaytime", PlaytimeForeverText) + }; + if (UnlockedAchievements > 0) + parts.Add(S.Format("Stats_SummaryAchievements", UnlockedAchievements)); + if (Stats.Count > 0) + parts.Add(S.Format("Stats_SummaryStats", Stats.Count)); + return string.Join(" • ", parts); + } + } + + public bool HasAchievements => Achievements.Count > 0; + public bool HasStats => Stats.Count > 0; + public Visibility AchievementsVisibility => HasAchievements ? Visibility.Visible : Visibility.Collapsed; + public Visibility StatsVisibility => HasStats ? Visibility.Visible : Visibility.Collapsed; + + public SymbolRegular ChevronSymbol => + IsExpanded ? SymbolRegular.ChevronDown24 : SymbolRegular.ChevronRight24; + + public Visibility DetailsVisibility => + IsExpanded ? Visibility.Visible : Visibility.Collapsed; + + public event PropertyChangedEventHandler? PropertyChanged; + private void Notify(string n) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(n)); + } + + internal class StatEntry + { + public uint Id { get; set; } + public uint Value { get; set; } + public string IdText => S.Format("Stats_StatId", Id); + public string ValueText => Value.ToString(CultureInfo.CurrentCulture); + } + + internal class AchievementEntry + { + public uint StatId { get; set; } + public int Bit { get; set; } + public bool Unlocked { get; set; } + public uint UnlockUnix { get; set; } + public string Name { get; set; } = ""; + + // Prefer the schema display name; fall back to the stat/bit identifier. + public string Label => !string.IsNullOrEmpty(Name) + ? Name + : S.Format("Stats_AchievementBit", StatId, Bit); + public string UnlockText => + Unlocked ? FormatUnixTime(UnlockUnix) : S.Get("Stats_Locked"); + public SymbolRegular StatusSymbol => + Unlocked ? SymbolRegular.Trophy24 : SymbolRegular.LockClosed24; + public System.Windows.Media.Brush StatusBrush => + Unlocked + ? (System.Windows.Media.Brush)Application.Current.Resources["TextFillColorPrimaryBrush"] + : (System.Windows.Media.Brush)Application.Current.Resources["TextFillColorTertiaryBrush"]; + } +} diff --git a/ui/Resources/Strings.resx b/ui/Resources/Strings.resx index 5cea7c39..c1954910 100644 --- a/ui/Resources/Strings.resx +++ b/ui/Resources/Strings.resx @@ -67,6 +67,72 @@ <data name="Nav_ManifestPinning" xml:space="preserve"> <value>Manifest Pinning</value> </data> + <data name="Nav_Stats" xml:space="preserve"> + <value>Stats & Playtime</value> + </data> + <data name="Stats_Hint" xml:space="preserve"> + <value>WIP WIP BLAH BLAH BLAH LOREM IPSUM</value> + </data> + <data name="Stats_Refresh" xml:space="preserve"> + <value>Refresh</value> + </data> + <data name="Stats_NoData" xml:space="preserve"> + <value>No synced stats found in the cloud yet. Launch a Lua-unlocked game with CloudRedirect active, let it sync, then refresh.</value> + </data> + <data name="Stats_NoCloud" xml:space="preserve"> + <value>No cloud provider is configured. Set up Google Drive, OneDrive, or a folder in Cloud Provider settings to view synced stats.</value> + </data> + <data name="Stats_AppFallbackName" xml:space="preserve"> + <value>App {0}</value> + </data> + <data name="Stats_AccountHeader" xml:space="preserve"> + <value>Account {0}</value> + </data> + <data name="Stats_PlaytimeForever" xml:space="preserve"> + <value>PLAYTIME (TOTAL)</value> + </data> + <data name="Stats_Playtime2Weeks" xml:space="preserve"> + <value>LAST 2 WEEKS</value> + </data> + <data name="Stats_LastPlayed" xml:space="preserve"> + <value>LAST PLAYED</value> + </data> + <data name="Stats_AchievementsHeader" xml:space="preserve"> + <value>Achievements</value> + </data> + <data name="Stats_StatsHeader" xml:space="preserve"> + <value>Stats</value> + </data> + <data name="Stats_Minutes" xml:space="preserve"> + <value>{0} min</value> + </data> + <data name="Stats_Hours" xml:space="preserve"> + <value>{0} h</value> + </data> + <data name="Stats_NoPlaytime" xml:space="preserve"> + <value>None</value> + </data> + <data name="Stats_Never" xml:space="preserve"> + <value>Never</value> + </data> + <data name="Stats_Locked" xml:space="preserve"> + <value>Locked</value> + </data> + <data name="Stats_StatId" xml:space="preserve"> + <value>Stat {0}</value> + </data> + <data name="Stats_AchievementBit" xml:space="preserve"> + <value>Stat {0} · bit {1}</value> + </data> + <data name="Stats_SummaryPlaytime" xml:space="preserve"> + <value>{0} played</value> + </data> + <data name="Stats_SummaryAchievements" xml:space="preserve"> + <value>{0} achievements</value> + </data> + <data name="Stats_SummaryStats" xml:space="preserve"> + <value>{0} stats</value> + </data> <data name="Nav_Settings" xml:space="preserve"> <value>Settings</value> </data> diff --git a/ui/Services/AchievementSchema.cs b/ui/Services/AchievementSchema.cs new file mode 100644 index 00000000..0009b5fe --- /dev/null +++ b/ui/Services/AchievementSchema.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; + +namespace CloudRedirect.Services; + +/// <summary> +/// Parses Steam's UserGameStatsSchema_<appId>.bin (a binary-KV tree) to map +/// each achievement (statId, bit) to its human-readable English display name. +/// +/// Read locally from appcache\stats so the UI can show real achievement names +/// immediately, without waiting for the DLL to re-import on the next launch. +/// +/// Schema tree shape: +/// <appId> (SECTION) +/// stats (SECTION) +/// <statId> (SECTION) +/// bits (SECTION) +/// <bit> (SECTION) +/// name "ACHIEVEMENT_x" (api name, fallback) +/// display (SECTION) > name (SECTION) > english "Human Name" +/// </summary> +internal static class AchievementSchema +{ + private const byte BKV_SECTION = 0x00; + private const byte BKV_STRING = 0x01; + private const byte BKV_INT = 0x02; + private const byte BKV_FLOAT = 0x03; + private const byte BKV_UINT64 = 0x07; + private const byte BKV_END = 0x08; + private const byte BKV_INT64 = 0x0A; + + private const int MaxDepth = 128; + + private sealed class Node + { + public byte Type; + public string Name = ""; + public string StrVal = ""; + public List<Node> Children = new(); + } + + /// <summary> + /// Returns (statId, bit) -> display name for the given app, or an empty map + /// if the schema is missing/unreadable. Key = ((ulong)statId << 32) | bit. + /// </summary> + public static Dictionary<ulong, string> LoadNames(uint appId) + { + var result = new Dictionary<ulong, string>(); + try + { + var steamPath = SteamDetector.FindSteamPath(); + if (steamPath == null) return result; + + var path = Path.Combine(steamPath, "appcache", "stats", + $"UserGameStatsSchema_{appId}.bin"); + if (!File.Exists(path)) return result; + + var data = File.ReadAllBytes(path); + int pos = 0; + var root = ParseSection(data, ref pos, 0); + + // Root holds one <appId> section; descend to "stats". + Node? statsSec = null; + foreach (var top in root) + { + var s = Find(top.Children, "stats"); + if (s != null) { statsSec = s; break; } + if (top.Name == "stats") { statsSec = top; break; } + } + if (statsSec == null) return result; + + foreach (var stat in statsSec.Children) + { + if (stat.Type != BKV_SECTION || !uint.TryParse(stat.Name, out var statId)) continue; + var bits = Find(stat.Children, "bits"); + if (bits == null) continue; + + foreach (var bitSec in bits.Children) + { + if (bitSec.Type != BKV_SECTION || !uint.TryParse(bitSec.Name, out var bit) || bit >= 32) + continue; + + string display = ""; + var disp = Find(bitSec.Children, "display"); + if (disp != null) + { + var nameSec = Find(disp.Children, "name"); + if (nameSec != null) + { + var eng = Find(nameSec.Children, "english"); + if (eng != null) display = eng.StrVal; + if (string.IsNullOrEmpty(display) && nameSec.Children.Count > 0) + display = nameSec.Children[0].StrVal; // first localized as fallback + } + } + if (string.IsNullOrEmpty(display)) + { + var apiName = Find(bitSec.Children, "name"); + if (apiName != null) display = apiName.StrVal; + } + + if (!string.IsNullOrEmpty(display)) + result[((ulong)statId << 32) | bit] = display; + } + } + } + catch { } + return result; + } + + private static Node? Find(List<Node> nodes, string name) + { + foreach (var n in nodes) + if (n.Name == name) return n; + return null; + } + + private static List<Node> ParseSection(byte[] data, ref int pos, int depth) + { + var nodes = new List<Node>(); + if (depth > MaxDepth) return nodes; + + while (pos < data.Length) + { + byte tag = data[pos++]; + if (tag == BKV_END) return nodes; + + var node = new Node { Type = tag, Name = ReadCString(data, ref pos) }; + + switch (tag) + { + case BKV_SECTION: + node.Children = ParseSection(data, ref pos, depth + 1); + break; + case BKV_STRING: + node.StrVal = ReadCString(data, ref pos); + break; + case BKV_INT: + case BKV_FLOAT: + pos += 4; + break; + case BKV_UINT64: + case BKV_INT64: + pos += 8; + break; + default: + return nodes; // unknown tag: stop + } + nodes.Add(node); + } + return nodes; + } + + private static string ReadCString(byte[] data, ref int pos) + { + int start = pos; + while (pos < data.Length && data[pos] != 0) pos++; + var s = Encoding.UTF8.GetString(data, start, pos - start); + if (pos < data.Length) pos++; // skip NUL + return s; + } +} diff --git a/ui/Services/CloudProviderClient.cs b/ui/Services/CloudProviderClient.cs index 2e4e70e4..bcd0901f 100644 --- a/ui/Services/CloudProviderClient.cs +++ b/ui/Services/CloudProviderClient.cs @@ -61,6 +61,74 @@ public record ListBlobsResult(IReadOnlyList<string> BlobFilenames, bool Complete /// </summary> public record DeleteBlobsResult(int Deleted, int Failed, IReadOnlyList<string> FailedFilenames, string? Error); + /// <summary> + /// Result of downloading a single blob's content. Found=false when the blob + /// does not exist; Content is the raw (decoded) text on success. + /// </summary> + public record DownloadBlobResult(bool Found, string? Content, string? Error); + + /// <summary>One app's stats.json content as stored in the cloud.</summary> + public record CloudStatsEntry(string AccountId, string AppId, string Content); + + /// <summary>Result of enumerating every app's stats.json in the cloud.</summary> + public record ListAllStatsResult(IReadOnlyList<CloudStatsEntry> Entries, string? Error); + + /// <summary> + /// Enumerate every app's stats.json in the cloud for the given accounts. + /// All provider/auth/enumeration logic lives in the native CLI; this just + /// shells out and parses the result. Returns empty (no error) when no cloud + /// is configured. + /// </summary> + public async Task<ListAllStatsResult> ListAllStatsAsync(CancellationToken cancel = default) + { + var config = SteamDetector.ReadConfig(); + var provider = UiCloudProviderFactory.TryResolve(config, _log); + if (provider == null) + return new ListAllStatsResult(Array.Empty<CloudStatsEntry>(), null); + + try + { + return await provider.ListAllStatsAsync(cancel); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new ListAllStatsResult(Array.Empty<CloudStatsEntry>(), ex.Message); + } + } + + /// <summary> + /// Download a single metadata blob (e.g. stats.json) from the cloud provider. + /// Returns Found=false when there is no cloud configured or the blob is absent. + /// </summary> + public async Task<DownloadBlobResult> DownloadAppBlobAsync( + string accountId, string appId, string filename, CancellationToken cancel = default) + { + if (IsUnsafeBlobName(filename)) + return new DownloadBlobResult(false, null, "Unsafe blob name"); + + var config = SteamDetector.ReadConfig(); + var provider = UiCloudProviderFactory.TryResolve(config, _log); + if (provider == null) + return new DownloadBlobResult(false, null, null); // local-only / no cloud + + try + { + return await provider.DownloadAppBlobAsync(accountId, appId, filename, cancel); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return new DownloadBlobResult(false, null, ex.Message); + } + } + /// <summary>List blob filenames under {accountId}/{appId}/blobs/. Local/no-cloud always Complete=true.</summary> public async Task<ListBlobsResult> ListAppBlobsAsync(string accountId, string appId, CancellationToken cancel = default) { diff --git a/ui/Services/EmbeddedCli.cs b/ui/Services/EmbeddedCli.cs index 9c42decf..b88d9b3c 100644 --- a/ui/Services/EmbeddedCli.cs +++ b/ui/Services/EmbeddedCli.cs @@ -21,7 +21,13 @@ internal static class EmbeddedCli if (cliStream == null || dllStream == null) return null; - string baseDir = Path.Combine(Path.GetTempPath(), "CloudRedirect", ComputeResourceHash(cliStream)); + // Hash BOTH the launcher stub and the DLL: the stub (cli_main) loads the + // DLL and runs the real CLI logic from it, so when only the DLL changes + // (e.g. a new command) the stub hash alone would not change and we'd + // reuse a temp dir with a stale DLL. Including the DLL forces a fresh + // extract whenever either resource changes. + string baseDir = Path.Combine(Path.GetTempPath(), "CloudRedirect", + ComputeResourceHash(cliStream, dllStream)); Directory.CreateDirectory(baseDir); string exePath = Path.Combine(baseDir, "cloud_redirect_cli.exe"); @@ -46,11 +52,23 @@ internal static class EmbeddedCli return exePath; } - private static string ComputeResourceHash(Stream stream) + private static string ComputeResourceHash(params Stream[] streams) { - stream.Position = 0; using var sha = System.Security.Cryptography.SHA256.Create(); - var hash = sha.ComputeHash(stream); - return Convert.ToHexString(hash).Substring(0, 16); + foreach (var stream in streams) + { + stream.Position = 0; + var bytes = new byte[stream.Length]; + int read = 0; + while (read < bytes.Length) + { + int n = stream.Read(bytes, read, bytes.Length - read); + if (n == 0) break; + read += n; + } + sha.TransformBlock(bytes, 0, read, null, 0); + } + sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0); + return Convert.ToHexString(sha.Hash!).Substring(0, 16); } } diff --git a/ui/Services/IUiCloudProvider.cs b/ui/Services/IUiCloudProvider.cs index c36b74b4..28cbdf94 100644 --- a/ui/Services/IUiCloudProvider.cs +++ b/ui/Services/IUiCloudProvider.cs @@ -22,6 +22,18 @@ internal interface IUiCloudProvider Task<CloudProviderClient.ListBlobsResult> ListAppBlobsAsync( string accountId, string appId, CancellationToken cancel); + /// <summary> + /// Download a single named metadata blob (e.g. stats.json) stored at + /// {accountId}/{appId}/{filename}. Returns Found=false if it does not exist. + /// </summary> + Task<CloudProviderClient.DownloadBlobResult> DownloadAppBlobAsync( + string accountId, string appId, string filename, CancellationToken cancel); + + /// <summary> + /// Search the cloud for every app's stats.json and return each one's content. + /// </summary> + Task<CloudProviderClient.ListAllStatsResult> ListAllStatsAsync(CancellationToken cancel); + /// <summary> /// Delete the named blobs. The facade has already filtered unsafe names /// (path separators, traversal, reserved DOS names, trailing dot/space). diff --git a/ui/Services/Providers/CliUiCloudProvider.cs b/ui/Services/Providers/CliUiCloudProvider.cs index e4f4de9f..3186a7a6 100644 --- a/ui/Services/Providers/CliUiCloudProvider.cs +++ b/ui/Services/Providers/CliUiCloudProvider.cs @@ -87,6 +87,72 @@ public CliUiCloudProvider(string provider, Action<string>? log) } } + public async Task<CloudProviderClient.DownloadBlobResult> DownloadAppBlobAsync( + string accountId, string appId, string filename, CancellationToken cancel) + { + var arg = filename.Contains(' ') ? $"\"{filename}\"" : filename; + var result = await RunCliAsync($"download-blob {_provider} {accountId} {appId} {arg}", cancel); + + if (result.ExitCode != 0) + { + var error = TryGetError(result.Output) ?? $"CLI exited with code {result.ExitCode}"; + return new CloudProviderClient.DownloadBlobResult(false, null, error); + } + + try + { + using var doc = JsonDocument.Parse(result.Output); + var root = doc.RootElement; + + bool found = root.TryGetProperty("found", out var foundProp) && foundProp.GetBoolean(); + string? error = root.TryGetProperty("error", out var errorProp) ? errorProp.GetString() : null; + string? content = root.TryGetProperty("content", out var contentProp) ? contentProp.GetString() : null; + + return new CloudProviderClient.DownloadBlobResult(found, content, error); + } + catch (JsonException ex) + { + return new CloudProviderClient.DownloadBlobResult(false, null, $"Invalid CLI response: {ex.Message}"); + } + } + + public async Task<CloudProviderClient.ListAllStatsResult> ListAllStatsAsync(CancellationToken cancel) + { + var result = await RunCliAsync($"list-all-stats {_provider}", cancel); + if (result.ExitCode != 0) + { + var error = TryGetError(result.Output) ?? $"CLI exited with code {result.ExitCode}"; + return new CloudProviderClient.ListAllStatsResult( + Array.Empty<CloudProviderClient.CloudStatsEntry>(), error); + } + + try + { + using var doc = JsonDocument.Parse(result.Output); + var root = doc.RootElement; + string? error = root.TryGetProperty("error", out var errorProp) ? errorProp.GetString() : null; + + var entries = new List<CloudProviderClient.CloudStatsEntry>(); + if (root.TryGetProperty("apps", out var appsArr) && appsArr.ValueKind == JsonValueKind.Array) + { + foreach (var item in appsArr.EnumerateArray()) + { + var acct = item.TryGetProperty("account_id", out var a) ? a.GetString() : null; + var app = item.TryGetProperty("app_id", out var p) ? p.GetString() : null; + var content = item.TryGetProperty("content", out var c) ? c.GetString() : null; + if (!string.IsNullOrEmpty(acct) && !string.IsNullOrEmpty(app) && !string.IsNullOrEmpty(content)) + entries.Add(new CloudProviderClient.CloudStatsEntry(acct!, app!, content!)); + } + } + return new CloudProviderClient.ListAllStatsResult(entries, error); + } + catch (JsonException ex) + { + return new CloudProviderClient.ListAllStatsResult( + Array.Empty<CloudProviderClient.CloudStatsEntry>(), $"Invalid CLI response: {ex.Message}"); + } + } + public async Task<CloudProviderClient.DeleteBlobsResult> DeleteAppBlobsAsync( string accountId, string appId, IReadOnlyCollection<string> blobFilenames, CancellationToken cancel) diff --git a/ui/Services/Providers/FolderUiCloudProvider.cs b/ui/Services/Providers/FolderUiCloudProvider.cs index 29c74ea6..6f2d65ca 100644 --- a/ui/Services/Providers/FolderUiCloudProvider.cs +++ b/ui/Services/Providers/FolderUiCloudProvider.cs @@ -57,6 +57,80 @@ public FolderUiCloudProvider(Action<string>? log, string syncPath) return Task.FromResult(new CloudProviderClient.ListBlobsResult(names, true, null)); } + public Task<CloudProviderClient.DownloadBlobResult> DownloadAppBlobAsync( + string accountId, string appId, string filename, CancellationToken cancel) + { + // Metadata-text blobs (stats.json) live at {accountId}/{appId}/{name}, + // not under blobs/ (those are SHA content blobs). + var appDir = Path.Combine(_syncPath, accountId, appId); + var appDirFull = Path.GetFullPath(appDir); + if (!appDirFull.EndsWith(Path.DirectorySeparatorChar) && + !appDirFull.EndsWith(Path.AltDirectorySeparatorChar)) + { + appDirFull += Path.DirectorySeparatorChar; + } + + string path; + try { path = Path.GetFullPath(Path.Combine(appDir, filename)); } + catch { return Task.FromResult(new CloudProviderClient.DownloadBlobResult(false, null, "Invalid path")); } + + if (!path.StartsWith(appDirFull, StringComparison.OrdinalIgnoreCase)) + return Task.FromResult(new CloudProviderClient.DownloadBlobResult(false, null, "Path traversal rejected")); + + if (!File.Exists(path)) + return Task.FromResult(new CloudProviderClient.DownloadBlobResult(false, null, null)); + + try + { + var content = File.ReadAllText(path); + return Task.FromResult(new CloudProviderClient.DownloadBlobResult(true, content, null)); + } + catch (Exception ex) + { + return Task.FromResult(new CloudProviderClient.DownloadBlobResult(false, null, ex.Message)); + } + } + + public Task<CloudProviderClient.ListAllStatsResult> ListAllStatsAsync(CancellationToken cancel) + { + // Folder provider has no search API; scan the sync root directly for + // {accountId}/{appId}/stats.json. Cheap on a local/network folder. + var entries = new List<CloudProviderClient.CloudStatsEntry>(); + try + { + if (!Directory.Exists(_syncPath)) + return Task.FromResult(new CloudProviderClient.ListAllStatsResult(entries, null)); + + foreach (var acctDir in Directory.GetDirectories(_syncPath)) + { + var accountId = Path.GetFileName(acctDir); + if (string.IsNullOrEmpty(accountId) || !ulong.TryParse(accountId, out _)) continue; + + foreach (var appDir in Directory.GetDirectories(acctDir)) + { + var appId = Path.GetFileName(appDir); + if (string.IsNullOrEmpty(appId) || !uint.TryParse(appId, out _)) continue; + + var statsPath = Path.Combine(appDir, "stats.json"); + if (!File.Exists(statsPath)) continue; + + try + { + var content = File.ReadAllText(statsPath); + if (!string.IsNullOrEmpty(content)) + entries.Add(new CloudProviderClient.CloudStatsEntry(accountId, appId, content)); + } + catch { } + } + } + } + catch (Exception ex) + { + return Task.FromResult(new CloudProviderClient.ListAllStatsResult(entries, ex.Message)); + } + return Task.FromResult(new CloudProviderClient.ListAllStatsResult(entries, null)); + } + public Task<CloudProviderClient.DeleteBlobsResult> DeleteAppBlobsAsync( string accountId, string appId, IReadOnlyCollection<string> blobFilenames, CancellationToken cancel) From 3156ec9134b5383640c23894df0d84d63bb151a1 Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Sun, 7 Jun 2026 20:40:40 -0400 Subject: [PATCH 11/24] Import native achievements and fetch missing schemas --- src/common/metadata_sync.cpp | 5 + src/common/metadata_sync.h | 12 + src/common/stats_store.cpp | 108 ++++++ src/common/stats_store.h | 7 + src/platform/win/cloud_intercept.cpp | 517 ++++++++++++++++++++++++++- 5 files changed, 641 insertions(+), 8 deletions(-) diff --git a/src/common/metadata_sync.cpp b/src/common/metadata_sync.cpp index 554cb78c..20745307 100644 --- a/src/common/metadata_sync.cpp +++ b/src/common/metadata_sync.cpp @@ -4,5 +4,10 @@ namespace MetadataSync { std::atomic<bool> steamToolsPresent{false}; std::atomic<bool> syncLuas{false}; +// Default ON: stats/playtime sync is the expected behavior; the user opts out. +std::atomic<bool> syncAchievements{true}; +std::atomic<bool> syncPlaytime{true}; +// Default OFF: experimental opt-in feature. +std::atomic<bool> schemaFetch{false}; } diff --git a/src/common/metadata_sync.h b/src/common/metadata_sync.h index e4c60a48..d9cadd4b 100644 --- a/src/common/metadata_sync.h +++ b/src/common/metadata_sync.h @@ -7,6 +7,18 @@ namespace MetadataSync { extern std::atomic<bool> steamToolsPresent; extern std::atomic<bool> syncLuas; +// Native stats/playtime sync gates (config: sync_achievements / sync_playtime). +// When false, the corresponding native path does NOT interfere at all: stats +// pass straight through to Steam's real server (no import/synthesize), and +// playtime is neither tracked nor merged. Default true (sync enabled). +extern std::atomic<bool> syncAchievements; +extern std::atomic<bool> syncPlaytime; + +// Experimental: proactively fetch missing achievement/stats schemas from the CM +// (config: experimental_schema_fetch). When false, no schema requests are sent. +// Default false (opt-in experimental feature). +extern std::atomic<bool> schemaFetch; + inline bool IsEnabled() { return steamToolsPresent.load(std::memory_order_relaxed) && syncLuas.load(std::memory_order_relaxed); diff --git a/src/common/stats_store.cpp b/src/common/stats_store.cpp index 2cc85376..5d08a43c 100644 --- a/src/common/stats_store.cpp +++ b/src/common/stats_store.cpp @@ -29,6 +29,8 @@ static CloudPushFn g_cloudPush; // Resolves the current Steam accountId for locating native UserGameStats blobs. static AccountIdProvider g_accountIdProvider; +// Fired when an import finds no schema for an app (platform requests it). +static SchemaMissingCallback g_schemaMissingCb; void SetCloudProvider(CloudPullFn pull, CloudPushFn push) { std::lock_guard<std::mutex> lock(g_mutex); @@ -41,6 +43,11 @@ void SetAccountIdProvider(AccountIdProvider provider) { g_accountIdProvider = std::move(provider); } +void SetSchemaMissingCallback(SchemaMissingCallback cb) { + std::lock_guard<std::mutex> lock(g_mutex); + g_schemaMissingCb = std::move(cb); +} + // ── Native UserGameStats (BKV) reader ──────────────────────────────────── // Steam stores per-user stats as a binary-KV tree in // appcache\stats\UserGameStats_<accountId>_<appId>.bin @@ -154,6 +161,68 @@ uint32_t BkvDataAsU32(const BkvNode& dataNode) { } } +// Build a (statId,bit) -> human-readable achievement name map from the parsed +// schema BKV tree. Schema shape: +// <appId> (SECTION) +// stats (SECTION) +// <statId> (SECTION) +// bits (SECTION) +// <bit> (SECTION) +// name "ACHIEVEMENT_x" (api name, fallback) +// display (SECTION) > name (SECTION) > english "Human Name" +// Prefers the English display name; falls back to the api name. +std::unordered_map<uint64_t, std::string> +ParseSchemaAchievementNames(const std::vector<BkvNode>& schemaRoot) { + std::unordered_map<uint64_t, std::string> names; + + // Root is a single <appId> section; descend to "stats". + const BkvNode* statsSec = nullptr; + for (const auto& top : schemaRoot) { + if (top.type != BKV_SECTION) continue; + if (auto* s = BkvFind(top.children, "stats")) { statsSec = s; break; } + if (top.name == "stats") { statsSec = ⊤ break; } + } + if (!statsSec) return names; + + for (const auto& stat : statsSec->children) { + if (stat.type != BKV_SECTION) continue; + bool numeric = !stat.name.empty(); + for (char c : stat.name) { if (c < '0' || c > '9') { numeric = false; break; } } + if (!numeric) continue; + uint32_t statId = (uint32_t)strtoul(stat.name.c_str(), nullptr, 10); + + const BkvNode* bits = BkvFind(stat.children, "bits"); + if (!bits) continue; + + for (const auto& bitSec : bits->children) { + if (bitSec.type != BKV_SECTION) continue; + bool bnum = !bitSec.name.empty(); + for (char c : bitSec.name) { if (c < '0' || c > '9') { bnum = false; break; } } + if (!bnum) continue; + uint32_t bit = (uint32_t)strtoul(bitSec.name.c_str(), nullptr, 10); + if (bit >= 32) continue; + + std::string display; + if (const BkvNode* disp = BkvFind(bitSec.children, "display")) { + if (const BkvNode* nameSec = BkvFind(disp->children, "name")) { + if (const BkvNode* eng = BkvFind(nameSec->children, "english")) + display = eng->strVal; + // Fall back to the first localized string if no english. + if (display.empty() && !nameSec->children.empty()) + display = nameSec->children.front().strVal; + } + } + if (display.empty()) { + if (const BkvNode* apiName = BkvFind(bitSec.children, "name")) + display = apiName->strVal; + } + if (!display.empty()) + names[((uint64_t)statId << 32) | bit] = display; + } + } + return names; +} + } // namespace // Active play sessions: appId -> session start (unix time) @@ -320,6 +389,10 @@ static bool ImportNativeStats(uint32_t appId, AppStats& out) { out.schema.assign(std::istreambuf_iterator<char>(sf), std::istreambuf_iterator<char>()); } + // No schema on disk -> ask the platform to fetch it from Steam's server + // (so achievement names become available on the next import). + if (out.schema.empty() && g_schemaMissingCb) + g_schemaMissingCb(appId); } // Stats: appcache\stats\UserGameStats_<accountId>_<appId>.bin @@ -342,6 +415,15 @@ static bool ImportNativeStats(uint32_t appId, AppStats& out) { const BkvNode* cache = BkvFind(root, "cache"); if (!cache) return false; + // Parse the schema (if present) for human-readable achievement names. + std::unordered_map<uint64_t, std::string> achNames; + if (!out.schema.empty()) { + size_t spos = 0, snodes = 0; + std::vector<BkvNode> sroot; + if (BkvRead(out.schema.data(), out.schema.size(), spos, sroot, 0, snodes)) + achNames = ParseSchemaAchievementNames(sroot); + } + size_t importedStats = 0, importedAch = 0; for (const auto& stat : cache->children) { if (stat.type != BKV_SECTION) continue; // skip crc / PendingChanges @@ -371,6 +453,14 @@ static bool ImportNativeStats(uint32_t appId, AppStats& out) { uint32_t bit = (uint32_t)strtoul(bitNode.name.c_str(), nullptr, 10); if (bit < 32) blk.unlockTimes[bit] = bitNode.intVal; } + // Attach human-readable names from the schema (for all 32 bits that + // have one, not just unlocked -- the UI may show locked ones too). + if (!achNames.empty()) { + for (uint32_t bit = 0; bit < 32; bit++) { + auto it = achNames.find(((uint64_t)statId << 32) | bit); + if (it != achNames.end()) blk.names[bit] = it->second; + } + } out.achievements.push_back(blk); ++importedAch; } @@ -416,6 +506,11 @@ static bool ParseAppStatsJson(const std::string& content, AppStats& out) { blk.unlockTimes[j] = (uint32_t)times[j].integer(); } } + const auto& names = item["names"]; + if (names.type == Json::Type::Array) { + for (size_t j = 0; j < names.size() && j < 32; j++) + blk.names[j] = names[j].str(); + } out.achievements.push_back(blk); } } @@ -457,6 +552,15 @@ static std::string BuildAppStatsJson(const AppStats& stats) { times.arrVal.push_back(Json::Number(a.unlockTimes[i])); } item.objVal["unlock_times"] = std::move(times); + // Human-readable per-bit names from the schema (may be empty strings). + bool anyName = false; + for (int i = 0; i < 32; i++) if (!a.names[i].empty()) { anyName = true; break; } + if (anyName) { + Json::Value namesArr = Json::Array(); + for (int i = 0; i < 32; i++) + namesArr.arrVal.push_back(Json::String(a.names[i])); + item.objVal["names"] = std::move(namesArr); + } achArr.arrVal.push_back(std::move(item)); } root.objVal["achievements"] = std::move(achArr); @@ -709,6 +813,10 @@ void StartSession(uint32_t appId) { if (stats.playtime.lastPlayedTime == 0) { LoadAppStats(appId, stats); } + // Seed achievements/stats from Steam's native blob too -- not every app gets + // a GetUserStats RPC, so launching the game is our reliable trigger to import + // (and then cloud-sync) the real stat/achievement data, not just playtime. + EnsureNativeImportLocked(appId, stats); stats.playtime.lastPlayedTime = NowUnix(); g_dirty[appId] = true; LOG("[Stats] Session started for app %u", appId); diff --git a/src/common/stats_store.h b/src/common/stats_store.h index 3f19f298..90c89553 100644 --- a/src/common/stats_store.h +++ b/src/common/stats_store.h @@ -31,6 +31,7 @@ struct AchievementBlock { uint32_t statId; // the achievement stat ID (type 4) uint32_t bits; // bitmask of unlocked achievements uint32_t unlockTimes[32]; // per-bit unlock timestamps + std::string names[32]; // per-bit human-readable display name (from schema) }; struct PlaytimeData { @@ -61,6 +62,12 @@ void Init(const std::string& storageRoot, const std::string& steamPath); using AccountIdProvider = std::function<uint32_t()>; void SetAccountIdProvider(AccountIdProvider provider); +// Install a callback invoked when a native import finds NO achievement schema +// for an app (UserGameStatsSchema_<appId>.bin missing). The platform layer uses +// this to request the schema from Steam's server. Fire-and-forget. +using SchemaMissingCallback = std::function<void(uint32_t appId)>; +void SetSchemaMissingCallback(SchemaMissingCallback cb); + // Load stats for an app. Returns true if data exists on disk. bool LoadAppStats(uint32_t appId, AppStats& out); diff --git a/src/platform/win/cloud_intercept.cpp b/src/platform/win/cloud_intercept.cpp index b60a7698..b08e582d 100644 --- a/src/platform/win/cloud_intercept.cpp +++ b/src/platform/win/cloud_intercept.cpp @@ -52,6 +52,7 @@ static constexpr uint32_t PROTO_FLAG = 0x80000000; static constexpr uint32_t EMSG_MASK = 0x7FFFFFFF; static constexpr uint32_t EMSG_SERVICE_METHOD = 151; static constexpr uint32_t EMSG_SERVICE_METHOD_RESP = 147; +static constexpr uint32_t EMSG_CLIENT_GET_USER_STATS_RESP = 819; // schema-fetch response static constexpr uint64_t JOBID_NONE = 0xFFFFFFFFFFFFFFFFULL; static constexpr uint32_t HDR_STEAMID = 1; @@ -100,9 +101,29 @@ static constexpr size_t SC_BDD_STOLEN_BYTES = 14; // first 14 bytes of prologue static constexpr uintptr_t SC_RVA_BASYNC_SEND = 0xCF0DF0; static constexpr size_t SC_BAS_STOLEN_BYTES = 15; // 5+5+1+4 bytes of prologue // CProtoBufMsg layout offsets +static constexpr uint32_t CPROTOBUFMSG_OFF_DESC = 0x08; // typed-body descriptor vtable* +static constexpr uint32_t CPROTOBUFMSG_OFF_CONN = 0x1C; // uint32_t connection handle static constexpr uint32_t CPROTOBUFMSG_OFF_EMSG = 0x20; // uint32_t EMsg | PROTO_FLAG static constexpr uint32_t CPROTOBUFMSG_OFF_BODY = 0x30; // protobuf body object* +// Schema-fetch injection: build a CMsgClientGetUserStats (EMsg 818) and send it +// via BAsyncSend, asking the server for the latest achievement schema of any app +// (schema_local_version=-1) on behalf of an owning SteamID (steam_id_for_user). +// The server's 819 response is handled by Steam itself, which writes +// appcache\stats\UserGameStatsSchema_<appid>.bin -- we just trigger the request. +// (IDA-verified against steamclient64: ctor sub_138CF07F0, finalize sub_138CF3390, +// cleanup sub_138CF0AA0, body descriptor off_1396E4460 @ RVA 0x16E4460.) +static constexpr uintptr_t SC_RVA_PBMSG_CTOR = 0xCF07F0; // CProtoBufMsgBase::ctor(this, emsg, 0) +static constexpr uintptr_t SC_RVA_PBMSG_FINALIZE = 0xCF3390; // allocate typed body +static constexpr uintptr_t SC_RVA_PBMSG_CLEANUP = 0xCF0AA0; // destroy msg +static constexpr uintptr_t SC_RVA_GETUSERSTATS_DESC = 0x16E4460; // CMsgClientGetUserStats body descriptor +// Typed vtable for CProtoBufMsg<CMsgClientGetUserStats> (??_7?$CProtoBufMsg@VCMsgClientGetUserStats@@@@6B@). +// MUST be installed at msg[0] after the base ctor: BAsyncSend dispatches GetSize +// (vtbl+24) and Serialize (vtbl+32) through it. Leaving the base vftable there +// serializes the message wrong -> pipes.cpp:881 BWrite failed -> client crash. +static constexpr uintptr_t SC_RVA_GETUSERSTATS_VFTABLE = 0x13368C8; +static constexpr uint32_t EMSG_CLIENT_GET_USER_STATS = 818; + // steamclient64.dll RVAs for CCMInterface discovery // IDA image base: 0x138000000 // qword_1397A70E8 = global CSteamEngine* pointer @@ -342,6 +363,35 @@ static NotificationSlot8Fn g_originalSlot8 = nullptr; // saved original sl static ParseFromArrayFn g_parseFromArray = nullptr; // sub_138BD0210 static SerializeToArrayFn g_serializeToArray = nullptr; // sub_138BD07E0 static std::atomic<bool> g_vtableHookInstalled{false}; + +// Schema-fetch injection primitives (resolved at hook install from steamclient64). +using PbMsgCtorFn = void*(__fastcall*)(void* self, int emsg, int unk); +using PbMsgFinalizeFn = void(__fastcall*)(void* self); +using PbMsgCleanupFn = void(__fastcall*)(void* self); +static PbMsgCtorFn g_pbMsgCtor = nullptr; +static PbMsgFinalizeFn g_pbMsgFinalize = nullptr; +static PbMsgCleanupFn g_pbMsgCleanup = nullptr; +static void* g_getUserStatsDesc = nullptr; // off_1396E4460 (resolved) +static void* g_getUserStatsVtbl = nullptr; // typed CProtoBufMsg vftable (resolved) +// A live connection handle captured from a real BAsyncSend call -- reused to send +// our injected schema requests on the same CM connection. +static std::atomic<uint32_t> g_liveConnHandle{0}; +// The CM connection handle captured from Steam's own GetUserStats (EMsg 818) -- +// the connection that receives 819 replies. Preferred over g_liveConnHandle for +// our injected schema requests. +static std::atomic<uint32_t> g_statsConnHandle{0}; +// Captured from Steam's own GetUserStats header (CMsgProtoBufHeader). The CM +// server drops any GetUserStats whose header lacks steamid + client_sessionid, +// so we copy these from Steam's live session onto our injected requests. +// Header field offsets (mapped from the live header object @ msg+0x28): +// +16 presence bitmask (bits: 0x40 steamid, 0x80 client_sessionid, +// 0x8000000 jobid_source, 0x80000000 realm) +// +104 steamid (fixed64), +112 client_sessionid (int32), +// +156 realm (uint32), +208 jobid_source (fixed64) +static std::atomic<uint64_t> g_hdrSteamId{0}; +static std::atomic<uint32_t> g_hdrSessionId{0}; +static std::atomic<uint32_t> g_hdrRealm{0}; +static std::atomic<bool> g_hdrCaptured{false}; static uintptr_t g_serviceTransportVtableEa = 0; // resolved via RTTI at install; 0 = unresolved, fall back to RVA // CClientUnifiedServiceTransport vtable slot offsets (stable interface contract). @@ -376,6 +426,7 @@ static bool HasNamespaceApps() { } uint32_t GetAccountId(); // defined later +void RequestSchemaForApp(uint32_t appId); // defined later (schema auto-fetch) // "Mark as private" support: Steam stores per-user private appIds as a JSON array // under PrivateApps_<accountId> in localconfig.vdf. We honor it so the friends @@ -745,6 +796,60 @@ static thread_local bool t_drainingInjectQueue = false; static void ProcessQueuedInjection(QueuedInjection* ctx); // defined below +// ── Schema-request queue ────────────────────────────────────────────────── +// Outbound GetUserStats schema requests MUST be sent on Steam's network thread: +// BAsyncSend touches per-thread pipe/coroutine TLS, and calling it from an +// arbitrary background thread corrupts steamclient pipe state (observed crash: +// steamclient.cpp:857 "bufRet.TellPut() == sizeof(uint8)" assert in the IPC +// release path, taking down steamwebhelper). So the sweep only ENQUEUES +// (appId, owner) pairs; we drain a few per net-thread tick for gentle pacing. +struct SchemaSendItem { uint32_t appId; uint64_t owner; }; +static std::queue<SchemaSendItem> g_schemaSendQueue; +static std::mutex g_schemaSendMutex; +static bool SendSchemaRequest(uint32_t appId, uint64_t ownerId, uint32_t connHandle); // fwd + +// Compile-time kill-switch: set to 0 to completely disable proactive schema +// fetching (leaves the rest of the DLL intact). Kept as an emergency guard. +#define SCHEMA_FETCH_ENABLED 1 + +// Reentrancy guard: SendSchemaRequest -> BAsyncSend sends a packet, which +// re-enters BAsyncSendHook / OnSendPkt -> DrainSchemaQueueOnNetThread again. +// Without this guard the drain recurses on itself before the first BAsyncSend +// returns, blowing the stack / corrupting the pipe and crashing the client. +static thread_local bool t_drainingSchemaQueue = false; + +// Drain a small batch of queued schema requests on the calling network thread. +// Called from the recv/send hooks (which already run on the net thread). +static void DrainSchemaQueueOnNetThread() { +#if !SCHEMA_FETCH_ENABLED + return; +#endif + if (!MetadataSync::schemaFetch.load(std::memory_order_relaxed)) return; + if (t_drainingSchemaQueue) return; // prevent reentrancy via BAsyncSend + if (g_shuttingDown.load(std::memory_order_acquire)) return; + // Need the captured session header (steamid/sessionid) before any send, or + // the server drops the request. + if (!g_hdrCaptured.load(std::memory_order_relaxed)) return; + // Prefer the CM handle captured from Steam's own GetUserStats; fall back to + // the generic live handle only if we never observed an 818. + uint32_t conn = g_statsConnHandle.load(std::memory_order_relaxed); + if (conn == 0) conn = g_liveConnHandle.load(std::memory_order_relaxed); + if (conn == 0) return; + t_drainingSchemaQueue = true; + constexpr int kMaxPerTick = 2; // gentle: a couple of sends per net tick + for (int i = 0; i < kMaxPerTick; ++i) { + SchemaSendItem item; + { + std::lock_guard<std::mutex> lock(g_schemaSendMutex); + if (g_schemaSendQueue.empty()) break; + item = g_schemaSendQueue.front(); + g_schemaSendQueue.pop(); + } + SendSchemaRequest(item.appId, item.owner, conn); + } + t_drainingSchemaQueue = false; +} + // Drain the inject queue on the calling network thread. Safe to call from OnSendPkt // or RecvPktMonitorHook. Caller must already be on the network thread. static void DrainInjectQueueOnNetThread() { @@ -1198,7 +1303,10 @@ static bool __fastcall ServiceMethodDirectHook(void* thisptr, const char* method // Native Player.GetUserStats#1 (per IDA, this lands on slot 4 / vtable+32). // Bodies here are RAW protobuf objects (no CProtoBufMsg +48 wrapper). - if (strcmp(methodName, StatsHandlers::RPC_GET_USER_STATS) == 0) { + // Gated by sync_achievements: when off, do not interfere -- pass straight + // through to Steam's real server. + if (strcmp(methodName, StatsHandlers::RPC_GET_USER_STATS) == 0 + && MetadataSync::syncAchievements.load(std::memory_order_relaxed)) { if (requestBody && responseBody && g_serializeToArray) { auto reqBytes = SerializeBodyToBytes(requestBody); auto reqFields = PB::Parse(reqBytes.data(), reqBytes.size()); @@ -1348,7 +1456,8 @@ static bool __fastcall ServiceMethodHook(void* thisptr, const char* methodName, // Player.ClientGetLastPlayedTimes#1 (account-wide): let the real server reply, // then APPEND our namespace apps' playtime so Steam shows it. Real owned games // keep their server playtime (the client merges per-appid). See IDA notes. - if (strcmp(methodName, StatsHandlers::RPC_GET_USER_STATS) == 0) { + if (strcmp(methodName, StatsHandlers::RPC_GET_USER_STATS) == 0 + && MetadataSync::syncAchievements.load(std::memory_order_relaxed)) { if (request && response) { void* reqBody = *(void**)((uintptr_t)request + 48); if (reqBody) { @@ -1378,7 +1487,8 @@ static bool __fastcall ServiceMethodHook(void* thisptr, const char* methodName, return g_originalSlot5(thisptr, methodName, request, response, flags); } - if (strcmp(methodName, StatsHandlers::RPC_GET_LAST_PLAYED) == 0) { + if (strcmp(methodName, StatsHandlers::RPC_GET_LAST_PLAYED) == 0 + && MetadataSync::syncPlaytime.load(std::memory_order_relaxed)) { bool result = g_originalSlot5(thisptr, methodName, request, response, flags); LOG("[Stats] slot5 GetLastPlayedTimes seen: serverResult=%d", result ? 1 : 0); if (result && response) { @@ -1997,6 +2107,27 @@ static void InstallServiceMethodHookLocked() { LOG("[VtHook] ParseFromArray=%p SerializeToArray=%p", g_parseFromArray, g_serializeToArray); + // Resolve schema-fetch injection primitives (best-effort; if a prologue + // check fails we just disable schema-fetch, leaving the rest intact). + { + auto ctor = (PbMsgCtorFn)(g_steamClientBase + SC_RVA_PBMSG_CTOR); + auto finalize = (PbMsgFinalizeFn)(g_steamClientBase + SC_RVA_PBMSG_FINALIZE); + auto cleanup = (PbMsgCleanupFn)(g_steamClientBase + SC_RVA_PBMSG_CLEANUP); + if (LooksLikeFunctionPrologue(reinterpret_cast<const uint8_t*>(ctor)) && + LooksLikeFunctionPrologue(reinterpret_cast<const uint8_t*>(finalize)) && + LooksLikeFunctionPrologue(reinterpret_cast<const uint8_t*>(cleanup))) { + g_pbMsgCtor = ctor; + g_pbMsgFinalize = finalize; + g_pbMsgCleanup = cleanup; + g_getUserStatsDesc = (void*)(g_steamClientBase + SC_RVA_GETUSERSTATS_DESC); + g_getUserStatsVtbl = (void*)(g_steamClientBase + SC_RVA_GETUSERSTATS_VFTABLE); + LOG("[Schema] Fetch primitives resolved (ctor=%p desc=%p vtbl=%p)", + ctor, g_getUserStatsDesc, g_getUserStatsVtbl); + } else { + LOG("[Schema] Fetch primitives failed prologue check -- schema auto-fetch disabled"); + } + } + // Prefer RTTI walk (build-update-tolerant); fall back to hardcoded RVA if RTTI fails. Validate slot 0 either way. // Resolution stays local; cache to g_serviceTransportVtableEa only on full-install success below. // Reject a cached EA that doesn't belong to the current steamclient image: the @@ -2280,6 +2411,8 @@ static __int64 __fastcall BuildDepotDependencyHook(__int64* a1, unsigned int a2, return result; } +static bool TryHandleSchemaResponse(const uint8_t* data, uint32_t size); // fwd decl + // RecvPkt monitor hook (logging + Approach D injection drain) static int64_t __fastcall RecvPktMonitorHook(void* thisptr, CNetPacket* pkt) { HookGuard guard; @@ -2287,6 +2420,7 @@ static int64_t __fastcall RecvPktMonitorHook(void* thisptr, CNetPacket* pkt) { return g_originalRecvPkt(thisptr, pkt); // Drain on the network-recv thread (valid Coroutine_Continue TLS). DrainInjectQueueOnNetThread(); + DrainSchemaQueueOnNetThread(); // schema sends must run on the net thread if (!pkt || !pkt->pubData || pkt->cubData < 8) return g_originalRecvPkt(thisptr, pkt); @@ -2295,6 +2429,12 @@ static int64_t __fastcall RecvPktMonitorHook(void* thisptr, CNetPacket* pkt) { memcpy(&emsgRaw, pkt->pubData, 4); uint32_t emsg = emsgRaw & EMSG_MASK; + // Capture our injected schema-fetch responses (legacy EMsg 819). + if (emsg == EMSG_CLIENT_GET_USER_STATS_RESP) { + TryHandleSchemaResponse(pkt->pubData, pkt->cubData); + return g_originalRecvPkt(thisptr, pkt); // let Steam process it too + } + if (emsg != EMSG_SERVICE_METHOD_RESP) return g_originalRecvPkt(thisptr, pkt); @@ -3833,6 +3973,19 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa if (cfg["sync_luas"].type == Json::Type::Bool) MetadataSync::syncLuas = cfg["sync_luas"].boolean(); } + // Native stats/playtime sync gates. Absent -> keep default (ON). When + // off, the matching native path does not interfere with Steam at all. + if (cfg["sync_achievements"].type == Json::Type::Bool) + MetadataSync::syncAchievements = cfg["sync_achievements"].boolean(); + if (cfg["sync_playtime"].type == Json::Type::Bool) + MetadataSync::syncPlaytime = cfg["sync_playtime"].boolean(); + // Experimental: proactive schema fetch (opt-in, default off). + if (cfg["experimental_schema_fetch"].type == Json::Type::Bool) + MetadataSync::schemaFetch = cfg["experimental_schema_fetch"].boolean(); + LOG("[Stats] Sync gates: achievements=%d, playtime=%d, schemaFetch=%d", + MetadataSync::syncAchievements.load() ? 1 : 0, + MetadataSync::syncPlaytime.load() ? 1 : 0, + MetadataSync::schemaFetch.load() ? 1 : 0); if (!cloudSaveOnly) { if (cfg["parental_bypass_playtime"].type == Json::Type::Bool) g_parentalBypassPlaytime = cfg["parental_bypass_playtime"].boolean(); @@ -3894,6 +4047,12 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa // Resolve current accountId lazily so the store can import Steam's native // UserGameStats blobs (appcache\stats\UserGameStats_<accountId>_<appId>.bin). StatsStore::SetAccountIdProvider([]() -> uint32_t { return GetAccountId(); }); + // When an import finds no achievement schema, fetch it from Steam's server. + StatsStore::SetSchemaMissingCallback([](uint32_t appId) { + // Run off-thread: this is called under the store mutex during import, + // and the fetch sends network messages + sleeps. + std::thread([appId] { RequestSchemaForApp(appId); }).detach(); + }); StatsStore::Init(cloudRoot, g_steamPath); StatsHandlers::Init(); @@ -4075,7 +4234,68 @@ static void RewriteGamesPlayedBody(void* bodyObj) { ParseBytesToBody(bodyObj, newBody.data(), newBody.size()); } +static void SweepNamespaceSchemas(); // fwd decl + +// Schedule the proactive schema sweep ONCE, on a delay, so it runs only after +// Steam's startup/login has settled (injecting during the startup RPC burst +// hangs the client on the spinning logo). The delay thread waits, then calls +// the sweep, which enqueues requests drained gently on the net thread. +static std::atomic<bool> g_schemaSweepScheduled{false}; +static void MaybeScheduleSchemaSweep() { + // Experimental opt-in: only fetch schemas when the user enabled it. + if (!MetadataSync::schemaFetch.load(std::memory_order_relaxed)) return; + if (g_schemaSweepScheduled.exchange(true)) return; // once per session + std::thread([] { + constexpr int kStartupSettleMs = 90000; // wait for startup to finish + for (int waited = 0; waited < kStartupSettleMs; waited += 500) { + if (g_shuttingDown.load(std::memory_order_acquire)) return; + Sleep(500); + } + if (g_shuttingDown.load(std::memory_order_acquire)) return; + SweepNamespaceSchemas(); + }).detach(); +} + static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { + // Capture a live connection handle for our injected schema-fetch requests. + // Do NOT kick the schema sweep here: this fires during Steam's login/startup + // RPC burst, and injecting our requests then stalls the client (spinning-logo + // hang). The sweep is started on a delay timer (see SweepNamespaceSchemas / + // the deferred-start thread below) once startup has settled. + if (connHandle && pMsg) { + // Capture the connection handle, preferring the one Steam itself uses for + // GetUserStats (EMsg 818) -- that is the CM connection that receives 819 + // replies. A handle grabbed from an unrelated send may be a different + // connection, so the server's reply would not route to where we listen. + uint32_t hookEmsg = *(uint32_t*)((uintptr_t)pMsg + CPROTOBUFMSG_OFF_EMSG) & EMSG_MASK; + if (hookEmsg == EMSG_CLIENT_GET_USER_STATS) { + if (g_statsConnHandle.exchange(connHandle, std::memory_order_relaxed) != connHandle) + LOG("[Schema] captured CM conn=%u from Steam's own GetUserStats", connHandle); + // Capture Steam's own header session fields (steamid, client_sessionid, + // realm) so we can stamp them onto our injected requests -- the CM + // server drops a GetUserStats whose header lacks these. + if (!g_hdrCaptured.load(std::memory_order_relaxed)) { + void* ownHdr = *(void**)((uintptr_t)pMsg + 0x28); + if (ownHdr) { + uint8_t* hb = (uint8_t*)ownHdr; + uint64_t sid = *(uint64_t*)(hb + 104); + uint32_t ses = *(uint32_t*)(hb + 112); + uint32_t rlm = *(uint32_t*)(hb + 156); + if (sid != 0) { + g_hdrSteamId.store(sid, std::memory_order_relaxed); + g_hdrSessionId.store(ses, std::memory_order_relaxed); + g_hdrRealm.store(rlm, std::memory_order_relaxed); + g_hdrCaptured.store(true, std::memory_order_relaxed); + LOG("[Schema] captured header session: steamid=0x%llX sessionid=%u realm=%u", + (unsigned long long)sid, ses, rlm); + } + } + } + } + // Still keep a generic fallback handle if we never see an 818. + g_liveConnHandle.store(connHandle, std::memory_order_relaxed); + MaybeScheduleSchemaSweep(); + } if (HasNamespaceApps() && pMsg && g_serializeToArray && g_parseFromArray) { uint32_t emsgRaw = *(uint32_t*)((uintptr_t)pMsg + CPROTOBUFMSG_OFF_EMSG); uint32_t emsg = emsgRaw & EMSG_MASK; @@ -4089,11 +4309,15 @@ static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { // Observe games-played to track native playtime sessions, then // rewrite for the non-Steam-game spoof. Observation reads only; // it starts/ends StatsStore sessions by appid. - auto observeBytes = SerializeBodyToBytes(bodyObj); - if (!observeBytes.empty()) { - LOG("[Stats] GamesPlayed observed (emsg=%u, %zu bytes) -> session tracking", - emsg, observeBytes.size()); - StatsHandlers::ObserveGamesPlayed(observeBytes.data(), observeBytes.size()); + // Playtime session tracking is gated by sync_playtime: when + // off, we do not observe games-played at all. + if (MetadataSync::syncPlaytime.load(std::memory_order_relaxed)) { + auto observeBytes = SerializeBodyToBytes(bodyObj); + if (!observeBytes.empty()) { + LOG("[Stats] GamesPlayed observed (emsg=%u, %zu bytes) -> session tracking", + emsg, observeBytes.size()); + StatsHandlers::ObserveGamesPlayed(observeBytes.data(), observeBytes.size()); + } } if (g_showNonSteamGame.load(std::memory_order_relaxed)) RewriteGamesPlayedBody(bodyObj); @@ -4104,6 +4328,282 @@ static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { return g_basOriginal(pMsg, connHandle); } +// ── Achievement-schema auto-fetch ────────────────────────────────────── +// +// When a namespace (lua) app has no UserGameStatsSchema_<appid>.bin, we ask +// Steam's server for the schema by sending CMsgClientGetUserStats (EMsg 818) +// with schema_local_version=-1 ("send latest") on behalf of a SteamID that +// OWNS the game (steam_id_for_user). The server only returns the schema to an +// owner, so we rotate through a list of public accounts that own huge +// libraries until one succeeds. Steam's own 819 response handler writes the +// schema .bin to disk -- we only trigger the request. +// (Technique verified against steamclient64 + SLScheevo / GBE_Tools.) + +// Public SteamID64s with very large libraries (subset of SLScheevo's list). +static const uint64_t kSchemaOwnerIds[] = { + 76561197978902089ull, // Terrum (https://steamcommunity.com/id/Terrum/) + 76561198028121353ull, 76561198017975643ull, 76561198001678750ull, + 76561198355953202ull, 76561197993544755ull, 76561198121643357ull, + 76561198001237877ull, 76561197979911851ull, 76561198217186687ull, + 76561198152618007ull, 76561197973009892ull, 76561198237402290ull, + 76561198213148949ull, 76561198108581917ull, 76561198037867621ull, + 76561197965319961ull, 76561197976597747ull, 76561198019712127ull, + 76561198094227663ull, 76561197969050296ull, +}; + +// Apps we've already attempted a schema fetch for this session (avoid spamming). +static std::mutex g_schemaFetchMutex; +static std::unordered_set<uint32_t> g_schemaFetchAttempted; + + + +// Build and send one CMsgClientGetUserStats for (appId, ownerId). Returns false +// if injection primitives aren't ready or send failed to dispatch. +static bool SendSchemaRequest(uint32_t appId, uint64_t ownerId, uint32_t connHandle) { + if (!g_pbMsgCtor || !g_pbMsgFinalize || !g_pbMsgCleanup || + !g_getUserStatsDesc || !g_getUserStatsVtbl || !g_parseFromArray) + return false; + + // CProtoBufMsg is ~72 bytes; over-allocate to be safe. + // Replicate Steam's exact CProtoBufMsg<CMsgClientGetUserStats> construction + // (sub_138A44F70): base ctor -> install TYPED vftable at msg[0] -> set body + // descriptor at +0x08 -> finalize. The typed vftable is mandatory: BAsyncSend + // serializes via vtbl+24/+32, and the base vftable produces a malformed wire + // message that fails the IPC pipe write (pipes.cpp:881) and crashes the client. + alignas(16) uint8_t msg[128] = {0}; + g_pbMsgCtor(msg, (int)EMSG_CLIENT_GET_USER_STATS, 0); // base ctor: emsg=818 + *(void**)(msg + 0) = g_getUserStatsVtbl; // install typed vftable + *(void**)(msg + CPROTOBUFMSG_OFF_DESC) = g_getUserStatsDesc; // typed-body descriptor + g_pbMsgFinalize(msg); // allocate body at +0x30 + + void* body = *(void**)(msg + CPROTOBUFMSG_OFF_BODY); + if (!body) { g_pbMsgCleanup(msg); return false; } + + // Populate the message header (CMsgProtoBufHeader, pointer at msg+40). Two + // requirements for the server to accept and reply to the request: + // + // 1. Expiry sentinel (+192 = -1): the serialized-size vtable method asserts + // at msgprotobuf.cpp:980 if the header's +192 is 0 ("timeout=0"). Steam's + // ExpectingReply (sub_138CF1320) writes -1 = "wait for reply, no deadline". + // Without it, BAsyncSend's size computation trips the assert and the IPC + // pipe write fails -> client crash. + // + // 2. Session fields (steamid + client_sessionid + realm + a unique + // jobid_source): the CM server SILENTLY DROPS a GetUserStats whose header + // lacks these. Steam's own send fills them via its framework; our raw + // BAsyncSend doesn't, so without this our header serializes empty and no + // 819 reply ever comes back. We copy the session values captured from + // Steam's own GetUserStats and stamp a unique jobid_source. + // + // Field offsets / presence bits mapped from the live header object: + // +104 steamid (fixed64, bit 0x40), +112 client_sessionid (int32, bit 0x80), + // +156 realm (uint32, bit 0x80000000), +208 jobid_source (fixed64, bit 0x8000000). + static std::atomic<uint64_t> s_jobIdCounter{0x5C00000000000001ull}; + if (void* hdr = *(void**)(msg + 0x28)) { + uint8_t* h = (uint8_t*)hdr; + *(int32_t*)(h + 192) = -1; // expiry = -1 (no deadline) + *(uint32_t*)(h + 16) &= ~0x4000000u; // clear "no reply expected" + if (g_hdrCaptured.load(std::memory_order_relaxed)) { + uint64_t jobId = s_jobIdCounter.fetch_add(1, std::memory_order_relaxed); + *(uint64_t*)(h + 104) = g_hdrSteamId.load(std::memory_order_relaxed); // steamid + *(uint32_t*)(h + 112) = g_hdrSessionId.load(std::memory_order_relaxed);// client_sessionid + *(uint32_t*)(h + 156) = g_hdrRealm.load(std::memory_order_relaxed); // realm + *(uint64_t*)(h + 208) = jobId; // jobid_source (unique) + *(uint32_t*)(h + 16) |= (0x40u | 0x80u | 0x8000000u | 0x80000000u); // presence bits + } + } + + // Build CMsgClientGetUserStats body: + // game_id#1 fixed64, crc_stats#2 varint, schema_local_version#3 int32(-1), steam_id_for_user#4 fixed64 + PB::Writer w; + w.WriteFixed64(1, (uint64_t)appId); // game_id (low 24 bits = appid) + w.WriteVarint(2, 0); // crc_stats = 0 + // schema_local_version = -1 (int32). Proto encodes a negative int32 as a + // sign-extended 64-bit varint (0xFFFFFFFFFFFFFFFF), NOT 0xFFFFFFFF -- the + // latter reads as version 4294967295 ("up to date"), so the server sends no + // schema. Use the full 64-bit -1 so it means "send me the latest schema". + w.WriteVarint(3, (uint64_t)(int64_t)(-1)); // schema_local_version = -1 (send latest) + w.WriteFixed64(4, ownerId); // steam_id_for_user = owner + + bool ok = ParseBytesToBody(body, w.Data().data(), w.Size()); + if (!ok) { g_pbMsgCleanup(msg); return false; } + + *(uint32_t*)(msg + CPROTOBUFMSG_OFF_CONN) = connHandle; + + // The 819 response is correlated by game_id (appid); see TryHandleSchemaResponse. + auto basend = reinterpret_cast<BAsyncSendFn>(g_basOriginal); + uint8_t sent = basend ? basend(msg, connHandle) : 0; + + g_pbMsgCleanup(msg); + return sent != 0; +} + +// Handle an incoming CMsgClientGetUserStatsResponse (EMsg 819) for one of our +// injected schema requests. Correlated by the response body's game_id (appid), +// since the framework assigns its own jobid on send. If the owning account's +// reply carries a schema, write UserGameStatsSchema_<appId>.bin (plus the +// per-user stats template). Returns true if we consumed it. +static bool TryHandleSchemaResponse(const uint8_t* data, uint32_t size) { + PacketView p; + if (!ParsePacket(data, size, p)) return false; + + // Match the response to an app we asked about via game_id (field 1, fixed64). + auto bodyFields = PB::Parse(p.bodyData, p.bodyLen); + const PB::Field* gameIdF = PB::FindField(bodyFields, 1); + if (!gameIdF) return false; + uint32_t appId = (uint32_t)(gameIdF->varintVal & 0xFFFFFF); + if (appId == 0) return false; + + { + std::lock_guard<std::mutex> lock(g_schemaFetchMutex); + if (g_schemaFetchAttempted.find(appId) == g_schemaFetchAttempted.end()) + return false; // not an app we asked about + } + + // Body = CMsgClientGetUserStatsResponse: eresult#2 int32, schema#4 bytes. + int32_t eresult = 2; + if (auto* er = PB::FindField(bodyFields, 2)) eresult = (int32_t)er->varintVal; + const PB::Field* schemaF = PB::FindField(bodyFields, 4); + + bool hasSchema = (eresult == 1 && schemaF && + schemaF->wireType == PB::LengthDelimited && schemaF->dataLen > 0); + if (!hasSchema) { + // This owner doesn't own the game (or sent no schema) -- another owner's + // reply may still land it. + return true; + } + + std::string schemaPath = g_steamPath + "appcache\\stats\\UserGameStatsSchema_" + + std::to_string(appId) + ".bin"; + // Don't overwrite if a valid schema already arrived from a faster owner. + if (GetFileAttributesA(schemaPath.c_str()) != INVALID_FILE_ATTRIBUTES) return true; + + HANDLE h = CreateFileA(schemaPath.c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h != INVALID_HANDLE_VALUE) { + DWORD written = 0; + WriteFile(h, schemaF->data, schemaF->dataLen, &written, nullptr); + CloseHandle(h); + LOG("[Schema] app %u: wrote schema (%u bytes) from server response", + appId, schemaF->dataLen); + + // Steam also needs a per-user stats file (UserGameStats_<accountid>_<appid>.bin) + // to load this app's stats -- without it the schema loads but stats reading + // fails ("failed to load file into buffer"). Write the empty/template stats + // blob (binary KV: cache{ crc=0; PendingChanges=0 }) that SLScheevo copies. + // Only create it if absent so we never clobber real achievement progress. + uint32_t acctId = GetAccountId(); + if (acctId != 0) { + static const uint8_t kUserStatsTemplate[38] = { + 0x00,0x63,0x61,0x63,0x68,0x65,0x00,0x02,0x63,0x72,0x63,0x00,0x00,0x00, + 0x00,0x00,0x02,0x50,0x65,0x6e,0x64,0x69,0x6e,0x67,0x43,0x68,0x61,0x6e, + 0x67,0x65,0x73,0x00,0x00,0x00,0x00,0x00,0x08,0x08 + }; + std::string statsPath = g_steamPath + "appcache\\stats\\UserGameStats_" + + std::to_string(acctId) + "_" + std::to_string(appId) + ".bin"; + if (GetFileAttributesA(statsPath.c_str()) == INVALID_FILE_ATTRIBUTES) { + HANDLE hs = CreateFileA(statsPath.c_str(), GENERIC_WRITE, 0, nullptr, + CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr); + if (hs != INVALID_HANDLE_VALUE) { + DWORD w2 = 0; + WriteFile(hs, kUserStatsTemplate, sizeof(kUserStatsTemplate), &w2, nullptr); + CloseHandle(hs); + LOG("[Schema] app %u: wrote per-user stats template (acct %u)", appId, acctId); + } + } + } + } else { + LOG("[Schema] app %u: failed to open %s for write (err=%u)", + appId, schemaPath.c_str(), GetLastError()); + } + return true; +} + +// Fetch the schema for one app by fanning requests across owner ids. Most owners +// don't own the game (the server then replies minimally, often with no game_id), +// so we DON'T block waiting per owner -- we fire all owners with gentle pacing and +// let whichever owning account's 819 land the .bin (handled async in the recv +// hook). The pacing (not blocking) is what keeps Steam's CM connection safe: 20 +// small messages spread over a few seconds instead of an instant burst. +void RequestSchemaForApp(uint32_t appId) { +#if !SCHEMA_FETCH_ENABLED + (void)appId; return; // kill-switch: see SCHEMA_FETCH_ENABLED +#endif + if (!MetadataSync::schemaFetch.load(std::memory_order_relaxed)) return; + if (appId == 0) return; + if (g_liveConnHandle.load(std::memory_order_relaxed) == 0) return; // no conn yet + + { + std::lock_guard<std::mutex> lock(g_schemaFetchMutex); + if (!g_schemaFetchAttempted.insert(appId).second) return; // already tried + } + + std::string schemaPath = g_steamPath + "appcache\\stats\\UserGameStatsSchema_" + + std::to_string(appId) + ".bin"; + if (GetFileAttributesA(schemaPath.c_str()) != INVALID_FILE_ATTRIBUTES) return; + + constexpr size_t kNumOwners = sizeof(kSchemaOwnerIds) / sizeof(kSchemaOwnerIds[0]); + // Enqueue one send per owner. The actual BAsyncSend happens on the network + // thread in DrainSchemaQueueOnNetThread (2 per tick), which both guarantees + // valid pipe/coroutine TLS and paces the traffic. Whichever owning account's + // 819 lands the .bin (handled async in the recv hook). + { + std::lock_guard<std::mutex> lock(g_schemaSendMutex); + for (uint64_t owner : kSchemaOwnerIds) + g_schemaSendQueue.push({appId, owner}); + } + LOG("[Schema] app %u: queued %zu schema request(s)", appId, kNumOwners); +} + +// One-time-per-session proactive sweep: request schemas for ALL namespace apps +// that lack one on disk. This covers apps the user hasn't launched yet (which +// otherwise never trigger an import, and so never request their schema). Fired +// once a live CM connection handle is captured. +static std::atomic<bool> g_schemaSweepDone{false}; +static void SweepNamespaceSchemas() { +#if !SCHEMA_FETCH_ENABLED + return; // kill-switch: see SCHEMA_FETCH_ENABLED +#endif + if (g_schemaSweepDone.exchange(true)) return; // once per session + if (g_liveConnHandle.load(std::memory_order_relaxed) == 0) { + g_schemaSweepDone.store(false); // retry on a later call + return; + } + + std::vector<uint32_t> apps; + { + std::lock_guard<std::mutex> lock(g_namespaceAppsMutex); + apps.assign(g_namespaceApps.begin(), g_namespaceApps.end()); + } + if (apps.empty()) return; + + LOG("[Schema] Proactive sweep: checking %zu namespace app(s) for missing schemas", apps.size()); + std::thread([apps] { + // Hard cap on how many apps we enqueue schema requests for per session, + // bounding the send queue (cap * owners). Sends are paced by the net-thread + // drain (2/tick), so this just limits total queued work. + constexpr int kMaxAppsPerSweep = 48; + int requested = 0; + for (uint32_t appId : apps) { + if (g_shuttingDown.load(std::memory_order_acquire)) break; + + // Skip apps already cached on disk without counting against the cap. + std::string schemaPath = g_steamPath + "appcache\\stats\\UserGameStatsSchema_" + + std::to_string(appId) + ".bin"; + if (GetFileAttributesA(schemaPath.c_str()) != INVALID_FILE_ATTRIBUTES) continue; + + if (requested >= kMaxAppsPerSweep) { + LOG("[Schema] Sweep cap reached (%d apps); remaining deferred to next session", + kMaxAppsPerSweep); + break; + } + RequestSchemaForApp(appId); // enqueues only; net thread does the sends + ++requested; + } + LOG("[Schema] Proactive sweep complete: enqueued schemas for %d app(s)", requested); + }).detach(); +} + void InstallGamesPlayedHook() { if (!HasNamespaceApps()) return; // NOTE: always install. The hook serves two purposes: (1) playtime session @@ -4329,6 +4829,7 @@ bool OnSendPkt(void* thisptr, const uint8_t* data, uint32_t size) { // can leave responses queued long enough for Steam to time the jobs out. Outbound // packets are far more frequent during the cloud-RPC bursts that fill the queue. DrainInjectQueueOnNetThread(); + DrainSchemaQueueOnNetThread(); // schema sends must run on the net thread // Try to discover the real CCMInterface via CSteamEngine global. // This also installs the vtable hook once CCMInterface is found. From 476e32308454d69730a0f890a4deceba2fb66347 Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Mon, 8 Jun 2026 01:28:33 -0400 Subject: [PATCH 12/24] Add optional pre-release version suffix (2.2.0-TEST1) --- Version.props | 4 ++++ ui/CloudRedirect.csproj | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/Version.props b/Version.props index 83a2c271..d91e9840 100644 --- a/Version.props +++ b/Version.props @@ -2,6 +2,10 @@ <PropertyGroup> <!-- Shared user-facing version (X.Y.Z) --> <ReleaseVersion>2.2.0</ReleaseVersion> + + <!-- Optional pre-release suffix (e.g. -TEST1, -beta). Empty for stable releases. + Appended to the user-facing version shown in Settings; AssemblyVersion stays numeric. --> + <ReleasePrerelease>-TEST1</ReleasePrerelease> <!-- Sync engine generation - increment on breaking protocol changes --> <CoreGeneration>1.0</CoreGeneration> diff --git a/ui/CloudRedirect.csproj b/ui/CloudRedirect.csproj index 90c33f75..86e42b3a 100644 --- a/ui/CloudRedirect.csproj +++ b/ui/CloudRedirect.csproj @@ -11,7 +11,10 @@ <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <ApplicationIcon>steam_logo3.ico</ApplicationIcon> <Version>$(ReleaseVersion)</Version> - <InformationalVersion>$(ReleaseVersion)+win.$(WindowsRevision)+core.$(CoreGeneration)</InformationalVersion> + <!-- User-facing display version (carries the optional pre-release suffix). + Read at runtime via AssemblyInformationalVersionAttribute; the build + metadata after '+' is stripped for display. --> + <InformationalVersion>$(ReleaseVersion)$(ReleasePrerelease)+win.$(WindowsRevision)+core.$(CoreGeneration)</InformationalVersion> <PublishSingleFile>true</PublishSingleFile> <SelfContained>false</SelfContained> <RuntimeIdentifier>win-x64</RuntimeIdentifier> From ac91470c01d2f77e7171b195f9edc8a29ec74890 Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:07:08 -0400 Subject: [PATCH 13/24] Add async cloud upload and per-platform playtime tracking --- src/common/cloud_storage.cpp | 14 +++ src/common/cloud_storage.h | 3 + src/common/stats_store.cpp | 207 ++++++++++++++++++++++++----------- src/common/stats_store.h | 13 +++ 4 files changed, 176 insertions(+), 61 deletions(-) diff --git a/src/common/cloud_storage.cpp b/src/common/cloud_storage.cpp index db345cec..fb033006 100644 --- a/src/common/cloud_storage.cpp +++ b/src/common/cloud_storage.cpp @@ -479,6 +479,20 @@ bool UploadCloudMetadataText(uint32_t accountId, uint32_t appId, return uploaded; } +// Queue an upload on the cloud work queue: thread-safe, unlike the sync variant +// which races other curl calls off the work thread (libcurl init isn't reentrant). +void UploadCloudMetadataTextAsync(uint32_t accountId, uint32_t appId, + const char* name, const std::string& content) { + if (!g_provider) return; + std::string path = CloudMetadataPath(accountId, appId, name); + ClearMissingMetadataPath(path); + CloudWorkQueue::WorkItem wi; + wi.type = CloudWorkQueue::WorkItem::Upload; + wi.cloudPath = std::move(path); + wi.data.assign(content.begin(), content.end()); + CloudWorkQueue::EnqueueWork(std::move(wi)); +} + void RemoveCloudMetadataIfPresent(uint32_t accountId, uint32_t appId, const char* name) { if (!g_provider || !g_provider->IsAuthenticated()) return; diff --git a/src/common/cloud_storage.h b/src/common/cloud_storage.h index 205198e1..fdd2921f 100644 --- a/src/common/cloud_storage.h +++ b/src/common/cloud_storage.h @@ -102,6 +102,9 @@ bool DownloadCloudMetadataWithLegacyFallback(uint32_t accountId, uint32_t appId, std::vector<uint8_t>& outData, bool* outUsedLegacy = nullptr); bool UploadCloudMetadataText(uint32_t accountId, uint32_t appId, const char* name, const std::string& content); +// Queued (thread-safe) variant: serializes on the cloud work queue. +void UploadCloudMetadataTextAsync(uint32_t accountId, uint32_t appId, + const char* name, const std::string& content); void RemoveCloudMetadataIfPresent(uint32_t accountId, uint32_t appId, const char* name); void RemoveLegacyCloudMetadataIfCanonicalExists(uint32_t accountId, uint32_t appId, const char* canonicalName, const char* legacyName); diff --git a/src/common/stats_store.cpp b/src/common/stats_store.cpp index 5d08a43c..1cf334a6 100644 --- a/src/common/stats_store.cpp +++ b/src/common/stats_store.cpp @@ -2,6 +2,7 @@ #include "json.h" #include "vdf.h" #include "log.h" +#include "file_util.h" #include <fstream> #include <sstream> @@ -31,6 +32,11 @@ static CloudPushFn g_cloudPush; static AccountIdProvider g_accountIdProvider; // Fired when an import finds no schema for an app (platform requests it). static SchemaMissingCallback g_schemaMissingCb; +// True for apps we manage; reconcile seeds their playtime from localconfig.vdf. +static NamespacePredicate g_isNamespaceApp; + +// Persist to disk; pushCloud=false writes locally only (used by startup reconcile). +static void WriteAppStats(uint32_t appId, const AppStats& stats, bool pushCloud); void SetCloudProvider(CloudPullFn pull, CloudPushFn push) { std::lock_guard<std::mutex> lock(g_mutex); @@ -48,6 +54,11 @@ void SetSchemaMissingCallback(SchemaMissingCallback cb) { g_schemaMissingCb = std::move(cb); } +void SetNamespacePredicate(NamespacePredicate pred) { + std::lock_guard<std::mutex> lock(g_mutex); + g_isNamespaceApp = std::move(pred); +} + // ── Native UserGameStats (BKV) reader ──────────────────────────────────── // Steam stores per-user stats as a binary-KV tree in // appcache\stats\UserGameStats_<accountId>_<appId>.bin @@ -59,7 +70,6 @@ void SetSchemaMissingCallback(SchemaMissingCallback cb) { // ├── data (INT/FLOAT/UINT64/INT64) -- stat value (achievement: bitfield) // └── AchievementTimes (SECTION) -- optional // └── <bit> (INT) -- unlock unix timestamp per bit index -// Parser mirrors the (now-removed) bkv_stats.cpp reader. namespace { enum BkvType : uint8_t { @@ -289,11 +299,19 @@ static void ReconcileLocalConfig(const std::string& cloudRoot, const std::string f.close(); if (vdf.empty()) continue; - // Find the "Apps" section: UserLocalConfigStore > Software > Valve > Steam > Apps - const char* basePath[] = {"UserLocalConfigStore", "Software", "Valve", "Steam", "Apps"}; + // Find the apps section: UserLocalConfigStore > Software > Valve > Steam > {Apps|apps}. + // Steam has shipped both casings of the leaf key across builds. + const char* appsKey = "apps"; + const char* basePath[] = {"UserLocalConfigStore", "Software", "Valve", "Steam", appsKey}; size_t appsStart = 0, appsEnd = 0; - if (!VdfUtil::FindVdfSectionRange(vdf, basePath, 5, appsStart, appsEnd)) - continue; + if (!VdfUtil::FindVdfSectionRange(vdf, basePath, 5, appsStart, appsEnd)) { + appsKey = "Apps"; + basePath[4] = appsKey; + if (!VdfUtil::FindVdfSectionRange(vdf, basePath, 5, appsStart, appsEnd)) { + LOG("[Stats] Reconcile: apps section not found in %s", lcPath.string().c_str()); + continue; + } + } // Enumerate child sections (each is an appid) VdfUtil::ForEachChildInSection(vdf, basePath, 5, [&](std::string_view name) -> bool { @@ -305,25 +323,27 @@ static void ReconcileLocalConfig(const std::string& cloudRoot, const std::string } if (appId == 0) return true; - // Only reconcile apps we already track (namespace apps with stats JSON) - if (!fs::exists(StatsPath(appId), ec)) return true; - - // Read Playtime/Playtime2wks/LastPlayed from this app's VDF section + // We only manage namespace apps. Real owned games keep their native, + // server-tracked playtime and are never reconciled or synced. + bool isNs = g_isNamespaceApp && g_isNamespaceApp(appId); + if (!isNs) return true; + LOG("[Stats] Reconcile: considering app %u (ns=1)", appId); + + // localconfig "Apps\<id>\Playtime" is the server's cross-platform total + // (sub_1389C7930 -> sub_1389CB7D0 writes it from GetLastPlayedTimes + // response field 4), not this machine's minutes -- and we write it + // ourselves by answering those RPCs. So take only LastPlayed (a display + // hint) here; per-platform fields are owned by EndSession. std::string appIdStr = std::to_string(appId); - const char* appPath[] = {"UserLocalConfigStore", "Software", "Valve", "Steam", "Apps", appIdStr.c_str()}; - uint32_t vdfPlaytime = 0, vdfPlaytime2wks = 0, vdfLastPlayed = 0; + const char* appPath[] = {"UserLocalConfigStore", "Software", "Valve", "Steam", appsKey, appIdStr.c_str()}; + uint32_t vdfLastPlayed = 0; VdfUtil::ForEachFieldInSection(vdf, appPath, 6, [&](const VdfUtil::FieldInfo& fi) -> bool { - if (fi.key == "Playtime") - try { vdfPlaytime = (uint32_t)std::stoul(std::string(fi.value)); } catch (...) {} - else if (fi.key == "Playtime2wks") - try { vdfPlaytime2wks = (uint32_t)std::stoul(std::string(fi.value)); } catch (...) {} - else if (fi.key == "LastPlayed") + if (fi.key == "LastPlayed") try { vdfLastPlayed = (uint32_t)std::stoul(std::string(fi.value)); } catch (...) {} return true; }); - - if (vdfPlaytime == 0) return true; + if (vdfLastPlayed == 0) return true; auto cacheIt = g_cache.find(appId); if (cacheIt == g_cache.end()) { @@ -332,25 +352,16 @@ static void ReconcileLocalConfig(const std::string& cloudRoot, const std::string } AppStats& stats = cacheIt->second; - if (stats.playtime.minutesForever >= vdfPlaytime) return true; + if (vdfLastPlayed <= stats.playtime.lastPlayedTime) return true; + stats.playtime.lastPlayedTime = vdfLastPlayed; - // Local has more playtime -- update - uint32_t delta = vdfPlaytime - stats.playtime.minutesForever; - stats.playtime.minutesForever = vdfPlaytime; - stats.playtime.minutesLastTwoWeeks = (std::max)(stats.playtime.minutesLastTwoWeeks, vdfPlaytime2wks); - if (vdfLastPlayed > stats.playtime.lastPlayedTime) - stats.playtime.lastPlayedTime = vdfLastPlayed; -#ifdef _WIN32 - stats.playtime.playtimeWindows += delta; -#elif defined(__APPLE__) - stats.playtime.playtimeMac += delta; -#else - stats.playtime.playtimeLinux += delta; -#endif - SaveAppStats(appId, stats); + // Local-only persist: startup reconcile must not push to the cloud. + WriteAppStats(appId, stats, false); reconciled++; - LOG("[Stats] Reconciled app %u from localconfig: %u -> %u min (+%u)", - appId, vdfPlaytime - delta, vdfPlaytime, delta); + LOG("[Stats] Reconciled app %u lastPlayed=%u (playtime owned by session tracking: win=%u mac=%u linux=%u)", + appId, vdfLastPlayed, + stats.playtime.playtimeWindows, stats.playtime.playtimeMac, + stats.playtime.playtimeLinux); return true; }); } @@ -380,10 +391,10 @@ static bool ImportNativeStats(uint32_t appId, AppStats& out) { uint32_t accountId = g_accountIdProvider(); if (accountId == 0) return false; - // Schema: appcache\stats\UserGameStatsSchema_<appId>.bin + // Schema: appcache/stats/UserGameStatsSchema_<appId>.bin { - std::string schemaPath = g_steamPath + "appcache\\stats\\UserGameStatsSchema_" - + std::to_string(appId) + ".bin"; + fs::path schemaPath = FileUtil::Utf8ToPath(g_steamPath) / "appcache" / "stats" + / ("UserGameStatsSchema_" + std::to_string(appId) + ".bin"); std::ifstream sf(schemaPath, std::ios::binary); if (sf.good()) { out.schema.assign(std::istreambuf_iterator<char>(sf), @@ -395,9 +406,9 @@ static bool ImportNativeStats(uint32_t appId, AppStats& out) { g_schemaMissingCb(appId); } - // Stats: appcache\stats\UserGameStats_<accountId>_<appId>.bin - std::string statsPath = g_steamPath + "appcache\\stats\\UserGameStats_" - + std::to_string(accountId) + "_" + std::to_string(appId) + ".bin"; + // Stats: appcache/stats/UserGameStats_<accountId>_<appId>.bin + fs::path statsPath = FileUtil::Utf8ToPath(g_steamPath) / "appcache" / "stats" + / ("UserGameStats_" + std::to_string(accountId) + "_" + std::to_string(appId) + ".bin"); std::ifstream f(statsPath, std::ios::binary); if (!f.good()) return false; std::vector<uint8_t> blob((std::istreambuf_iterator<char>(f)), @@ -577,26 +588,58 @@ static std::string BuildAppStatsJson(const AppStats& stats) { return Json::Stringify(root); } +// Max-merge each platform's forever field (the total is their sum), so a stale +// local or older cloud blob can't regress another device's time. +static void MergePlaytime(PlaytimeData& dst, const PlaytimeData& src) { + dst.playtimeWindows = (std::max)(dst.playtimeWindows, src.playtimeWindows); + dst.playtimeMac = (std::max)(dst.playtimeMac, src.playtimeMac); + dst.playtimeLinux = (std::max)(dst.playtimeLinux, src.playtimeLinux); + dst.minutesLastTwoWeeks = (std::max)(dst.minutesLastTwoWeeks, src.minutesLastTwoWeeks); + dst.lastPlayedTime = (std::max)(dst.lastPlayedTime, src.lastPlayedTime); + dst.minutesForever = dst.playtimeWindows + dst.playtimeMac + dst.playtimeLinux; +} + bool LoadAppStats(uint32_t appId, AppStats& out) { std::string path = StatsPath(appId); - std::string content; + bool haveLocal = false; std::ifstream f(path); if (f.good()) { - content.assign((std::istreambuf_iterator<char>(f)), - std::istreambuf_iterator<char>()); + std::string local((std::istreambuf_iterator<char>(f)), + std::istreambuf_iterator<char>()); f.close(); - } else if (g_cloudPull && g_cloudPull(appId, content) && !content.empty()) { - // No local copy; pull the cloud blob and materialize it locally so - // subsequent reads hit disk and FlushAll round-trips correctly. - std::ofstream wf(path, std::ios::trunc); - wf << content; - wf.close(); - LOG("[Stats] Pulled app %u stats from cloud (%zu bytes)", appId, content.size()); + if (!local.empty() && ParseAppStatsJson(local, out)) + haveLocal = true; + } + + // Always consult the cloud and merge per-platform: a local copy from a + // prior session must not hide another device's playtime in the cloud. + std::string cloud; + if (g_cloudPull && g_cloudPull(appId, cloud) && !cloud.empty()) { + AppStats cloudStats; + if (ParseAppStatsJson(cloud, cloudStats)) { + if (!haveLocal) { + out = std::move(cloudStats); + haveLocal = true; + } else { + MergePlaytime(out.playtime, cloudStats.playtime); + // Adopt cloud achievements/stats/schema when we hold none. + if (out.stats.empty() && !cloudStats.stats.empty()) + out.stats = std::move(cloudStats.stats); + if (out.achievements.empty() && !cloudStats.achievements.empty()) + out.achievements = std::move(cloudStats.achievements); + if (out.schema.empty() && !cloudStats.schema.empty()) + out.schema = std::move(cloudStats.schema); + } + // Materialize the merged result locally for fast subsequent reads. + WriteAppStats(appId, out, false); + LOG("[Stats] Merged app %u with cloud blob (forever=%u win=%u mac=%u linux=%u)", + appId, out.playtime.minutesForever, out.playtime.playtimeWindows, + out.playtime.playtimeMac, out.playtime.playtimeLinux); + } } - if (content.empty()) return false; - if (!ParseAppStatsJson(content, out)) return false; + if (!haveLocal) return false; // Load schema blob if exists (separate binary sidecar). std::string schemaPath = SchemaPath(appId); @@ -608,7 +651,9 @@ bool LoadAppStats(uint32_t appId, AppStats& out) { return true; } -void SaveAppStats(uint32_t appId, const AppStats& stats) { +// Persist locally and, when pushCloud, queue a cloud upload. Reconcile writes +// locally only; the cloud is written on session end, when playtime accrues. +static void WriteAppStats(uint32_t appId, const AppStats& stats, bool pushCloud) { std::string path = StatsPath(appId); std::string json = BuildAppStatsJson(stats); @@ -616,15 +661,17 @@ void SaveAppStats(uint32_t appId, const AppStats& stats) { f << json; f.close(); - // Save schema blob separately if present if (!stats.schema.empty()) { std::string schemaPath = SchemaPath(appId); std::ofstream sf(schemaPath, std::ios::binary | std::ios::trunc); sf.write(reinterpret_cast<const char*>(stats.schema.data()), stats.schema.size()); } - // Cloud-back the stats document (fire-and-forget; provider queues upload). - if (g_cloudPush) g_cloudPush(appId, json); + if (pushCloud && g_cloudPush) g_cloudPush(appId, json); +} + +void SaveAppStats(uint32_t appId, const AppStats& stats) { + WriteAppStats(appId, stats, true); } // Apps for which native import has been successfully attempted (imported real @@ -679,6 +726,41 @@ AppStats& GetOrCreate(uint32_t appId) { return stats; } +void SeedApps(const std::vector<uint32_t>& appIds) { + for (uint32_t appId : appIds) { + if (appId == 0) continue; + GetOrCreate(appId); // pulls cloud blob + imports native + loads local + } +} + +std::vector<uint32_t> RefreshFromCloud(const std::vector<uint32_t>& appIds) { + std::vector<uint32_t> changed; + if (!g_cloudPull) return changed; + for (uint32_t appId : appIds) { + if (appId == 0) continue; + std::string cloud; + if (!g_cloudPull(appId, cloud) || cloud.empty()) continue; + AppStats cloudStats; + if (!ParseAppStatsJson(cloud, cloudStats)) continue; + + std::lock_guard<std::mutex> lock(g_mutex); + AppStats& cur = g_cache[appId]; + PlaytimeData before = cur.playtime; + MergePlaytime(cur.playtime, cloudStats.playtime); + // Another device advanced this app's playtime -> persist locally and report. + if (cur.playtime.minutesForever != before.minutesForever || + cur.playtime.lastPlayedTime != before.lastPlayedTime) { + WriteAppStats(appId, cur, false); + changed.push_back(appId); + LOG("[Stats] Cloud advanced app %u: forever %u -> %u (win=%u mac=%u linux=%u)", + appId, before.minutesForever, cur.playtime.minutesForever, + cur.playtime.playtimeWindows, cur.playtime.playtimeMac, + cur.playtime.playtimeLinux); + } + } + return changed; +} + // Deterministic, order-independent CRC over stat values AND achievement // unlock state. This is our opaque sync token (per IDA: the client just stores // and echoes whatever crc we send; it never recomputes). It MUST be stable @@ -833,10 +915,8 @@ void EndSession(uint32_t appId) { g_activeSessions.erase(it); auto& stats = g_cache[appId]; - stats.playtime.minutesForever += minutes; - stats.playtime.minutesLastTwoWeeks += minutes; - stats.playtime.lastPlayedTime = now; - + // Accrue only this platform's field; a session here can't overwrite another + // device's cloud playtime. #ifdef _WIN32 stats.playtime.playtimeWindows += minutes; #elif defined(__APPLE__) @@ -844,7 +924,12 @@ void EndSession(uint32_t appId) { #else stats.playtime.playtimeLinux += minutes; #endif + stats.playtime.minutesForever = stats.playtime.playtimeWindows + + stats.playtime.playtimeMac + stats.playtime.playtimeLinux; + stats.playtime.minutesLastTwoWeeks += minutes; + stats.playtime.lastPlayedTime = now; + // Queue the push (not a blocking curl on the net thread, which raced at close). g_dirty[appId] = true; SaveAppStats(appId, stats); g_dirty[appId] = false; diff --git a/src/common/stats_store.h b/src/common/stats_store.h index 90c89553..666f90d7 100644 --- a/src/common/stats_store.h +++ b/src/common/stats_store.h @@ -68,6 +68,19 @@ void SetAccountIdProvider(AccountIdProvider provider); using SchemaMissingCallback = std::function<void(uint32_t appId)>; void SetSchemaMissingCallback(SchemaMissingCallback cb); +// Namespace-app predicate; reconcile uses it to seed playtime from localconfig.vdf +// for managed apps before any stats JSON exists. +using NamespacePredicate = std::function<bool(uint32_t appId)>; +void SetNamespacePredicate(NamespacePredicate pred); + +// Seed apps at startup (cloud blob + native UserGameStats + local JSON) so +// GetLastPlayedTimes has data before launch. Requires a logged-in accountId. +void SeedApps(const std::vector<uint32_t>& appIds); + +// Re-pull + merge each app's cloud blob; returns apps whose playtime advanced +// (another device played) for a live notification. Runs in the background. +std::vector<uint32_t> RefreshFromCloud(const std::vector<uint32_t>& appIds); + // Load stats for an app. Returns true if data exists on disk. bool LoadAppStats(uint32_t appId, AppStats& out); From aef76e967f1a7ee13b82720b3ed85926c053075e Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:08:08 -0400 Subject: [PATCH 14/24] Sync stats, achievements, and playtime across devices --- CMakeLists.txt | 4 + src/common/stats_handlers.cpp | 78 +++-- src/common/stats_handlers.h | 10 + src/common/stats_store.cpp | 88 ++++++ src/common/stats_store.h | 6 + src/platform/linux/achievement_inject.cpp | 250 ++++++++++++++++ src/platform/linux/achievement_inject.h | 39 +++ src/platform/linux/cloud_hooks.cpp | 130 +++++++- src/platform/linux/cloud_hooks.h | 4 + src/platform/linux/cloud_intercept.cpp | 5 + src/platform/linux/cloud_intercept.h | 3 + src/platform/linux/gamesplayed_hook.cpp | 245 +++++++++++++++ src/platform/linux/gamesplayed_hook.h | 28 ++ src/platform/linux/init.cpp | 6 + src/platform/linux/live_playtime.cpp | 322 ++++++++++++++++++++ src/platform/linux/live_playtime.h | 47 +++ src/platform/linux/stats_hooks.cpp | 94 ++++++ src/platform/linux/stats_hooks.h | 45 +++ src/platform/win/cloud_intercept.cpp | 348 ++++++++++++++++++++-- 19 files changed, 1692 insertions(+), 60 deletions(-) create mode 100644 src/platform/linux/achievement_inject.cpp create mode 100644 src/platform/linux/achievement_inject.h create mode 100644 src/platform/linux/gamesplayed_hook.cpp create mode 100644 src/platform/linux/gamesplayed_hook.h create mode 100644 src/platform/linux/live_playtime.cpp create mode 100644 src/platform/linux/live_playtime.h create mode 100644 src/platform/linux/stats_hooks.cpp create mode 100644 src/platform/linux/stats_hooks.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f0391848..27e4c42b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -100,6 +100,10 @@ else() src/platform/linux/vtable_hook.cpp src/platform/linux/cloud_hooks.cpp src/platform/linux/cloud_intercept.cpp + src/platform/linux/stats_hooks.cpp + src/platform/linux/gamesplayed_hook.cpp + src/platform/linux/live_playtime.cpp + src/platform/linux/achievement_inject.cpp src/platform/linux/http_transport_linux.cpp src/platform/linux/token_store_linux.cpp src/platform/linux/log.cpp diff --git a/src/common/stats_handlers.cpp b/src/common/stats_handlers.cpp index c9ef14fa..1a5fb2a5 100644 --- a/src/common/stats_handlers.cpp +++ b/src/common/stats_handlers.cpp @@ -1,5 +1,6 @@ #include "stats_handlers.h" #include "stats_store.h" +#include "metadata_sync.h" #include "protobuf.h" #include "log.h" @@ -47,21 +48,11 @@ CloudIntercept::RpcResult HandleGetUserStats(uint32_t appId, const std::vector<P PB::Writer resp; - // Authoritative server contract (IDA-verified: CAPIJobRequestUserStats @ - // steamclient!0x138A45A20 / legacy sub_138A44F70): - // * crc_stats is a server-owned opaque token. The client stores whatever - // crc we last sent and echoes it in the request (field 4). It never - // recomputes it from data. - // * The client adopts our stats ONLY when our crc_stats (response field 2) - // DIFFERS from the client's current crc (request field 4). When they - // match and we send no stats, the client logs "we must be up to date" - // and leaves its local stats untouched. - // So we are the source of truth: always report OUR crc. Send schema + the - // full stats array only when the client is stale (clientCrc != ourCrc), - // which makes the client adopt our (cloud-restored) data. When they match, - // send crc only -> client no-ops. We hold the authoritative copy even if - // empty, so we never clobber: an empty store only happens when Steam itself - // had no stats blob for this app. + // crc_stats is a server-owned opaque token the client echoes back; it adopts + // our stats only when our crc (response field 2) differs from its own (request + // field 4). So always report OUR crc and send schema+stats only when the client + // is stale; matching crc -> client no-ops. (CAPIJobRequestUserStats @ + // steamclient!0x138A45A20 / legacy sub_138A44F70.) // Field 2: crc_stats (always our authoritative token) resp.WriteVarint(2, stats.crcStats); @@ -126,8 +117,20 @@ CloudIntercept::RpcResult HandleGetUserStats(uint32_t appId, const std::vector<P // first_playtime(5, uint32), // playtime_windows_forever(6), playtime_mac_forever(7), // playtime_linux_forever(8) -// Returning this response makes Steam populate its own in-memory minutes-played -// record (the same one the library UI reads), so playtime shows natively. +// This response populates Steam's in-memory minutes-played record (read by the +// library UI). Writes one Game submessage (field 1). +static void WriteGame(PB::Writer& out, uint32_t appId, const StatsStore::PlaytimeData& pt) { + PB::Writer game; + game.WriteVarint(1, appId); // appid (int32) + game.WriteVarint(2, pt.lastPlayedTime); // last_playtime (uint32) + game.WriteVarint(3, pt.minutesLastTwoWeeks); // playtime_2weeks (int32) + game.WriteVarint(4, pt.minutesForever); // playtime_forever (int32) + if (pt.playtimeWindows) game.WriteVarint(6, pt.playtimeWindows); + if (pt.playtimeMac) game.WriteVarint(7, pt.playtimeMac); + if (pt.playtimeLinux) game.WriteVarint(8, pt.playtimeLinux); + out.WriteSubmessage(1, game); // games (repeated) +} + CloudIntercept::RpcResult HandleGetLastPlayedTimes(const std::vector<PB::Field>& reqBody) { uint32_t minLastPlayed = 0; auto* minField = PB::FindField(reqBody, 1); @@ -147,17 +150,7 @@ CloudIntercept::RpcResult HandleGetLastPlayedTimes(const std::vector<PB::Field>& if (pt.minutesForever == 0 && pt.lastPlayedTime == 0) continue; - PB::Writer game; - game.WriteVarint(1, appId); // appid (int32) - game.WriteVarint(2, pt.lastPlayedTime); // last_playtime (uint32) - game.WriteVarint(3, pt.minutesLastTwoWeeks); // playtime_2weeks (int32) - game.WriteVarint(4, pt.minutesForever); // playtime_forever (int32) - // Per-platform forever fields so the native record is internally consistent. - if (pt.playtimeWindows) game.WriteVarint(6, pt.playtimeWindows); - if (pt.playtimeMac) game.WriteVarint(7, pt.playtimeMac); - if (pt.playtimeLinux) game.WriteVarint(8, pt.playtimeLinux); - - resp.WriteSubmessage(1, game); // games (repeated) + WriteGame(resp, appId, pt); ++emitted; } @@ -166,6 +159,19 @@ CloudIntercept::RpcResult HandleGetLastPlayedTimes(const std::vector<PB::Field>& return CloudIntercept::RpcResult(std::move(resp)); } +// Build a CPlayer_LastPlayedTimes_Notification body (repeated Game games, field 1) +// for the given apps. The platform layer injects this as a server notification so +// a running client adopts the new playtime live. Empty if no app has playtime. +PB::Writer BuildLastPlayedNotificationBody(const std::vector<uint32_t>& appIds) { + PB::Writer body; + for (uint32_t appId : appIds) { + StatsStore::PlaytimeData pt = StatsStore::GetPlaytime(appId); + if (pt.minutesForever == 0 && pt.lastPlayedTime == 0) continue; + WriteGame(body, appId, pt); + } + return body; +} + // Legacy EMsg 818: CMsgClientGetUserStats // Request: game_id(1,fixed64), crc_stats(2,uint32), schema_local_version(3,int32), steam_id_for_user(4,fixed64) // Response: game_id(1,fixed64), eresult(2,int32), crc_stats(3,uint32), schema(4,bytes), @@ -340,6 +346,22 @@ void ObserveGamesPlayed(const uint8_t* body, size_t bodyLen) { } } +// Observe CMsgClientStoreUserStats2 (EMsg 5466, game_id field 1) -- sent on +// unlock. The body has no timestamps, but Steam writes them to the native blob in +// the same store job, so re-read the blob here to sync the new unlocks. +void ObserveStoreUserStats(const uint8_t* body, size_t bodyLen) { + if (!MetadataSync::syncAchievements.load(std::memory_order_relaxed)) return; + + auto fields = PB::Parse(body, bodyLen); + auto* gameIdField = PB::FindField(fields, 1); // game_id (fixed64) + if (!gameIdField) return; + + uint32_t appId = (uint32_t)(gameIdField->varintVal & 0xFFFFFF); + if (appId == 0 || !IsNamespaceApp(appId)) return; + + StatsStore::CaptureNativeUnlocks(appId); +} + void Shutdown() { { std::lock_guard<std::mutex> lock(g_sessionMutex); diff --git a/src/common/stats_handlers.h b/src/common/stats_handlers.h index d0be7d4e..dd8544de 100644 --- a/src/common/stats_handlers.h +++ b/src/common/stats_handlers.h @@ -11,6 +11,8 @@ namespace StatsHandlers { // Service RPC method names inline constexpr const char* RPC_GET_USER_STATS = "Player.GetUserStats#1"; inline constexpr const char* RPC_GET_LAST_PLAYED = "Player.ClientGetLastPlayedTimes#1"; +// Server->client notification method (we push this to update playtime live). +inline constexpr const char* RPC_GET_LAST_PLAYED_NOTIFY = "PlayerClient.NotifyLastPlayedTimes#1"; // Namespace-app predicate. The platform layer installs this so playtime // session tracking (and any persistence) is restricted to namespace/lua apps @@ -35,6 +37,10 @@ CloudIntercept::RpcResult HandleGetUserStats(uint32_t appId, const std::vector<P // Service RPC handler for Player.ClientGetLastPlayedTimes#1 CloudIntercept::RpcResult HandleGetLastPlayedTimes(const std::vector<PB::Field>& reqBody); +// Build a CPlayer_LastPlayedTimes_Notification body (repeated Game games) for the +// given apps, for the platform layer to inject as a live server notification. +PB::Writer BuildLastPlayedNotificationBody(const std::vector<uint32_t>& appIds); + // Legacy EMsg handlers - return response body bytes // Returns nullopt if this EMsg should pass through to real server std::optional<std::vector<uint8_t>> HandleLegacyGetUserStats( @@ -47,6 +53,10 @@ std::optional<std::vector<uint8_t>> HandleLegacyStoreUserStats2( // We don't intercept it - just observe it to track playtime. void ObserveGamesPlayed(const uint8_t* body, size_t bodyLen); +// Observe CMsgClientStoreUserStats2 (EMsg 5466) to capture achievement unlocks +// the moment the game stores them. body = serialized message; game_id is field 1. +void ObserveStoreUserStats(const uint8_t* body, size_t bodyLen); + // Shutdown - flush and cleanup void Shutdown(); diff --git a/src/common/stats_store.cpp b/src/common/stats_store.cpp index 1cf334a6..273693ac 100644 --- a/src/common/stats_store.cpp +++ b/src/common/stats_store.cpp @@ -599,6 +599,54 @@ static void MergePlaytime(PlaytimeData& dst, const PlaytimeData& src) { dst.minutesForever = dst.playtimeWindows + dst.playtimeMac + dst.playtimeLinux; } +// Union-merge achievements: an unlock is monotonic, so a bit stays unlocked if +// either side has it. The existing unlock timestamp wins (so a re-import can't +// rewrite an earlier unlock, including one another device recorded); native fills +// bits we don't yet hold. Names are adopted from src when we lack them. Returns +// true if dst changed. +static bool MergeAchievements(std::vector<AchievementBlock>& dst, + const std::vector<AchievementBlock>& src) { + bool changed = false; + for (const auto& s : src) { + AchievementBlock* d = nullptr; + for (auto& a : dst) { if (a.statId == s.statId) { d = &a; break; } } + if (!d) { + dst.push_back(s); + changed = true; + continue; + } + if ((d->bits | s.bits) != d->bits) { d->bits |= s.bits; changed = true; } + for (int bit = 0; bit < 32; ++bit) { + if (d->unlockTimes[bit] == 0 && s.unlockTimes[bit] != 0) { + d->unlockTimes[bit] = s.unlockTimes[bit]; + changed = true; + } + if (d->names[bit].empty() && !s.names[bit].empty()) + d->names[bit] = s.names[bit]; + } + } + return changed; +} + +// Merge the latest stat values from src into dst (this device's native stats are +// authoritative for their own values). Returns true if dst changed. +static bool MergeStatValues(std::vector<StatEntry>& dst, + const std::vector<StatEntry>& src) { + bool changed = false; + for (const auto& s : src) { + bool found = false; + for (auto& d : dst) { + if (d.statId == s.statId) { + if (d.value != s.value) { d.value = s.value; changed = true; } + found = true; + break; + } + } + if (!found) { dst.push_back(s); changed = true; } + } + return changed; +} + bool LoadAppStats(uint32_t appId, AppStats& out) { std::string path = StatsPath(appId); bool haveLocal = false; @@ -705,6 +753,40 @@ static void EnsureNativeImportLocked(uint32_t appId, AppStats& stats) { } } +// Re-read Steam's native blob and merge any newly unlocked achievements / updated +// stat values into the cached store. Unlike EnsureNativeImportLocked this runs +// even when we already hold data -- it is the capture point for unlocks earned +// during a session that has just ended (Steam flushes the blob on game close). +// Merges (never overwrites) so cross-device unlocks survive. Caller holds mutex. +static bool ReimportNativeStatsLocked(uint32_t appId, AppStats& stats) { + if (!g_accountIdProvider || g_accountIdProvider() == 0) return false; + + AppStats native; + if (!ImportNativeStats(appId, native)) return false; + + bool changed = MergeStatValues(stats.stats, native.stats); + if (MergeAchievements(stats.achievements, native.achievements)) changed = true; + if (stats.schema.empty() && !native.schema.empty()) { + stats.schema = std::move(native.schema); + changed = true; + } + if (changed) stats.crcStats = ComputeCrcLocked(stats); + return changed; +} + +void CaptureNativeUnlocks(uint32_t appId) { + std::lock_guard<std::mutex> lock(g_mutex); + auto& stats = g_cache[appId]; + // Make sure the base data exists (first observation may precede any import). + if (stats.stats.empty()) EnsureNativeImportLocked(appId, stats); + if (ReimportNativeStatsLocked(appId, stats)) { + g_dirty[appId] = true; + SaveAppStats(appId, stats); // pushes to cloud + g_dirty[appId] = false; + LOG("[Stats] Captured native unlocks for app %u (crc=%u)", appId, stats.crcStats); + } +} + AppStats& GetOrCreate(uint32_t appId) { std::lock_guard<std::mutex> lock(g_mutex); auto it = g_cache.find(appId); @@ -929,6 +1011,12 @@ void EndSession(uint32_t appId) { stats.playtime.minutesLastTwoWeeks += minutes; stats.playtime.lastPlayedTime = now; + // Steam flushes the native blob on game close; merge any new unlocks (also + // catches another device's, so they're never lost). + if (ReimportNativeStatsLocked(appId, stats)) + LOG("[Stats] Session end: merged new native achievements/stats for app %u (crc=%u)", + appId, stats.crcStats); + // Queue the push (not a blocking curl on the net thread, which raced at close). g_dirty[appId] = true; SaveAppStats(appId, stats); diff --git a/src/common/stats_store.h b/src/common/stats_store.h index 666f90d7..42f89954 100644 --- a/src/common/stats_store.h +++ b/src/common/stats_store.h @@ -106,6 +106,12 @@ const std::vector<uint8_t>& GetSchema(uint32_t appId); // Compute CRC32 over current stat values for an app. uint32_t ComputeCrc(uint32_t appId); +// Re-read Steam's native blob for an app and merge any newly unlocked +// achievements / updated stat values into the store, then push to the cloud if +// anything changed. Called when an achievement-store message is observed on the +// wire (the genuine unlock event). Safe to call from the network thread. +void CaptureNativeUnlocks(uint32_t appId); + // Playtime tracking void StartSession(uint32_t appId); void EndSession(uint32_t appId); diff --git a/src/platform/linux/achievement_inject.cpp b/src/platform/linux/achievement_inject.cpp new file mode 100644 index 00000000..48492533 --- /dev/null +++ b/src/platform/linux/achievement_inject.cpp @@ -0,0 +1,250 @@ +#include "achievement_inject.h" +#include "stats_handlers.h" +#include "cloud_intercept.h" +#include "protobuf.h" +#include "log.h" + +#include <atomic> +#include <cstring> +#include <csetjmp> +#include <csignal> +#include <mutex> +#include <queue> +#include <unistd.h> + +namespace AchievementInject { + +// ── Resolved steamclient.so entry points (file offsets from IDA) ──────────── +// sub_2AC1EC0 WrapPacket: (rawPkt{?,data@+4,size@+8}, 1) -> CProtoBufNetPacket* +// sub_2A6A1E0 BRouteMsgToJob: (jobMgr, connCtx, wrappedPkt, route, -1) -> routed? +// dword_2ECDB40 (.bss) engine global pointer; jobMgr = *engine + 0x1B8 +// CProtoBufMsg layout (32-bit): EMsg @ +20, header buffer ptr @ +28, body @ +32. +// Parsed CM header: jobid_target @ hdr+7, jobid_source @ hdr+15 (byte offsets). +using WrapPacketFn = void*(*)(void* rawPkt, char addRef); +using BRouteMsgFn = char(*)(int jobMgr, int connCtx, void* wrappedPkt, void* route, int from); + +static constexpr uintptr_t RVA_WRAP_PACKET = 0x2AC1EC0; +static constexpr uintptr_t RVA_BROUTE = 0x2A6A1E0; +static constexpr uintptr_t RVA_ENGINE_GLOBAL = 0x2ECDB40; +static constexpr uintptr_t RVA_JOBCUR_GLOBAL = 0x2F00A60; // g_pJobCur (current job) +static constexpr uint32_t ENGINE_OFF_JOBMGR = 0x1B8; // jobMgr = *engine + 0x1B8 +static constexpr uint32_t CCM_OFF_CONNCTX = 1404; // connCtx = *(cmInterface+1404) +static constexpr uint32_t JOB_OFF_JOBID = 16; // CJob+16 = jobid (CJob ctor sub_2A5A170) + +static constexpr uint32_t EMSG_GET_USER_STATS = 818; +static constexpr uint32_t EMSG_GET_USER_STATS_RESP = 819; +static constexpr uint32_t EMSG_PROTO_FLAG = 0x80000000u; + +// CMsgProtoBufHeader field numbers. Routing is by jobid_target; steamid is +// included so the response header validates against the connection. +static constexpr uint32_t HDR_F_STEAMID = 1; // fixed64 +static constexpr uint32_t HDR_F_JOBID_TARGET = 11; // fixed64 + +static uintptr_t g_base = 0; +static WrapPacketFn g_wrapPacket = nullptr; +static BRouteMsgFn g_bRoute = nullptr; +static SerializeBodyFn g_serializeBody = nullptr; + +// ── Signatures (PIC call/add displacements wildcarded) ────────────────────── +// sub_2AC1EC0: 55 89 E5 57 E8 ?? ?? ?? ?? 81 C7 ?? ?? ?? ?? 56 53 83 EC 5C 8B 5D 08 65 8B 15 +static const uint8_t kWrapB[] = {0x55,0x89,0xE5,0x57,0xE8,0,0,0,0,0x81,0xC7,0,0,0,0,0x56,0x53,0x83,0xEC,0x5C,0x8B,0x5D,0x08,0x65,0x8B,0x15}; +static const uint8_t kWrapM[] = {1,1,1,1,1,0,0,0,0,1,1,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1}; +// sub_2A6A1E0: 55 89 E5 57 56 E8 ?? ?? ?? ?? 81 C6 ?? ?? ?? ?? 53 83 EC 7C 8B 45 08 8B 4D 14 89 45 90 +static const uint8_t kRouteB[] = {0x55,0x89,0xE5,0x57,0x56,0xE8,0,0,0,0,0x81,0xC6,0,0,0,0,0x53,0x83,0xEC,0x7C,0x8B,0x45,0x08,0x8B,0x4D,0x14,0x89,0x45,0x90}; +static const uint8_t kRouteM[] = {1,1,1,1,1,1,0,0,0,0,1,1,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1}; + +static void* ScanSig(uintptr_t base, size_t size, const uint8_t* b, const uint8_t* m, size_t len) { + if (size < len) return nullptr; + const uint8_t* s = (const uint8_t*)base; + const uint8_t* end = s + size - len; + for (; s <= end; ++s) { + bool ok = true; + for (size_t i = 0; i < len; ++i) + if (m[i] && s[i] != b[i]) { ok = false; break; } + if (ok) return (void*)s; + } + return nullptr; +} + +// ── Crash guard for the wrap/route calls (a layout mismatch faults to SIGSEGV; +// convert it to a skipped inject rather than taking down steamwebhelper). ─── +static sigjmp_buf g_jmp; +static volatile sig_atomic_t g_inCall = 0; +static void CrashHandler(int sig) { if (g_inCall) siglongjmp(g_jmp, sig); raise(sig); } +class CallGuard { +public: + CallGuard() { + struct sigaction sa = {}; + sa.sa_handler = CrashHandler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESETHAND; + sigaction(SIGSEGV, &sa, &m_segv); + sigaction(SIGBUS, &sa, &m_bus); + g_inCall = 1; + } + ~CallGuard() { + g_inCall = 0; + sigaction(SIGSEGV, &m_segv, nullptr); + sigaction(SIGBUS, &m_bus, nullptr); + } +private: + struct sigaction m_segv = {}; + struct sigaction m_bus = {}; +}; + +// One pending 819 response: the 818's jobid + the app + the captured cmInterface. +struct Pending { + uint64_t jobIdTarget; + uint32_t appId; + void* cmInterface; +}; +static std::queue<Pending> g_queue; +static std::mutex g_queueMutex; + +bool Resolve(uintptr_t base, size_t size, SerializeBodyFn serialize) { + g_base = base; + g_serializeBody = serialize; + void* wrap = ScanSig(base, size, kWrapB, kWrapM, sizeof(kWrapB)); + void* route = ScanSig(base, size, kRouteB, kRouteM, sizeof(kRouteB)); + if (!wrap || !route) { + LOG("[Stats] AchievementInject: signature scan incomplete (wrap=%p route=%p) -- legacy 818 serve disabled", + wrap, route); + return false; + } + g_wrapPacket = (WrapPacketFn)wrap; + g_bRoute = (BRouteMsgFn)route; + LOG("[Stats] AchievementInject resolved: WrapPacket=%p BRouteMsgToJob=%p", wrap, route); + return true; +} + +bool Ready() { return g_wrapPacket && g_bRoute && g_base && g_serializeBody; } + +void ObserveOutbound(uint32_t emsg, void* msgObj, void* cmInterface) { + if (!Ready() || emsg != EMSG_GET_USER_STATS || !msgObj || !cmInterface) return; + + uint64_t jobId = 0; + void* bodyObj = nullptr; + { + // The outbound 818 header has no jobid yet (stamped at final serialize). + // Read it from the sending coroutine instead: g_pJobCur is the + // CAPIJobRequestUserStats, jobid at CJob+16 (ctor sub_2A5A170). + CallGuard guard; + if (sigsetjmp(g_jmp, 1) != 0) return; + uintptr_t jobCur = *(uintptr_t*)(g_base + RVA_JOBCUR_GLOBAL); + if (jobCur) jobId = *(uint64_t*)(jobCur + JOB_OFF_JOBID); + bodyObj = *(void**)((uint8_t*)msgObj + 32); + } + if (!bodyObj) return; + + // game_id is field 1 (fixed64) of the body. Serialize + parse it. + size_t blen = 0; + const uint8_t* bbytes = g_serializeBody(bodyObj, &blen); + if (!bbytes || blen == 0) return; + auto fields = PB::Parse(bbytes, blen); + auto* f1 = PB::FindField(fields, 1); + uint32_t appId = f1 ? (uint32_t)(f1->varintVal & 0xFFFFFF) : 0; + if (appId == 0 || !CloudIntercept::IsNamespaceApp(appId)) return; + + { + std::lock_guard<std::mutex> lock(g_queueMutex); + g_queue.push(Pending{jobId, appId, cmInterface}); + } + LOG("[Stats] Observed legacy GetUserStats(818) app=%u jobid=%llu -> queued 819", + appId, (unsigned long long)jobId); +} + +// Build the raw CM wire bytes for a 819 response: [EMsg|protoflag][hdrLen][header] +// [body], matching the CProtoBuf packet framing the wrap function expects. +static std::vector<uint8_t> BuildWirePacket(uint64_t jobIdTarget, + const std::vector<uint8_t>& body) { + // Header: steamid, client_sessionid, jobid_target (the request's jobid_source). + PB::Writer hdr; + uint64_t steamId = (uint64_t)CloudIntercept::GetAccountId() + | (1ULL << 32) | (1ULL << 52) | (1ULL << 56); + hdr.WriteFixed64(HDR_F_STEAMID, steamId); + hdr.WriteFixed64(HDR_F_JOBID_TARGET, jobIdTarget); + auto hdrBytes = hdr.Data(); + + std::vector<uint8_t> pkt; + pkt.reserve(4 + 4 + hdrBytes.size() + body.size()); + uint32_t emsg = EMSG_GET_USER_STATS_RESP | EMSG_PROTO_FLAG; + pkt.push_back(emsg & 0xFF); pkt.push_back((emsg >> 8) & 0xFF); + pkt.push_back((emsg >> 16) & 0xFF); pkt.push_back((emsg >> 24) & 0xFF); + uint32_t hl = (uint32_t)hdrBytes.size(); + pkt.push_back(hl & 0xFF); pkt.push_back((hl >> 8) & 0xFF); + pkt.push_back((hl >> 16) & 0xFF); pkt.push_back((hl >> 24) & 0xFF); + pkt.insert(pkt.end(), hdrBytes.begin(), hdrBytes.end()); + pkt.insert(pkt.end(), body.begin(), body.end()); + return pkt; +} + +// CNetPacket shell handed to WrapPacket (layout from CNetPacket::AddRef sub_2AEC230): +// +0 ?, +4 data, +8 size, +12 refcount, +16 owned-copy buffer. +// We seed refcount=1 so WrapPacket's AddRef (-> 2) copies our wire bytes into a +// steam-owned buffer, removing any dependency on this stack data's lifetime. +struct RawPkt { uint32_t pad0; const uint8_t* data; uint32_t size; uint32_t refcount; uint32_t copyBuf; uint32_t pad[3]; }; + +static void RouteOne(const Pending& p) { + // Build the 819 body from our store via the shared legacy handler. We hand it + // a minimal request body (game_id + crc=0 to force a full send) so it resolves + // the app and emits schema + stats + achievement_blocks. + PB::Writer reqBody; + reqBody.WriteFixed64(1, (uint64_t)p.appId); // game_id + reqBody.WriteVarint(2, 0); // crc_stats = 0 (force send) + reqBody.WriteVarint(3, (uint64_t)(int64_t)-1);// schema_local_version = -1 + auto reqBytes = reqBody.Data(); + + auto built = StatsHandlers::HandleLegacyGetUserStats(reqBytes.data(), reqBytes.size(), 0); + if (!built.has_value() || built->empty()) { + LOG("[Stats] 819 for app=%u: store had nothing to serve", p.appId); + return; + } + + auto wire = BuildWirePacket(p.jobIdTarget, *built); + + CallGuard guard; + if (sigsetjmp(g_jmp, 1) != 0) { + LOG("[Stats] 819 inject for app=%u crashed -- skipped", p.appId); + return; + } + + RawPkt raw = {}; + raw.data = wire.data(); + raw.size = (uint32_t)wire.size(); + raw.refcount = 1; // AddRef -> 2 forces a steam-owned copy of the data + + void* wrapped = g_wrapPacket(&raw, 1); + if (!wrapped) { + LOG("[Stats] 819 inject app=%u: WrapPacket returned null", p.appId); + return; + } + + uint32_t engine = *(uint32_t*)(g_base + RVA_ENGINE_GLOBAL); + int jobMgr = (int)(engine + ENGINE_OFF_JOBMGR); + int connCtx = *(int*)((uint8_t*)p.cmInterface + CCM_OFF_CONNCTX); + + // route layout (from CCMInterface::RecvPkt asm): +0/+4 = -1, +8 = jobid_target + // (QWORD, also carried in the packet header), +16 = emsg, +20 = realm(-3). + uint8_t route[24] = {0}; + *(int32_t*)(route + 0) = -1; + *(int32_t*)(route + 4) = -1; + *(uint64_t*)(route + 8) = p.jobIdTarget; + *(int32_t*)(route + 16) = (int32_t)EMSG_GET_USER_STATS_RESP; + *(int32_t*)(route + 20) = -3; + + char ok = g_bRoute(jobMgr, connCtx, wrapped, route, -1); + LOG("[Stats] 819 inject app=%u jobid=%llu -> BRouteMsgToJob=%d", + p.appId, (unsigned long long)p.jobIdTarget, (int)ok); +} + +void DrainOnNetThread() { + if (!Ready()) return; + std::vector<Pending> batch; + { + std::lock_guard<std::mutex> lock(g_queueMutex); + while (!g_queue.empty()) { batch.push_back(g_queue.front()); g_queue.pop(); } + } + for (auto& p : batch) RouteOne(p); +} + +} // namespace AchievementInject diff --git a/src/platform/linux/achievement_inject.h b/src/platform/linux/achievement_inject.h new file mode 100644 index 00000000..78042d4d --- /dev/null +++ b/src/platform/linux/achievement_inject.h @@ -0,0 +1,39 @@ +#pragma once +#include <cstddef> +#include <cstdint> +#include <vector> + +// Serves the legacy CMsgClientGetUserStats (EMsg 818) achievement request on +// Linux by injecting a CMsgClientGetUserStatsResponse (819) routed to the waiting +// CAPIJobRequestUserStats. The Linux Steam client fetches achievements for many +// apps via this legacy CM message (CAPIJobRequestUserStats picks it over the +// Player.GetUserStats#1 service method when appid < a runtime threshold), so the +// achievements our store holds only reach the library UI through this path. +// +// Flow: observe the outbound 818 (its jobid + appid) -> build the 819 body from +// the store -> wrap it as a CProtoBufNetPacket with a header whose jobid_target +// is the 818's jobid -> route it via CJobMgr::BRouteMsgToJob, which resumes the +// suspended job exactly as a real server reply would. +namespace AchievementInject { + +// Serializes a protobuf message body to raw bytes (installed by the platform +// layer; same helper the GamesPlayed observer uses). +using SerializeBodyFn = const uint8_t* (*)(void* bodyObj, size_t* outLen); + +// Resolve the steamclient.so functions by signature (WrapPacket, BRouteMsgToJob). +// Returns true if both were found. With them absent the legacy path is left to +// the real server (achievements just won't show for apps Steam has no data for). +bool Resolve(uintptr_t steamclientBase, size_t steamclientSize, SerializeBodyFn serialize); +bool Ready(); + +// Called from the CCMInterface::Send observer for every outbound message. Detects +// EMsg 818, reads its appid + jobid from the message header, and (for a namespace +// app) queues a 819 response. Returns immediately; the response is routed on the +// next network-thread drain. msgObj = the CProtoBufMsg being sent. +void ObserveOutbound(uint32_t emsg, void* msgObj, void* cmInterface); + +// Route any queued 819 responses. MUST run on Steam's network thread (valid +// coroutine TLS), same constraint as the live playtime drain. +void DrainOnNetThread(); + +} // namespace AchievementInject diff --git a/src/platform/linux/cloud_hooks.cpp b/src/platform/linux/cloud_hooks.cpp index c202ed97..d871331f 100644 --- a/src/platform/linux/cloud_hooks.cpp +++ b/src/platform/linux/cloud_hooks.cpp @@ -1,5 +1,12 @@ #include "cloud_hooks.h" #include "cloud_intercept.h" +#include "stats_hooks.h" +#include "gamesplayed_hook.h" +#include "live_playtime.h" +#include "achievement_inject.h" +#include "stats_store.h" +#include "stats_handlers.h" +#include "metadata_sync.h" #include "rpc_handlers.h" #include "app_state.h" #include "local_storage.h" @@ -216,6 +223,33 @@ static bool ParseIntoMessage(void* msg, const uint8_t* data, size_t len) { return g_parseFromArray(msg, data, (int)len) != 0; } +// Serialize a protobuf body object into a thread-local buffer for the +// GamesPlayed observer (runs on Steam's network thread). +static const uint8_t* SerializeBodyTL(void* bodyObj, size_t* outLen) { + static thread_local std::vector<uint8_t> tlBuf; + tlBuf = SerializeMessage(bodyObj); + if (outLen) *outLen = tlBuf.size(); + return tlBuf.empty() ? nullptr : tlBuf.data(); +} + +void CloudHooks::InstallGamesPlayedObserver(uintptr_t steamclientBase, size_t steamclientSize) { + if (!g_serializeToArray) { + LOG("[GamesPlayed] serializer not resolved -- playtime tracking disabled"); + return; + } + GamesPlayedHook::SetSerializer(&SerializeBodyTL); + GamesPlayedHook::Install(steamclientBase, steamclientSize); + + // Resolve the playtime writer/message helpers and install the CUser-capture + // detour used to apply cross-device playtime updates live. + if (LivePlaytime::Resolve(steamclientBase, steamclientSize, g_parseFromArray)) + LivePlaytime::InstallUserCapture(); + + // Resolve the packet-wrap + job-routing functions used to serve the legacy + // CMsgClientGetUserStats (818) achievement fetch with a 819 response. + AchievementInject::Resolve(steamclientBase, steamclientSize, &SerializeBodyTL); +} + static std::optional<CloudIntercept::RpcResult> DispatchCloudRpc( const char* method, uint32_t appId, const std::vector<PB::Field>& reqBody) { using namespace CloudIntercept; @@ -263,6 +297,16 @@ static void EnsureInitialized() { auto cfg = Json::Parse(configStr); std::string providerName = cfg["provider"].str(); + // Native stats/playtime sync gates. Absent -> keep default (ON). + // When off, the matching native path does not interfere with Steam. + if (cfg["sync_achievements"].type == Json::Type::Bool) + MetadataSync::syncAchievements = cfg["sync_achievements"].boolean(); + if (cfg["sync_playtime"].type == Json::Type::Bool) + MetadataSync::syncPlaytime = cfg["sync_playtime"].boolean(); + LOG("[Stats] Sync gates: achievements=%d, playtime=%d", + MetadataSync::syncAchievements.load() ? 1 : 0, + MetadataSync::syncPlaytime.load() ? 1 : 0); + if (!providerName.empty() && providerName != "local") { provider = CreateCloudProvider(providerName); if (provider) { @@ -296,6 +340,62 @@ static void EnsureInitialized() { PendingOpsJournal::Init(storageRoot); HttpServer::Start(storageRoot, CloudIntercept::GetAccountId()); + // Native stats / playtime store (cloud-backed). Per-app stats blobs ride + // each app's Steam Cloud sync, same as the CN/root-token metadata blobs. + StatsStore::SetCloudProvider( + [](uint32_t appId, std::string& outJson) -> bool { + uint32_t accountId = CloudIntercept::GetAccountId(); + if (accountId == 0) return false; + std::vector<uint8_t> data; + if (!CloudStorage::DownloadCloudMetadataWithLegacyFallback( + accountId, appId, "stats.json", nullptr, data) || data.empty()) + return false; + outJson.assign(reinterpret_cast<const char*>(data.data()), data.size()); + return true; + }, + [](uint32_t appId, const std::string& json) { + uint32_t accountId = CloudIntercept::GetAccountId(); + if (accountId == 0) return; + // Queued: serializes on the cloud work queue (safe from any thread). + CloudStorage::UploadCloudMetadataTextAsync(accountId, appId, "stats.json", json); + }); + // Track playtime/stats for namespace (lua) apps only -- real owned games + // must never have their playtime recorded or synced. + StatsHandlers::SetNamespacePredicate( + [](uint32_t appId) { return CloudIntercept::IsNamespaceApp(appId); }); + StatsStore::SetNamespacePredicate( + [](uint32_t appId) { return CloudIntercept::IsNamespaceApp(appId); }); + StatsStore::SetAccountIdProvider( + []() -> uint32_t { return CloudIntercept::GetAccountId(); }); + StatsStore::Init(cloudRedirectRoot, CloudIntercept::GetSteamPath()); + StatsHandlers::Init(); + // Seed managed apps so playtime/achievements are available before the + // user launches anything: pulls each app's cloud stats blob and imports + // Steam's native data. + StatsStore::SeedApps(CloudIntercept::GetNamespaceApps()); + + // Re-pull the cloud every 60s for another device's playtime advances. + // RefreshFromCloud merges to disk; advanced apps are queued for a live + // update applied on the network thread (LivePlaytime::DrainOnNetThread). + std::thread([] { + for (;;) { + for (int i = 0; i < 60 && !g_shuttingDown.load(std::memory_order_acquire); ++i) + sleep(1); + if (g_shuttingDown.load(std::memory_order_acquire)) return; + auto changed = StatsStore::RefreshFromCloud(CloudIntercept::GetNamespaceApps()); + if (!changed.empty() && LivePlaytime::Ready()) { + PB::Writer body = StatsHandlers::BuildLastPlayedNotificationBody(changed); + if (body.Size() > 0) + LivePlaytime::Queue(body.Data()); + } + } + }).detach(); + StatsHooks::SetProtobufHelpers( + [](void* msg) { return SerializeMessage(msg); }, + [](void* msg, const uint8_t* data, size_t len) { + return ParseIntoMessage(msg, data, len); + }); + g_initialized.store(true, std::memory_order_release); LOG("[Linux] Storage initialized: root=%s, accountId=%u, namespaceApps=%d", @@ -340,7 +440,33 @@ extern "C" int hook_BYieldingSend(void* pThis, const char* methodName, void* req origFn = g_origBYieldingSend.load(std::memory_order_acquire); } if (!origFn) return 0; - + + // The playtime writer touches Steam's minutes-played map and posts callbacks, + // so queued updates are drained here on the network thread, not on the poller. + LivePlaytime::DrainOnNetThread(); + // Queued 819 achievement responses route to their waiting job here (needs the + // network thread's coroutine TLS, same as any inbound CM packet). + AchievementInject::DrainOnNetThread(); + + // Native stats / playtime service methods (Player.*) ride this same path. + // GetUserStats is answered from our store; GetLastPlayedTimes needs the real + // server reply first, then we append our namespace apps' playtime. + if (methodName && g_serializeToArray && g_parseFromArray) { + if (strcmp(methodName, StatsHandlers::RPC_GET_USER_STATS) == 0) { + EnsureInitialized(); + if (StatsHooks::TryHandleGetUserStats(methodName, request, response, flags)) + return 1; + return origFn(pThis, methodName, request, response, flags); + } + if (strcmp(methodName, StatsHandlers::RPC_GET_LAST_PLAYED) == 0) { + EnsureInitialized(); + int result = origFn(pThis, methodName, request, response, flags); + if (result) + StatsHooks::MergeLastPlayedTimes(methodName, request, response); + return result; + } + } + if (!IsCloudRpc(methodName) || !g_serializeToArray || !g_parseFromArray) return origFn(pThis, methodName, request, response, flags); @@ -618,6 +744,8 @@ extern "C" bool hook_IsCloudEnabledForApp(void* pThis, unsigned int appId) void CloudHooks::BeginShutdown() { g_shuttingDown.store(true, std::memory_order_release); + GamesPlayedHook::Remove(); + LivePlaytime::RemoveUserCapture(); for (int i = 0; i < 300 && g_hookRefCount.load(std::memory_order_acquire) > 0; ++i) usleep(10000); // 10ms, up to 3s total } diff --git a/src/platform/linux/cloud_hooks.h b/src/platform/linux/cloud_hooks.h index 25419e1c..226f4f09 100644 --- a/src/platform/linux/cloud_hooks.h +++ b/src/platform/linux/cloud_hooks.h @@ -15,6 +15,10 @@ namespace CloudHooks // Must be called after steamclient is loaded. Returns true on success. bool ResolveProtobufHelpers(void* steamclientBase, size_t steamclientSize); + // Install the GamesPlayed observer (playtime tracking) using the resolved + // protobuf serializer. Call after ResolveProtobufHelpers succeeds. + void InstallGamesPlayedObserver(uintptr_t steamclientBase, size_t steamclientSize); + // Signal hooks to stop and wait for in-flight calls to drain. void BeginShutdown(); } diff --git a/src/platform/linux/cloud_intercept.cpp b/src/platform/linux/cloud_intercept.cpp index 62cb49ca..4fcdcacc 100644 --- a/src/platform/linux/cloud_intercept.cpp +++ b/src/platform/linux/cloud_intercept.cpp @@ -267,6 +267,11 @@ bool HasNamespaceApps() { return !g_namespaceApps.empty(); } +std::vector<uint32_t> GetNamespaceApps() { + std::lock_guard<std::mutex> lock(g_nsMutex); + return std::vector<uint32_t>(g_namespaceApps.begin(), g_namespaceApps.end()); +} + std::string GetSteamPath() { std::lock_guard<std::mutex> lock(g_mutex); if (g_steamPath.empty()) diff --git a/src/platform/linux/cloud_intercept.h b/src/platform/linux/cloud_intercept.h index f994ee55..b98311c0 100644 --- a/src/platform/linux/cloud_intercept.h +++ b/src/platform/linux/cloud_intercept.h @@ -13,6 +13,9 @@ void InitLinux(); bool IsNamespaceApp(uint32_t appId); bool HasNamespaceApps(); +// Snapshot of all managed namespace app IDs. +std::vector<uint32_t> GetNamespaceApps(); + // Dynamically register an app as a namespace app void RegisterNamespaceApp(uint32_t appId); diff --git a/src/platform/linux/gamesplayed_hook.cpp b/src/platform/linux/gamesplayed_hook.cpp new file mode 100644 index 00000000..5a063524 --- /dev/null +++ b/src/platform/linux/gamesplayed_hook.cpp @@ -0,0 +1,245 @@ +#include "gamesplayed_hook.h" +#include "stats_handlers.h" +#include "achievement_inject.h" +#include "metadata_sync.h" +#include "log.h" + +#include <atomic> +#include <cstring> +#include <sys/mman.h> +#include <unistd.h> + +namespace GamesPlayedHook { + +// CProtoBufMsg 32-bit layout (reversed from ctor): EMsg at +20 (high bit is the +// send flag, mask it off), body protobuf object pointer at +32. +static constexpr size_t OFF_EMSG = 20; +static constexpr size_t OFF_BODY = 32; +static constexpr uint32_t EMSG_MASK = 0x7FFFFFFF; + +// CMsgClientGamesPlayed EMsg variants. +static constexpr uint32_t EMSG_GAMES_PLAYED = 742; +static constexpr uint32_t EMSG_GAMES_PLAYED_NO_DATABLOB = 715; +static constexpr uint32_t EMSG_GAMES_PLAYED_WITH_DATABLOB = 5410; +// CMsgClientStoreUserStats2 -- sent when a game unlocks an achievement / sets a stat. +static constexpr uint32_t EMSG_STORE_USER_STATS2 = 5466; +// CMsgClientGetUserStats -- the legacy achievement-fetch request (we serve 819). +static constexpr uint32_t EMSG_GET_USER_STATS = 818; + +// Serialize a protobuf message object to raw bytes; installed by the platform +// layer so this file stays free of the protobuf-helper plumbing. +static SerializeBodyFn g_serializeBody = nullptr; + +void SetSerializer(SerializeBodyFn fn) { g_serializeBody = fn; } + +static std::atomic<bool> g_installed{false}; +static std::atomic<bool> g_shuttingDown{false}; +static std::atomic<int> g_inFlight{0}; + +// Detour bookkeeping. +static uint8_t* g_hookPoint = nullptr; // funcStart + PROLOGUE_LEN +static uint8_t g_savedBytes[16]; // original bytes at the hook point +static size_t g_savedLen = 0; +static uint8_t* g_trampoline = nullptr; // executable: stolen bytes + jmp back + +// The PIC prologue (push ebp/edi/esi/ebx; call get_pc_thunk; add ebx, delta) is +// position-locked, so we detour AFTER it. At the hook point ebx already holds +// the GOT base and the 4 register pushes are done. +// +15: 83 EC 1C sub esp, 1Ch +// +18: 8B 74 24 30 mov esi, [esp+0x30] ; a1 = cmInterface +// +22: 8B 44 24 34 mov eax, [esp+0x34] ; a2 = msg +static constexpr size_t PROLOGUE_LEN = 15; // bytes before the hook point +static constexpr size_t STOLEN_LEN = 11; // 3 esp-relative insns (position-independent) +static constexpr size_t RESUME_OFF = PROLOGUE_LEN + STOLEN_LEN; // +26 + +// Signature for CCMInterface::Send entry. Wildcards (??) cover the PIC-relative +// call displacement and the add-ebx immediate, which both move with load addr. +// 55 57 56 53 push ebp/edi/esi/ebx +// E8 ?? ?? ?? ?? call get_pc_thunk.bx +// 81 C3 ?? ?? ?? ?? add ebx, <PIC delta> +// 83 EC 1C sub esp, 1Ch +// 8B 74 24 30 mov esi, [esp+0x30] +// 8B 44 24 34 mov eax, [esp+0x34] +// 8B 96 FC 04 00 00 mov edx, [esi+0x4FC] +static const uint8_t kSigBytes[] = { + 0x55,0x57,0x56,0x53, 0xE8,0,0,0,0, 0x81,0xC3,0,0,0,0, + 0x83,0xEC,0x1C, 0x8B,0x74,0x24,0x30, 0x8B,0x44,0x24,0x34, + 0x8B,0x96,0xFC,0x04,0x00,0x00 +}; +static const uint8_t kSigMask[] = { + 1,1,1,1, 1,0,0,0,0, 1,1,0,0,0,0, + 1,1,1, 1,1,1,1, 1,1,1,1, + 1,1,1,1,1,1 +}; +static constexpr size_t kSigLen = sizeof(kSigBytes); + +// Observer: runs on Steam's network thread. Read-only. +extern "C" void GamesPlayedHook_OnSend(int cmInterface, void* msg) { + g_inFlight.fetch_add(1, std::memory_order_acquire); + if (!g_shuttingDown.load(std::memory_order_acquire) && msg && g_serializeBody) { + uint32_t emsg = *(uint32_t*)((uint8_t*)msg + OFF_EMSG) & EMSG_MASK; + + // Legacy achievement fetch: queue a 819 response for namespace apps. + if (emsg == EMSG_GET_USER_STATS && + MetadataSync::syncAchievements.load(std::memory_order_relaxed)) { + AchievementInject::ObserveOutbound(emsg, msg, (void*)(uintptr_t)cmInterface); + } + + bool isGamesPlayed = (emsg == EMSG_GAMES_PLAYED || + emsg == EMSG_GAMES_PLAYED_NO_DATABLOB || + emsg == EMSG_GAMES_PLAYED_WITH_DATABLOB); + bool isStoreStats = (emsg == EMSG_STORE_USER_STATS2); + + if ((isGamesPlayed && MetadataSync::syncPlaytime.load(std::memory_order_relaxed)) || + (isStoreStats && MetadataSync::syncAchievements.load(std::memory_order_relaxed))) { + void* bodyObj = *(void**)((uint8_t*)msg + OFF_BODY); + if (bodyObj) { + size_t len = 0; + const uint8_t* bytes = g_serializeBody(bodyObj, &len); + if (bytes && len > 0) { + if (isGamesPlayed) { + LOG("[Stats] GamesPlayed observed (emsg=%u, %zu bytes) -> session tracking", + emsg, len); + StatsHandlers::ObserveGamesPlayed(bytes, len); + } else { + LOG("[Stats] StoreUserStats2 observed (emsg=%u, %zu bytes) -> capturing unlocks", + emsg, len); + StatsHandlers::ObserveStoreUserStats(bytes, len); + } + } + } + } + } + g_inFlight.fetch_sub(1, std::memory_order_release); +} + +// Hand-written 32-bit trampoline, built at runtime to patch absolute targets. +// The stolen instructions load esi=a1/eax=a2 from the stack, so they run first. +// +// <STOLEN_LEN stolen bytes> ; sub esp,1Ch; mov esi,[esp+30]=a1; mov eax,[esp+34]=a2 +// pushad ; 60 save the now-loaded esi/eax (+ all regs) +// push eax ; 50 arg2 = msg (a2) +// push esi ; 56 arg1 = cmInterface (a1) +// mov eax, <OnSend> ; B8 xx xx xx xx +// call eax ; FF D0 +// add esp, 8 ; 83 C4 08 +// popad ; 61 restore esi/eax for the resume point +// push <resume> ; 68 xx xx xx xx +// ret ; C3 +static bool BuildTrampoline(uint8_t* hookPoint) { + long pageSize = sysconf(_SC_PAGESIZE); + g_trampoline = (uint8_t*)mmap(nullptr, pageSize, PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (g_trampoline == MAP_FAILED) { + g_trampoline = nullptr; + LOG("[GamesPlayed] mmap trampoline failed"); + return false; + } + + uint8_t* p = g_trampoline; + auto emit = [&](std::initializer_list<uint8_t> bytes) { + for (uint8_t b : bytes) *p++ = b; + }; + auto emit32 = [&](uint32_t v) { + *p++ = v & 0xFF; *p++ = (v >> 8) & 0xFF; *p++ = (v >> 16) & 0xFF; *p++ = (v >> 24) & 0xFF; + }; + + // Stolen bytes first -- they set up esp and load esi=a1, eax=a2. + memcpy(p, hookPoint, STOLEN_LEN); + p += STOLEN_LEN; + + emit({0x60}); // pushad + emit({0x50}); // push eax (a2=msg) + emit({0x56}); // push esi (a1=cmInterface) + emit({0xB8}); emit32((uint32_t)(uintptr_t)&GamesPlayedHook_OnSend); // mov eax, OnSend + emit({0xFF, 0xD0}); // call eax + emit({0x83, 0xC4, 0x08}); // add esp, 8 + emit({0x61}); // popad + + // Resume: push <funcStart+RESUME_OFF>; ret (leaves eax/esi intact). + uintptr_t resume = (uintptr_t)(hookPoint - PROLOGUE_LEN) + RESUME_OFF; + emit({0x68}); emit32((uint32_t)resume); // push resume + emit({0xC3}); // ret + return true; +} + +static bool FindBySignature(uintptr_t base, size_t size, uint8_t*& outFuncStart) { + if (size < kSigLen) return false; + const uint8_t* start = (const uint8_t*)base; + const uint8_t* end = start + size - kSigLen; + for (const uint8_t* s = start; s <= end; ++s) { + bool match = true; + for (size_t i = 0; i < kSigLen; ++i) { + if (kSigMask[i] && s[i] != kSigBytes[i]) { match = false; break; } + } + if (match) { + outFuncStart = const_cast<uint8_t*>(s); + return true; + } + } + return false; +} + +static bool MakeWritable(void* addr, size_t len) { + long pageSize = sysconf(_SC_PAGESIZE); + uintptr_t page = (uintptr_t)addr & ~(uintptr_t)(pageSize - 1); + uintptr_t endAddr = (uintptr_t)addr + len; + size_t pageLen = ((endAddr - page) + (pageSize - 1)) & ~(size_t)(pageSize - 1); + return mprotect((void*)page, pageLen, PROT_READ | PROT_WRITE | PROT_EXEC) == 0; +} + +bool Install(uintptr_t steamclientBase, size_t steamclientSize) { + bool expected = false; + if (!g_installed.compare_exchange_strong(expected, true)) return true; + + uint8_t* funcStart = nullptr; + if (!FindBySignature(steamclientBase, steamclientSize, funcStart)) { + LOG("[GamesPlayed] CCMInterface::Send signature not found -- playtime tracking disabled"); + g_installed.store(false); + return false; + } + LOG("[GamesPlayed] CCMInterface::Send found at %p (sc+0x%zx)", + funcStart, (size_t)((uintptr_t)funcStart - steamclientBase)); + + g_hookPoint = funcStart + PROLOGUE_LEN; + + if (!BuildTrampoline(g_hookPoint)) { + g_installed.store(false); + return false; + } + + g_savedLen = STOLEN_LEN; + memcpy(g_savedBytes, g_hookPoint, g_savedLen); + + if (!MakeWritable(g_hookPoint, g_savedLen)) { + LOG("[GamesPlayed] mprotect RWX failed at hook point"); + g_installed.store(false); + return false; + } + + // E9 rel32 jmp to the trampoline; pad remaining stolen bytes with NOP. + int32_t rel = (int32_t)((uintptr_t)g_trampoline - ((uintptr_t)g_hookPoint + 5)); + g_hookPoint[0] = 0xE9; + memcpy(g_hookPoint + 1, &rel, 4); + for (size_t i = 5; i < g_savedLen; ++i) g_hookPoint[i] = 0x90; // nop + + __builtin___clear_cache((char*)g_hookPoint, (char*)g_hookPoint + g_savedLen); + LOG("[GamesPlayed] Inline detour installed at %p -> trampoline %p", g_hookPoint, g_trampoline); + return true; +} + +void Remove() { + if (!g_installed.load(std::memory_order_acquire)) return; + g_shuttingDown.store(true, std::memory_order_release); + + if (g_hookPoint && MakeWritable(g_hookPoint, g_savedLen)) { + memcpy(g_hookPoint, g_savedBytes, g_savedLen); + __builtin___clear_cache((char*)g_hookPoint, (char*)g_hookPoint + g_savedLen); + } + for (int i = 0; i < 300 && g_inFlight.load(std::memory_order_acquire) > 0; ++i) + usleep(10000); // up to 3s + + g_installed.store(false, std::memory_order_release); +} + +} // namespace GamesPlayedHook diff --git a/src/platform/linux/gamesplayed_hook.h b/src/platform/linux/gamesplayed_hook.h new file mode 100644 index 00000000..07deaa63 --- /dev/null +++ b/src/platform/linux/gamesplayed_hook.h @@ -0,0 +1,28 @@ +#pragma once +#include <cstddef> +#include <cstdint> + +// Playtime session tracking for namespace (lua) apps on Linux. +// +// Steam broadcasts CMsgClientGamesPlayed (EMsg 5410/742/715) through the CM +// send primitive CCMInterface::Send when a game starts or stops. We tap that +// send to observe the broadcast and start/stop StatsStore sessions by appid. +// This is read-only: we never modify or block the message. + +namespace GamesPlayedHook { + +// Serialize a protobuf message body object to raw bytes. Returns a pointer to a +// thread-local buffer valid until the next call on the same thread; sets *outLen. +// Installed by the platform layer so this module stays free of protobuf plumbing. +using SerializeBodyFn = const uint8_t* (*)(void* bodyObj, size_t* outLen); +void SetSerializer(SerializeBodyFn fn); + +// Resolve CCMInterface::Send in the loaded steamclient.so and install an inline +// detour that observes outbound GamesPlayed broadcasts. Safe to call once after +// steamclient is mapped and relocated. Returns true if the detour was installed. +bool Install(uintptr_t steamclientBase, size_t steamclientSize); + +// Remove the detour and wait for in-flight observers to drain. +void Remove(); + +} // namespace GamesPlayedHook diff --git a/src/platform/linux/init.cpp b/src/platform/linux/init.cpp index 80148c73..e38704af 100644 --- a/src/platform/linux/init.cpp +++ b/src/platform/linux/init.cpp @@ -230,6 +230,12 @@ static void DoInit() if (!pbOk) Log::Error("Protobuf helpers not fully resolved -- cloud RPCs may fail"); + // Observe outbound CMsgClientGamesPlayed to track namespace-app playtime. + if (pbOk) + { + CloudHooks::InstallGamesPlayedObserver(steamBase, steamSize); + } + // Sweep stray *.cloudredirect metadata from userdata/{app}/remote/. { std::string steamPath = CloudIntercept::GetSteamPath(); diff --git a/src/platform/linux/live_playtime.cpp b/src/platform/linux/live_playtime.cpp new file mode 100644 index 00000000..ef3520d9 --- /dev/null +++ b/src/platform/linux/live_playtime.cpp @@ -0,0 +1,322 @@ +#include "live_playtime.h" +#include "log.h" + +#include <atomic> +#include <cstring> +#include <csetjmp> +#include <csignal> +#include <mutex> +#include <queue> +#include <sys/mman.h> +#include <unistd.h> + +namespace LivePlaytime { + +// Resolved steamclient.so entry points (file offsets from IDA, base added at +// runtime; sig-scanned below). +// sub_182F530 writer: (CUser*, Game** games, int count) -> syncTime +// sub_182F840 wrapper: (CUser*, Response_msg*) -> calls writer (games@+20,count@+12) +// sub_2AC91C0 msg ctor: zeroes the CProtoBufMsg wrapper +// sub_2ACA490 msg init: allocates the inner MessageLite body at wrapper[8] +// sub_2AC8970 msg dtor +// sub_1132CD0 registry int write (CUser+2648 obj, 3, key, val) +// off_2E15B4C CProtoBufMsg<...Response> typed wrapper vtable +// off_2EA3FFC CPlayer_GetLastPlayedTimes_Response descriptor +using WriterFn = int(*)(int pUser, int games, int count); +using WrapperFn = int(*)(int pUser, int respMsg); +using MsgCtorFn = void(*)(int self, int a2, int a3); +using MsgInitFn = void(*)(int self); +using MsgDtorFn = void(*)(int self); + +static uintptr_t g_base = 0; +static ParseFromArrayFn g_parseFromArray = nullptr; +static WrapperFn g_wrapper = nullptr; // sub_182F840 +static MsgCtorFn g_msgCtor = nullptr; +static MsgInitFn g_msgInit = nullptr; +static MsgDtorFn g_msgDtor = nullptr; +static uintptr_t g_respWrapperVt = 0; // base + 0x2E15B4C +static uintptr_t g_respDescriptor = 0; // base + 0x2EA3FFC + +// Inner CPlayer_GetLastPlayedTimes_Response (32-bit): games array ptr @ +20, +// element count @ +12. CProtoBufMsg wrapper: inner body at wrapper[8]. +static constexpr size_t RESP_OFF_GAMES_ARRAY = 20; +static constexpr size_t RESP_OFF_GAMES_COUNT = 12; +static constexpr size_t WRAP_INNER_SLOT = 8; // wrapper[8] = m_pProtoBufBody +static constexpr size_t WRAP_DWORDS = 12; // wrapper allocation (msg ctor writes up to +0x1C) + +// Captured CUser pointer (recorded by the writer-entry detour). +static std::atomic<int> g_pUser{0}; + +// Signature scan; PIC call/add displacements are wildcarded. +struct Sig { const uint8_t* bytes; const uint8_t* mask; size_t len; }; + +// writer sub_182F530: E8 ?? ?? ?? ?? 05 ?? ?? ?? ?? 55 89 E5 57 56 53 83 EC 3C 8B 4D 10 89 45 D0 85 C9 0F 8E +static const uint8_t kWriterB[] = {0xE8,0,0,0,0, 0x05,0,0,0,0, 0x55,0x89,0xE5,0x57,0x56,0x53,0x83,0xEC,0x3C,0x8B,0x4D,0x10,0x89,0x45,0xD0,0x85,0xC9,0x0F,0x8E}; +static const uint8_t kWriterM[] = {1,0,0,0,0, 1,0,0,0,0, 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1}; + +// Wrapper sub_182F840 loads games@[a2+20]/count@[a2+12]; Apply() inlines those, +// so only the writer + message helpers are sig-scanned. + +// msg ctor sub_2AC91C0: 57 56 53 8B 74 24 10 E8 ?? ?? ?? ?? 81 C3 ?? ?? ?? ?? 83 EC 08 C7 46 04 00 00 00 00 +static const uint8_t kCtorB[] = {0x57,0x56,0x53,0x8B,0x74,0x24,0x10,0xE8,0,0,0,0,0x81,0xC3,0,0,0,0,0x83,0xEC,0x08,0xC7,0x46,0x04,0,0,0,0}; +static const uint8_t kCtorM[] = {1,1,1,1,1,1,1,1,0,0,0,0,1,1,0,0,0,0,1,1,1,1,1,1,0,0,0,0}; + +// msg init sub_2ACA490: 56 53 E8 ?? ?? ?? ?? 81 C3 ?? ?? ?? ?? 83 EC 04 8B 74 24 10 8B 46 20 85 C0 74 22 +static const uint8_t kInitB[] = {0x56,0x53,0xE8,0,0,0,0,0x81,0xC3,0,0,0,0,0x83,0xEC,0x04,0x8B,0x74,0x24,0x10,0x8B,0x46,0x20,0x85,0xC0,0x74,0x22}; +static const uint8_t kInitM[] = {1,1,1,0,0,0,0,1,1,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1}; + +// msg dtor sub_2AC8970: 57 56 E8 ?? ?? ?? ?? 81 C6 ?? ?? ?? ?? 53 8B 5C 24 10 8B 53 28 +static const uint8_t kDtorB[] = {0x57,0x56,0xE8,0,0,0,0,0x81,0xC6,0,0,0,0,0x53,0x8B,0x5C,0x24,0x10,0x8B,0x53,0x28}; +static const uint8_t kDtorM[] = {1,1,1,0,0,0,0,1,1,0,0,0,0,1,1,1,1,1,1,1,1}; + +static void* ScanSig(uintptr_t base, size_t size, const uint8_t* b, const uint8_t* m, size_t len) { + if (size < len) return nullptr; + const uint8_t* s = (const uint8_t*)base; + const uint8_t* end = s + size - len; + for (; s <= end; ++s) { + bool ok = true; + for (size_t i = 0; i < len; ++i) + if (m[i] && s[i] != b[i]) { ok = false; break; } + if (ok) return (void*)s; + } + return nullptr; +} + +// Crash guard: a layout mismatch in the parse/writer calls faults to SIGSEGV; +// convert it to a skipped update instead of a crash. +static sigjmp_buf g_jmp; +static volatile sig_atomic_t g_inCall = 0; +static void CrashHandler(int sig) { + if (g_inCall) siglongjmp(g_jmp, sig); + raise(sig); +} +class CallGuard { +public: + CallGuard() { + struct sigaction sa = {}; + sa.sa_handler = CrashHandler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESETHAND; + sigaction(SIGSEGV, &sa, &m_segv); + sigaction(SIGBUS, &sa, &m_bus); + g_inCall = 1; + } + ~CallGuard() { + g_inCall = 0; + sigaction(SIGSEGV, &m_segv, nullptr); + sigaction(SIGBUS, &m_bus, nullptr); + } +private: + struct sigaction m_segv = {}; + struct sigaction m_bus = {}; +}; + +// CUser-capture detour on the writer entry. Writer (__cdecl) prologue: +// +0x00 call get_pc_thunk; +0x05 add eax,delta (PIC) +// +0x0A push ebp; +0x0B mov ebp,esp +// +0x0D push edi; +0x0E push esi; +0x0F push ebx; +0x10 sub esp,0x3C +// Detour at +0x0D (frame established, a1=CUser at [ebp+8]); the stolen bytes are +// position-independent and run in the trampoline before resuming. +static constexpr size_t WRITER_PROLOGUE = 0x0D; // detour point offset +static constexpr size_t WRITER_STOLEN = 6; // 57 56 53 83 EC 3C +static constexpr size_t WRITER_RESUME = WRITER_PROLOGUE + WRITER_STOLEN; + +static std::atomic<bool> g_captureInstalled{false}; +static uint8_t* g_writerStart = nullptr; +static uint8_t* g_hookPoint = nullptr; +static uint8_t g_savedBytes[16]; +static size_t g_savedLen = 0; +static uint8_t* g_trampoline = nullptr; + +// Recorded on the writer's natural entry. ebp+8 = a1 = CUser. +extern "C" void LivePlaytime_CaptureUser(int pUser) { + if (pUser && !g_pUser.load(std::memory_order_acquire)) { + g_pUser.store(pUser, std::memory_order_release); + LOG("[Stats] Captured CUser=%p for live playtime updates", (void*)(uintptr_t)pUser); + } +} + +static bool MakeWritable(void* addr, size_t len) { + long ps = sysconf(_SC_PAGESIZE); + uintptr_t page = (uintptr_t)addr & ~(uintptr_t)(ps - 1); + uintptr_t endA = (uintptr_t)addr + len; + size_t pl = ((endA - page) + (ps - 1)) & ~(size_t)(ps - 1); + return mprotect((void*)page, pl, PROT_READ | PROT_WRITE | PROT_EXEC) == 0; +} + +// Trampoline: run stolen bytes, then read CUser from [ebp+8] and call the +// capture hook, then resume. ebp is already valid at the hook point. +// <stolen: push edi; push esi; push ebx; sub esp,0x3C> +// pushad +// push dword [ebp+8] ; a1 = CUser +// mov eax, LivePlaytime_CaptureUser +// call eax +// add esp, 4 +// popad +// push <resume> +// ret +static bool BuildTrampoline(uint8_t* hookPoint) { + long ps = sysconf(_SC_PAGESIZE); + g_trampoline = (uint8_t*)mmap(nullptr, ps, PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (g_trampoline == MAP_FAILED) { g_trampoline = nullptr; return false; } + + uint8_t* p = g_trampoline; + auto emit = [&](std::initializer_list<uint8_t> b) { for (uint8_t x : b) *p++ = x; }; + auto emit32 = [&](uint32_t v) { *p++=v&0xFF; *p++=(v>>8)&0xFF; *p++=(v>>16)&0xFF; *p++=(v>>24)&0xFF; }; + + memcpy(p, hookPoint, WRITER_STOLEN); // stolen prologue insns + p += WRITER_STOLEN; + + emit({0x60}); // pushad + emit({0xFF, 0x75, 0x08}); // push dword [ebp+8] (CUser) + emit({0xB8}); emit32((uint32_t)(uintptr_t)&LivePlaytime_CaptureUser); // mov eax, hook + emit({0xFF, 0xD0}); // call eax + emit({0x83, 0xC4, 0x04}); // add esp, 4 + emit({0x61}); // popad + + uintptr_t resume = (uintptr_t)g_writerStart + WRITER_RESUME; + emit({0x68}); emit32((uint32_t)resume); // push resume + emit({0xC3}); // ret + return true; +} + +bool Resolve(uintptr_t base, size_t size, ParseFromArrayFn parse) { + g_base = base; + g_parseFromArray = parse; + + void* writer = ScanSig(base, size, kWriterB, kWriterM, sizeof(kWriterB)); + void* ctor = ScanSig(base, size, kCtorB, kCtorM, sizeof(kCtorB)); + void* init = ScanSig(base, size, kInitB, kInitM, sizeof(kInitB)); + void* dtor = ScanSig(base, size, kDtorB, kDtorM, sizeof(kDtorB)); + + if (!writer || !ctor || !init || !dtor) { + LOG("[Stats] LivePlaytime: signature scan incomplete (writer=%p ctor=%p init=%p dtor=%p) -- live UI updates disabled", + writer, ctor, init, dtor); + return false; + } + + g_writerStart = (uint8_t*)writer; + g_msgCtor = (MsgCtorFn)ctor; + g_msgInit = (MsgInitFn)init; + g_msgDtor = (MsgDtorFn)dtor; + g_respWrapperVt = base + 0x2E15B4C; + g_respDescriptor = base + 0x2EA3FFC; + + LOG("[Stats] LivePlaytime resolved: writer=%p ctor=%p init=%p dtor=%p", + writer, ctor, init, dtor); + return true; +} + +bool InstallUserCapture() { + if (!g_writerStart) return false; + bool expected = false; + if (!g_captureInstalled.compare_exchange_strong(expected, true)) return true; + + g_hookPoint = g_writerStart + WRITER_PROLOGUE; + if (!BuildTrampoline(g_hookPoint)) { + g_captureInstalled.store(false); + return false; + } + g_savedLen = WRITER_STOLEN; + memcpy(g_savedBytes, g_hookPoint, g_savedLen); + if (!MakeWritable(g_hookPoint, g_savedLen)) { + g_captureInstalled.store(false); + return false; + } + int32_t rel = (int32_t)((uintptr_t)g_trampoline - ((uintptr_t)g_hookPoint + 5)); + g_hookPoint[0] = 0xE9; + memcpy(g_hookPoint + 1, &rel, 4); + for (size_t i = 5; i < g_savedLen; ++i) g_hookPoint[i] = 0x90; // nop pad + __builtin___clear_cache((char*)g_hookPoint, (char*)g_hookPoint + g_savedLen); + LOG("[Stats] LivePlaytime CUser-capture detour installed at %p", g_hookPoint); + return true; +} + +void RemoveUserCapture() { + if (!g_captureInstalled.load(std::memory_order_acquire)) return; + if (g_hookPoint && MakeWritable(g_hookPoint, g_savedLen)) { + memcpy(g_hookPoint, g_savedBytes, g_savedLen); + __builtin___clear_cache((char*)g_hookPoint, (char*)g_hookPoint + g_savedLen); + } + g_captureInstalled.store(false, std::memory_order_release); +} + +bool Ready() { + return g_msgCtor && g_msgInit && g_msgDtor && g_parseFromArray && + g_pUser.load(std::memory_order_acquire) != 0; +} + +void Apply(const std::vector<uint8_t>& respBody) { + if (respBody.empty() || !Ready()) return; + int pUser = g_pUser.load(std::memory_order_acquire); + + // CProtoBufMsg<CPlayer_GetLastPlayedTimes_Response> on the stack (sub_182F8A0): + // ctor zeroes it, [0]=typed vtable, [1]=descriptor, init allocates the inner + // MessageLite body at wrapper[8]. + uint32_t wrapper[WRAP_DWORDS] = {0}; + + CallGuard guard; + if (sigsetjmp(g_jmp, 1) != 0) { + LOG("[Stats] LivePlaytime::Apply crashed in steamclient call -- skipped"); + return; + } + + g_msgCtor((int)(uintptr_t)wrapper, 0, 0); + wrapper[0] = (uint32_t)g_respWrapperVt; + wrapper[1] = (uint32_t)g_respDescriptor; + g_msgInit((int)(uintptr_t)wrapper); + + int inner = (int)wrapper[WRAP_INNER_SLOT]; + if (!inner) { + LOG("[Stats] LivePlaytime::Apply: inner body alloc failed"); + return; + } + + if (!g_parseFromArray((void*)(uintptr_t)inner, respBody.data(), (int)respBody.size())) { + LOG("[Stats] LivePlaytime::Apply: ParseFromArray failed (%zu bytes)", respBody.size()); + g_msgDtor((int)(uintptr_t)wrapper); + return; + } + + // sub_182F840: v = *(inner+20); if (v) v += 4; writer(pUser, v, *(inner+12)). + int arrayBase = *(int*)((uint8_t*)(uintptr_t)inner + RESP_OFF_GAMES_ARRAY); + int count = *(int*)((uint8_t*)(uintptr_t)inner + RESP_OFF_GAMES_COUNT); + int games = arrayBase ? (arrayBase + 4) : 0; + + if (games && count > 0) { + auto writer = (WriterFn)(g_writerStart); + writer(pUser, games, count); + LOG("[Stats] LivePlaytime::Apply: pushed %d game(s) to live client map", count); + } else { + LOG("[Stats] LivePlaytime::Apply: no games parsed (count=%d)", count); + } + + g_msgDtor((int)(uintptr_t)wrapper); +} + +// ── Net-thread-safe queue ────────────────────────────────────────────────── +static std::queue<std::vector<uint8_t>> g_queue; +static std::mutex g_queueMutex; + +void Queue(const std::vector<uint8_t>& respBody) { + if (respBody.empty()) return; + std::lock_guard<std::mutex> lock(g_queueMutex); + g_queue.push(respBody); +} + +void DrainOnNetThread() { + if (!Ready()) return; + std::vector<std::vector<uint8_t>> batch; + { + std::lock_guard<std::mutex> lock(g_queueMutex); + while (!g_queue.empty()) { + batch.push_back(std::move(g_queue.front())); + g_queue.pop(); + } + } + for (auto& body : batch) + Apply(body); +} + +} // namespace LivePlaytime diff --git a/src/platform/linux/live_playtime.h b/src/platform/linux/live_playtime.h new file mode 100644 index 00000000..a325a7c9 --- /dev/null +++ b/src/platform/linux/live_playtime.h @@ -0,0 +1,47 @@ +#pragma once +#include <cstddef> +#include <cstdint> +#include <vector> + +// Pushes another device's playtime into the running client's tracking map + +// library UI. Parses a CPlayer_GetLastPlayedTimes_Response and drives the writer +// (steamclient.so sub_182F530), which updates m_mapAppMinutesPlayed, the +// localconfig keys, and posts the 1020046 AppMinutesPlayedDataNotice callback -- +// the same write sub_182F8A0 performs after its GetLastPlayedTimes RPC. +// +// PlayerClient.NotifyLastPlayedTimes#1 is a service-method listener, not a +// job-name route, so a synthesized packet cannot be routed to it; the direct +// write is the only path to a live update. +namespace LivePlaytime { + +using ParseFromArrayFn = int(*)(void* msg, const void* data, int len); + +// Resolve the steamclient.so functions by signature. Returns true if all the +// required entry points were found (otherwise live updates are unavailable and +// the poller's on-disk merge still applies on Steam's next natural refresh). +bool Resolve(uintptr_t steamclientBase, size_t steamclientSize, ParseFromArrayFn parseFromArray); + +// Install the CUser-capture detour on the writer's entry. The first natural +// playtime write (post-logon refresh) records the active CUser pointer, which we +// reuse to drive our own updates. Safe no-op if Resolve failed. +bool InstallUserCapture(); +void RemoveUserCapture(); + +// Apply a serialized CPlayer_GetLastPlayedTimes_Response (repeated Game games) +// to the running client. No-op until the CUser has been captured. Must run on +// Steam's network thread. +void Apply(const std::vector<uint8_t>& respBody); + +// Queue a serialized response body from any thread; the net-thread drain applies +// it. The background cloud poller uses this so the writer (which touches Steam's +// minutes-played map and posts callbacks) only runs on the network thread. +void Queue(const std::vector<uint8_t>& respBody); + +// Apply all queued bodies. Caller MUST be on Steam's network thread (invoked +// from the GamesPlayed send observer and the GetLastPlayedTimes transport hook). +void DrainOnNetThread(); + +// True once the CUser pointer has been captured and updates can be applied. +bool Ready(); + +} // namespace LivePlaytime diff --git a/src/platform/linux/stats_hooks.cpp b/src/platform/linux/stats_hooks.cpp new file mode 100644 index 00000000..45e3d7ff --- /dev/null +++ b/src/platform/linux/stats_hooks.cpp @@ -0,0 +1,94 @@ +#include "stats_hooks.h" +#include "stats_handlers.h" +#include "metadata_sync.h" +#include "cloud_intercept.h" +#include "protobuf.h" +#include "log.h" + +#include <cstring> + +namespace StatsHooks { + +static SerializeFn g_serialize; +static ParseFn g_parse; + +void SetProtobufHelpers(SerializeFn serialize, ParseFn parse) { + g_serialize = std::move(serialize); + g_parse = std::move(parse); +} + +bool TryHandleGetUserStats(const char* methodName, void* request, void* response, int* flags) { + if (!methodName || !g_serialize || !g_parse) return false; + if (strcmp(methodName, StatsHandlers::RPC_GET_USER_STATS) != 0) return false; + if (!MetadataSync::syncAchievements.load(std::memory_order_relaxed)) return false; + if (!request || !response) return false; + + auto reqBytes = g_serialize(request); + if (reqBytes.empty()) return false; + auto reqFields = PB::Parse(reqBytes.data(), reqBytes.size()); + + // appid is field 2 in CPlayer_GetUserStats_Request. + uint32_t appId = 0; + if (auto* f = PB::FindField(reqFields, 2)) appId = (uint32_t)f->varintVal; + if (appId == 0 || !CloudIntercept::IsNamespaceApp(appId)) return false; + + auto res = StatsHandlers::HandleGetUserStats(appId, reqFields); + if (res.body.Size() == 0) { + LOG("[Stats] GetUserStats app=%u: store returned empty -> passthrough", appId); + return false; + } + if (!g_parse(response, res.body.Data().data(), res.body.Size())) { + LOG("[Stats] GetUserStats app=%u: ParseFromArray failed -> passthrough", appId); + return false; + } + if (flags) { + flags[2] = 1; // transport success + flags[3] = res.eresult; // eresult + } + LOG("[Stats] GetUserStats app=%u handled locally (%zu bytes)", appId, res.body.Size()); + return true; +} + +void MergeLastPlayedTimes(const char* methodName, void* request, void* response) { + if (!methodName || !g_serialize || !g_parse) return; + if (strcmp(methodName, StatsHandlers::RPC_GET_LAST_PLAYED) != 0) return; + if (!MetadataSync::syncPlaytime.load(std::memory_order_relaxed)) return; + if (!response) return; + + std::vector<PB::Field> reqFields; + if (request) { + auto reqBytes = g_serialize(request); + if (!reqBytes.empty()) + reqFields = PB::Parse(reqBytes.data(), reqBytes.size()); + } + + auto ours = StatsHandlers::HandleGetLastPlayedTimes(reqFields); + if (ours.body.Size() == 0) return; + + // Keep the server's response verbatim and append our games[] (field 1, each + // a length-delimited CPlayer_LastPlayedTimes_Game). The client merges by + // appid, so real owned games keep their server playtime. + auto respBytes = g_serialize(response); + PB::Writer merged; + auto existing = PB::Parse(respBytes.data(), respBytes.size()); + for (const auto& f : existing) { + if (f.wireType == PB::Varint) merged.WriteVarint(f.fieldNum, f.varintVal); + else if (f.wireType == PB::Fixed64) merged.WriteFixed64(f.fieldNum, f.varintVal); + else if (f.wireType == PB::Fixed32) merged.WriteFixed32(f.fieldNum, (uint32_t)f.varintVal); + else if (f.wireType == PB::LengthDelimited) merged.WriteBytes(f.fieldNum, f.data, f.dataLen); + } + + auto ourFields = PB::Parse(ours.body.Data().data(), ours.body.Size()); + size_t added = 0; + for (const auto& f : ourFields) { + if (f.fieldNum == 1 && f.wireType == PB::LengthDelimited) { + merged.WriteBytes(1, f.data, f.dataLen); + ++added; + } + } + + if (added > 0 && g_parse(response, merged.Data().data(), merged.Size())) + LOG("[Stats] GetLastPlayedTimes: appended %zu local game(s) to server response", added); +} + +} // namespace StatsHooks diff --git a/src/platform/linux/stats_hooks.h b/src/platform/linux/stats_hooks.h new file mode 100644 index 00000000..dbfd2ef9 --- /dev/null +++ b/src/platform/linux/stats_hooks.h @@ -0,0 +1,45 @@ +#pragma once +#include <cstdint> +#include <vector> +#include <functional> + +// Native achievement / playtime sync for namespace (lua) apps on Linux. +// +// Steam's modern stats path is the Player.* unified service method, carried on +// the same CClientUnifiedServiceTransport vtable the cloud hooks already wrap +// (cloud_hooks.cpp slots 5/7/8). These entry points let the cloud hooks hand +// off Player.GetUserStats#1 / Player.ClientGetLastPlayedTimes#1 without knowing +// the stats internals. + +namespace StatsHooks { + +// Serialize a live protobuf message object to raw bytes (msg vtable ByteSizeLong +// + SerializeToArray). Provided by cloud_hooks.cpp. +using SerializeFn = std::function<std::vector<uint8_t>(void* msg)>; +// Parse raw bytes into a live protobuf message object (ParseFromArray). +using ParseFn = std::function<bool(void* msg, const uint8_t* data, size_t len)>; + +void SetProtobufHelpers(SerializeFn serialize, ParseFn parse); + +// Attempt to answer a Player.* stats service method from the local store. +// +// Returns true if the method was handled locally (the caller must then return +// success and must NOT forward to the real server). Returns false to pass the +// call through unchanged. +// +// GetUserStats#1 -> answered entirely from the store (gated on +// MetadataSync::syncAchievements) +// ClientGetLastPlayedTimes#1-> the caller forwards to the server FIRST, then +// passes the real response here to append our +// namespace apps' playtime (gated on syncPlaytime) +// +// `request` / `response` are the live protobuf message objects from the hook. +// `flags` is the transport flag array (flags[2]=transport ok, flags[3]=eresult). +bool TryHandleGetUserStats(const char* methodName, void* request, void* response, int* flags); + +// Merge our namespace apps' playtime into an already-populated server response. +// Call AFTER the real server reply succeeds. No-op if syncPlaytime is off or we +// have nothing to add. +void MergeLastPlayedTimes(const char* methodName, void* request, void* response); + +} // namespace StatsHooks diff --git a/src/platform/win/cloud_intercept.cpp b/src/platform/win/cloud_intercept.cpp index b08e582d..29f20405 100644 --- a/src/platform/win/cloud_intercept.cpp +++ b/src/platform/win/cloud_intercept.cpp @@ -75,6 +75,8 @@ static constexpr uint32_t EMSG_CLIENT_PICSPRODUCTINFO = 8903; static constexpr uint32_t EMSG_CLIENT_GAMES_PLAYED = 742; static constexpr uint32_t EMSG_CLIENT_GAMES_PLAYED_NO_DATABLOB = 715; static constexpr uint32_t EMSG_CLIENT_GAMES_PLAYED_WITH_DATABLOB = 5410; +// CMsgClientStoreUserStats2 -- sent when a game unlocks an achievement / sets a stat. +static constexpr uint32_t EMSG_CLIENT_STORE_USER_STATS2 = 5466; // CMsgClientGamesPlayed protobuf field numbers static constexpr uint32_t GP_FIELD_GAMES_PLAYED = 1; // repeated GamePlayed (length-delimited) @@ -106,6 +108,11 @@ static constexpr uint32_t CPROTOBUFMSG_OFF_CONN = 0x1C; // uint32_t connectio static constexpr uint32_t CPROTOBUFMSG_OFF_EMSG = 0x20; // uint32_t EMsg | PROTO_FLAG static constexpr uint32_t CPROTOBUFMSG_OFF_BODY = 0x30; // protobuf body object* +// g_pJobCur (qword_1397DC0C0): the CJob coroutine currently running; its CJobID +// is at CJob+32 (GetJobID, asserted at userremotestorage.cpp:3920). +static constexpr uintptr_t SC_RVA_JOBCUR_GLOBAL = 0x17DC0C0; +static constexpr uint32_t JOB_OFF_JOBID = 32; + // Schema-fetch injection: build a CMsgClientGetUserStats (EMsg 818) and send it // via BAsyncSend, asking the server for the latest achievement schema of any app // (schema_local_version=-1) on behalf of an owning SteamID (steam_id_for_user). @@ -143,6 +150,31 @@ static constexpr uintptr_t SC_RVA_SERVICE_TRANSPORT_VT = 0x1247A70; static constexpr uintptr_t SC_RVA_PARSE_FROM_ARRAY = 0xBC42F0; // sub_138BE7A40 = protobuf SerializeToArray (writes body to raw bytes) static constexpr uintptr_t SC_RVA_SERIALIZE_TO_ARRAY = 0xBC4700; +// Live playtime update -- the write half of CUser's playtime refresh +// (sub_1389DA1D0), driven from a synthesized response instead of a CM RPC. +// sub_1389C7930 = the writer: iterates the parsed Game array, updates +// m_mapTrackingPlaytimeForApp, writes localconfig (sub_1389CB7D0), +// and fires the 1020046 "minutes played changed" UI callback. +// sub_138CF07F0 = CProtoBufMsg ctor (zeroes the wrapper) +// sub_138CF3390 = CProtoBufMsg init (allocates the inner MessageLite body at [6]) +// sub_138CF0AA0 = CProtoBufMsg dtor +// off_1396C1360 = CPlayer_GetLastPlayedTimes_Response type descriptor +// ??_7CProtoBufMsg<...Response> = typed wrapper vtable +// off_1396D3F48 = "Software\\Valve\\Steam\\LastPlayedTimesSyncTime" registry key +static constexpr uintptr_t SC_RVA_PLAYTIME_WRITER = 0x9C7930; +static constexpr uintptr_t SC_RVA_MSG_CTOR = 0xCF07F0; +static constexpr uintptr_t SC_RVA_MSG_INIT = 0xCF3390; +static constexpr uintptr_t SC_RVA_MSG_DTOR = 0xCF0AA0; +static constexpr uintptr_t SC_RVA_RESP_DESCRIPTOR = 0x16C1360; +static constexpr uintptr_t SC_RVA_RESP_WRAPPER_VT = 0x1323380; +// off_1396D3F48: pointer to "Software\\Valve\\Steam\\LastPlayedTimesSyncTime" +static constexpr uintptr_t SC_RVA_REGKEY_SYNCTIME = 0x16D3F48; +// CUser member offsets used by the writer path +static constexpr uint32_t USER_OFF_REGISTRY = 3272; // CUser+0xCC8: registry obj (sync-time write) +// Inner CPlayer_GetLastPlayedTimes_Response message offsets +static constexpr uint32_t RESP_OFF_GAMES_COUNT = 24; // repeated games: element count +static constexpr uint32_t RESP_OFF_GAMES_ARRAY = 32; // repeated games: array base ptr + // CSteamEngine layout offsets static constexpr uint32_t ENGINE_OFF_JOBMGR = 592; // CJobMgr embedded at CSteamEngine+592 static constexpr uint32_t ENGINE_OFF_GLOBAL_HANDLE = 3144; // uint32_t: global user handle @@ -425,6 +457,11 @@ static bool HasNamespaceApps() { return !g_namespaceApps.empty(); } +static std::vector<uint32_t> GetNamespaceApps() { + std::lock_guard<std::mutex> lock(g_namespaceAppsMutex); + return std::vector<uint32_t>(g_namespaceApps.begin(), g_namespaceApps.end()); +} + uint32_t GetAccountId(); // defined later void RequestSchemaForApp(uint32_t appId); // defined later (schema auto-fetch) @@ -673,6 +710,19 @@ static uint64_t GetJobIdSource(const std::vector<PB::Field>& header) { return f ? f->varintVal : JOBID_NONE; } +// Jobid of the running coroutine. Correlates an outbound 818 with the 819 we +// inject back: the framework stamps the header's jobid_source only after our send +// hook, but the sending job already holds the id. SEH-isolated against a faulting +// global read (no C++ objects in scope). +static uint64_t ReadCurrentJobId() { + uint64_t jobId = 0; + __try { + uintptr_t jobCur = *(uintptr_t*)(g_steamClientBase + SC_RVA_JOBCUR_GLOBAL); + if (jobCur) jobId = *(uint64_t*)(jobCur + JOB_OFF_JOBID); + } __except(EXCEPTION_EXECUTE_HANDLER) { jobId = 0; } + return jobId; +} + static std::vector<uint8_t> BuildPacket(uint32_t emsg, const PB::Writer& header, const PB::Writer& body) { uint32_t emsgRaw = emsg | PROTO_FLAG; uint32_t headerLen = (uint32_t)header.Size(); @@ -783,7 +833,7 @@ struct QueuedInjection { uint32_t pktSize; CNetPacket* pktStruct; // malloc'd CNetPacket uint64_t jobIdTarget; // job to route the response to - uint32_t emsg; // EMsg type (147 = response, 152 = send-to-client) + uint32_t emsg; // EMsg type (147 service-method resp, 819 legacy user-stats resp) char methodName[128]; }; @@ -796,6 +846,35 @@ static thread_local bool t_drainingInjectQueue = false; static void ProcessQueuedInjection(QueuedInjection* ctx); // defined below +// Live playtime-update queue. The writer touches the CUser map + UI callbacks, so +// it must run on the net thread; the poller only enqueues the serialized response +// body and the net-thread drain applies it via ApplyLastPlayedUpdate. +static std::queue<std::vector<uint8_t>> g_playtimeUpdateQueue; +static std::mutex g_playtimeUpdateMutex; +static void ApplyLastPlayedUpdate(const std::vector<uint8_t>& respBody); // defined below + +// Enqueue a serialized CPlayer_GetLastPlayedTimes_Response body for the net thread +// to push into the running client's playtime map. Safe to call from any thread. +static void QueueLastPlayedUpdate(const std::vector<uint8_t>& respBody) { + if (respBody.empty()) return; + std::lock_guard<std::mutex> lock(g_playtimeUpdateMutex); + g_playtimeUpdateQueue.push(respBody); +} + +// Drain queued playtime updates. Caller MUST be on Steam's network thread. +static void DrainPlaytimeUpdateQueueOnNetThread() { + std::vector<std::vector<uint8_t>> batch; + { + std::lock_guard<std::mutex> lock(g_playtimeUpdateMutex); + while (!g_playtimeUpdateQueue.empty()) { + batch.push_back(std::move(g_playtimeUpdateQueue.front())); + g_playtimeUpdateQueue.pop(); + } + } + for (auto& body : batch) + ApplyLastPlayedUpdate(body); +} + // ── Schema-request queue ────────────────────────────────────────────────── // Outbound GetUserStats schema requests MUST be sent on Steam's network thread: // BAsyncSend touches per-thread pipe/coroutine TLS, and calling it from an @@ -954,35 +1033,45 @@ static void ProcessQueuedInjection(QueuedInjection* ctx) { LOG("[INJECT] jobMgr=%p route: tgt=%llu emsg=%d flags=%d", jobMgr, (unsigned long long)ctx->jobIdTarget, route.emsg, route.flags); - // Pre-check: verify job still exists. BRouteMsgToJob silently no-ops on a - // missing slot but returns 1, which would log a false success while the - // game's pending download silently fails. - using FindJobFn = int(__fastcall*)(void* slotMap, void* pJobId); - FindJobFn findJob = (FindJobFn)(g_steamClientBase + SC_RVA_FIND_JOB); - int jobSlot = -1; - bool findJobThrew = false; - __try { - void* slotMap = (void*)((uintptr_t)jobMgr + 0x200); - jobSlot = findJob(slotMap, &route.jobidTarget); - if (jobSlot >= 0) { - uintptr_t slotArr = *(uintptr_t*)((uintptr_t)jobMgr + 0x230); - void* cjobPtr = *(void**)(slotArr + (uintptr_t)jobSlot * 24 + 8); - uint32_t jobState = cjobPtr ? *(uint32_t*)((uintptr_t)cjobPtr + 0x84) : 999; - LOG("[INJECT] FindJob slot=%d cjob=%p state=%u", jobSlot, cjobPtr, jobState); - } else { - LOG("[INJECT] FindJob: job not found (slot=%d) -- timed out, dropping inject for %s", - jobSlot, ctx->methodName); + // Two routing modes in BRouteMsgToJob (sub_138D02320, jobmgr.cpp): + // - RESPONSE: matched to a WAITING job by jobid_target (resume-by-jobid). + // - NOTIFICATION: no waiting job; routed by target_job_name via + // GMapJobTypesByName().Find(name) -> spawns the registered listener job. + // A notification (jobIdTarget == JOBID_NONE) must SKIP the waiting-job + // pre-check, otherwise we'd drop a perfectly routable server push. + bool isNotification = (ctx->jobIdTarget == JOBID_NONE); + if (!isNotification) { + // Pre-check: verify the waiting job still exists. BRouteMsgToJob silently + // no-ops on a missing slot but returns 1 (false success), masking a + // dropped response. + using FindJobFn = int(__fastcall*)(void* slotMap, void* pJobId); + FindJobFn findJob = (FindJobFn)(g_steamClientBase + SC_RVA_FIND_JOB); + int jobSlot = -1; + bool findJobThrew = false; + __try { + void* slotMap = (void*)((uintptr_t)jobMgr + 0x200); + jobSlot = findJob(slotMap, &route.jobidTarget); + if (jobSlot >= 0) { + uintptr_t slotArr = *(uintptr_t*)((uintptr_t)jobMgr + 0x230); + void* cjobPtr = *(void**)(slotArr + (uintptr_t)jobSlot * 24 + 8); + uint32_t jobState = cjobPtr ? *(uint32_t*)((uintptr_t)cjobPtr + 0x84) : 999; + LOG("[INJECT] FindJob slot=%d cjob=%p state=%u", jobSlot, cjobPtr, jobState); + } else { + LOG("[INJECT] FindJob: job not found (slot=%d) -- timed out, dropping inject for %s", + jobSlot, ctx->methodName); + } + } __except(EXCEPTION_EXECUTE_HANDLER) { + LOG("[INJECT] EXCEPTION in FindJob: code=0x%08X", GetExceptionCode()); + findJobThrew = true; } - } __except(EXCEPTION_EXECUTE_HANDLER) { - LOG("[INJECT] EXCEPTION in FindJob: code=0x%08X", GetExceptionCode()); - findJobThrew = true; - } - if (jobSlot < 0 && !findJobThrew) { - // Drop without routing: BRouteMsgToJob would log a misleading "success" otherwise. - __try { g_releaseWrapped(wrappedPkt); } __except(EXCEPTION_EXECUTE_HANDLER) {} - VirtualFree(ctx->pktBuf, 0, MEM_RELEASE); - delete ctx; - return; + if (jobSlot < 0 && !findJobThrew) { + __try { g_releaseWrapped(wrappedPkt); } __except(EXCEPTION_EXECUTE_HANDLER) {} + VirtualFree(ctx->pktBuf, 0, MEM_RELEASE); + delete ctx; + return; + } + } else { + LOG("[INJECT] notification %s: routing by target_job_name (no waiting job)", ctx->methodName); } // Increment refcount (matches RecvPkt at 0x13859D4CC) @@ -1108,7 +1197,131 @@ static bool InjectResponse(uint64_t jobIdTarget, const std::string& methodName, return true; } +// Route a 819 response built from the store to the waiting CAPIJobRequestUserStats +// by jobid_target. Unlike InjectResponse this is a raw EMsg, not a service-method +// response: the header carries only steamid + jobid_target, no target_job_name. +// Otherwise it shares the inject queue / drain / cleanup path. +static bool InjectLegacyUserStatsResponse(uint64_t jobIdTarget, uint32_t appId, + const std::vector<uint8_t>& body) { + if (!g_wrapPacket || !g_bRouteMsgToJob || !g_releaseWrapped || !g_cmInterface) + return false; + + // 819 header: steamid (validates against the connection) + jobid_target (the + // value the framework stamped as jobid_source on the outbound 818). + PB::Writer hdr; + if (g_steamId.load()) hdr.WriteFixed64(HDR_STEAMID, g_steamId.load()); + hdr.WriteFixed64(HDR_JOBID_TARGET, jobIdTarget); + + // Frame: [emsg|protoflag (4)][hdrLen (4)][header][body]; body length is implicit + // (cubData carries the total). BuildPacket takes a body Writer, so frame with an + // empty body and append the already-serialized 819 bytes. + PB::Writer emptyBody; + auto pktData = BuildPacket(EMSG_CLIENT_GET_USER_STATS_RESP, hdr, emptyBody); + pktData.insert(pktData.end(), body.begin(), body.end()); + + uint8_t* pktBuf = (uint8_t*)VirtualAlloc(nullptr, pktData.size(), + MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if (!pktBuf) return false; + memcpy(pktBuf, pktData.data(), pktData.size()); + + auto* fakePkt = (CNetPacket*)malloc(sizeof(CNetPacket)); + if (!fakePkt) { VirtualFree(pktBuf, 0, MEM_RELEASE); return false; } + memset(fakePkt, 0, sizeof(CNetPacket)); + fakePkt->pubData = pktBuf; + fakePkt->cubData = (uint32_t)pktData.size(); + fakePkt->m_cRef = 1; + + auto* ctx = new QueuedInjection(); + ctx->pktBuf = pktBuf; + ctx->pktSize = (uint32_t)pktData.size(); + ctx->pktStruct = fakePkt; + ctx->jobIdTarget = jobIdTarget; + ctx->emsg = EMSG_CLIENT_GET_USER_STATS_RESP; + snprintf(ctx->methodName, sizeof(ctx->methodName), "ClientGetUserStatsResponse(app=%u)", appId); + + { + std::lock_guard<std::mutex> lock(g_injectMutex); + g_injectQueue.push(ctx); + } + LOG("[Stats] Queued legacy 819 for app=%u jobid=%llu (%zu bytes)", + appId, (unsigned long long)jobIdTarget, pktData.size()); + return true; +} + +// Push another device's playtime into the client's tracking map + library UI by +// feeding a synthesized response to Steam's own writer (sub_1389C7930: updates +// m_mapTrackingPlaytimeForApp, localconfig, fires the 1020046 UI callback). +// Direct-write because NotifyLastPlayedTimes#1 is a service-method listener, not +// in GMapJobTypesByName, so BRouteMsgToJob can't route to it. +// respBody = serialized CPlayer_GetLastPlayedTimes_Response. Net thread only. +static void ApplyLastPlayedUpdate(const std::vector<uint8_t>& respBody) { + if (!g_parseFromArray || respBody.empty()) return; + + uintptr_t pUser = FindCurrentUser(); + if (!pUser) { + LOG("[Stats] ApplyLastPlayedUpdate: no current user"); + return; + } + + // CProtoBufMsg<CPlayer_GetLastPlayedTimes_Response> on the stack (sub_1389DA1D0): + // ctor zeroes the wrapper, [0]=typed vtable, [1]=descriptor, init allocates the + // inner MessageLite body at wrapper[6]. + uintptr_t wrapper[11] = {0}; + using MsgCtorFn = void(__fastcall*)(void* self, int a2, int a3); + using MsgInitFn = void(__fastcall*)(void* self); + using MsgDtorFn = void(__fastcall*)(void* self); + using WriterFn = uint32_t(__fastcall*)(uintptr_t pUser, uintptr_t gamesArray, int count); + using RegWriteFn = void(__fastcall*)(void* reg, int type, const char* key, uint32_t val); + + auto msgCtor = (MsgCtorFn)(g_steamClientBase + SC_RVA_MSG_CTOR); + auto msgInit = (MsgInitFn)(g_steamClientBase + SC_RVA_MSG_INIT); + auto msgDtor = (MsgDtorFn)(g_steamClientBase + SC_RVA_MSG_DTOR); + auto writer = (WriterFn) (g_steamClientBase + SC_RVA_PLAYTIME_WRITER); + + __try { + msgCtor(wrapper, 0, 0); + wrapper[0] = g_steamClientBase + SC_RVA_RESP_WRAPPER_VT; + wrapper[1] = g_steamClientBase + SC_RVA_RESP_DESCRIPTOR; + msgInit(wrapper); + + uintptr_t inner = wrapper[6]; // m_pProtoBufBody (the inner MessageLite) + if (!inner) { + LOG("[Stats] ApplyLastPlayedUpdate: inner body alloc failed"); + return; + } + if (!g_parseFromArray((void*)inner, (const char*)respBody.data(), (int)respBody.size())) { + LOG("[Stats] ApplyLastPlayedUpdate: ParseFromArray failed (%zu bytes)", respBody.size()); + msgDtor(wrapper); + return; + } + + // sub_1389DA1D0: v2 = *(inner+32); games = (v2 ? v2+8 : 0); count = *(inner+24) + uintptr_t arrayBase = *(uintptr_t*)(inner + RESP_OFF_GAMES_ARRAY); + int count = *(int*)(inner + RESP_OFF_GAMES_COUNT); + uintptr_t games = arrayBase ? (arrayBase + 8) : 0; + + if (games && count > 0) { + uint32_t syncTime = writer(pUser, games, count); + // Persist LastPlayedTimesSyncTime (sub_1389DA1D0 tail). + __try { + uintptr_t reg = pUser + USER_OFF_REGISTRY; + auto regWrite = (RegWriteFn)(*(uintptr_t*)(*(uintptr_t*)reg + 80)); + const char* key = *(const char**)(g_steamClientBase + SC_RVA_REGKEY_SYNCTIME); + regWrite((void*)reg, 3, key, syncTime); + } __except(EXCEPTION_EXECUTE_HANDLER) { + LOG("[Stats] ApplyLastPlayedUpdate: sync-time write threw (non-fatal)"); + } + LOG("[Stats] ApplyLastPlayedUpdate: pushed %d game(s) to live client map", count); + } else { + LOG("[Stats] ApplyLastPlayedUpdate: no games parsed (count=%d)", count); + } + + msgDtor(wrapper); + } __except(EXCEPTION_EXECUTE_HANDLER) { + LOG("[Stats] ApplyLastPlayedUpdate: EXCEPTION code=0x%08X", GetExceptionCode()); + } +} uint32_t GetAccountId() { return (uint32_t)(g_steamId.load() & 0xFFFFFFFF); @@ -2421,6 +2634,7 @@ static int64_t __fastcall RecvPktMonitorHook(void* thisptr, CNetPacket* pkt) { // Drain on the network-recv thread (valid Coroutine_Continue TLS). DrainInjectQueueOnNetThread(); DrainSchemaQueueOnNetThread(); // schema sends must run on the net thread + DrainPlaytimeUpdateQueueOnNetThread(); // live playtime push (touches CUser map) if (!pkt || !pkt->pubData || pkt->cubData < 8) return g_originalRecvPkt(thisptr, pkt); @@ -4035,11 +4249,11 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa outJson.assign(reinterpret_cast<const char*>(data.data()), data.size()); return true; }, - // push: upload the per-app stats blob (fire-and-forget) + // push: queue the per-app stats blob (serialized on the cloud work queue) [](uint32_t appId, const std::string& json) { uint32_t accountId = GetAccountId(); if (accountId == 0) return; - CloudStorage::UploadCloudMetadataText(accountId, appId, "stats.json", json); + CloudStorage::UploadCloudMetadataTextAsync(accountId, appId, "stats.json", json); }); // Restrict all playtime/stats tracking to namespace/lua apps only -- real // owned games must never have their playtime recorded or synced. @@ -4047,6 +4261,7 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa // Resolve current accountId lazily so the store can import Steam's native // UserGameStats blobs (appcache\stats\UserGameStats_<accountId>_<appId>.bin). StatsStore::SetAccountIdProvider([]() -> uint32_t { return GetAccountId(); }); + StatsStore::SetNamespacePredicate([](uint32_t appId) { return IsNamespaceApp(appId); }); // When an import finds no achievement schema, fetch it from Steam's server. StatsStore::SetSchemaMissingCallback([](uint32_t appId) { // Run off-thread: this is called under the store mutex during import, @@ -4055,6 +4270,29 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa }); StatsStore::Init(cloudRoot, g_steamPath); StatsHandlers::Init(); + StatsStore::SeedApps(GetNamespaceApps()); + + // Background: poll the cloud for another device's playtime advances and push the + // new totals into the running client's tracking map + library UI -- mirroring + // Steam's own CUser playtime refresh write path, minus the network round-trip. + // The writer must run on Steam's network thread, so we only enqueue here; the + // net-thread drain (DrainPlaytimeUpdateQueueOnNetThread) applies it. + { + std::thread poller([] { + for (;;) { + for (int i = 0; i < 60 && !g_shuttingDown.load(); ++i) + std::this_thread::sleep_for(std::chrono::seconds(1)); + if (g_shuttingDown.load()) return; + auto changed = StatsStore::RefreshFromCloud(GetNamespaceApps()); + if (changed.empty()) continue; + PB::Writer body = StatsHandlers::BuildLastPlayedNotificationBody(changed); + if (body.Size() > 0) + QueueLastPlayedUpdate(body.Data()); + } + }); + std::lock_guard<std::mutex> lock(g_bgThreadsMutex); + g_bgThreads.push_back(std::move(poller)); + } SteamKvInjector::Init(); @@ -4323,6 +4561,53 @@ static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { RewriteGamesPlayedBody(bodyObj); } } + else if (emsg == EMSG_CLIENT_STORE_USER_STATS2 && + MetadataSync::syncAchievements.load(std::memory_order_relaxed)) { + // The client sends this when a game unlocks an achievement / sets a + // stat. The body has no unlock timestamps, but Steam writes the native + // blob with fresh AchievementTimes in the same store job, so we re-read + // it here to sync the new unlocks. + void* bodyObj = *(void**)((uintptr_t)pMsg + CPROTOBUFMSG_OFF_BODY); + if (bodyObj) { + auto observeBytes = SerializeBodyToBytes(bodyObj); + if (!observeBytes.empty()) { + LOG("[Stats] StoreUserStats2 observed (emsg=%u, %zu bytes) -> capturing unlocks", + emsg, observeBytes.size()); + StatsHandlers::ObserveStoreUserStats(observeBytes.data(), observeBytes.size()); + } + } + } + else if (emsg == EMSG_CLIENT_GET_USER_STATS && + MetadataSync::syncAchievements.load(std::memory_order_relaxed)) { + // Namespace apps fetch stats over the legacy 818 path (appid below the + // service-method threshold; see CAPIJobRequestUserStats_BYieldingRun), + // so serve a 819 from the store. The job MERGES our stats into its + // native blob loaded from disk (YieldingMergeStats -> "using SERVER + // stats data"); with no local UserGameStats_<acct>_<appid>.bin the load + // fails and it skips, so this surfaces another device's unlocks only + // when the game has been run here. + void* bodyObj = *(void**)((uintptr_t)pMsg + CPROTOBUFMSG_OFF_BODY); + if (bodyObj) { + auto reqBytes = SerializeBodyToBytes(bodyObj); + if (!reqBytes.empty()) { + auto fields = PB::Parse(reqBytes.data(), reqBytes.size()); + auto* f1 = PB::FindField(fields, 1); // game_id (fixed64) + uint32_t appId = f1 ? (uint32_t)(f1->varintVal & 0xFFFFFF) : 0; + if (appId != 0 && IsNamespaceApp(appId)) { + uint64_t jobId = ReadCurrentJobId(); + auto built = StatsHandlers::HandleLegacyGetUserStats( + reqBytes.data(), reqBytes.size(), g_steamId.load()); + if (built.has_value() && !built->empty()) { + LOG("[Stats] GetUserStats(818) observed app=%u jobid=%llu -> serving 819", + appId, (unsigned long long)jobId); + InjectLegacyUserStatsResponse(jobId, appId, *built); + } else { + LOG("[Stats] GetUserStats(818) app=%u: store had nothing to serve", appId); + } + } + } + } + } } return g_basOriginal(pMsg, connHandle); @@ -4830,6 +5115,7 @@ bool OnSendPkt(void* thisptr, const uint8_t* data, uint32_t size) { // packets are far more frequent during the cloud-RPC bursts that fill the queue. DrainInjectQueueOnNetThread(); DrainSchemaQueueOnNetThread(); // schema sends must run on the net thread + DrainPlaytimeUpdateQueueOnNetThread(); // live playtime push (touches CUser map) // Try to discover the real CCMInterface via CSteamEngine global. // This also installs the vtable hook once CCMInterface is found. From 27ac5ce05a5cf40d215fa7d0a01a745184f5620a Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:13:01 -0400 Subject: [PATCH 15/24] Gate stats sync behind per-feature toggles --- src/common/stats_store.cpp | 7 ++++-- src/platform/linux/cloud_hooks.cpp | 34 ++++++++++++++++++++-------- src/platform/win/cloud_intercept.cpp | 2 ++ 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/common/stats_store.cpp b/src/common/stats_store.cpp index 273693ac..abb254b2 100644 --- a/src/common/stats_store.cpp +++ b/src/common/stats_store.cpp @@ -3,6 +3,7 @@ #include "vdf.h" #include "log.h" #include "file_util.h" +#include "metadata_sync.h" #include <fstream> #include <sstream> @@ -1012,8 +1013,10 @@ void EndSession(uint32_t appId) { stats.playtime.lastPlayedTime = now; // Steam flushes the native blob on game close; merge any new unlocks (also - // catches another device's, so they're never lost). - if (ReimportNativeStatsLocked(appId, stats)) + // catches another device's). Gated on sync_achievements, not sync_playtime + // (EndSession runs under the latter). + if (MetadataSync::syncAchievements.load(std::memory_order_relaxed) && + ReimportNativeStatsLocked(appId, stats)) LOG("[Stats] Session end: merged new native achievements/stats for app %u (crc=%u)", appId, stats.crcStats); diff --git a/src/platform/linux/cloud_hooks.cpp b/src/platform/linux/cloud_hooks.cpp index d871331f..151293d1 100644 --- a/src/platform/linux/cloud_hooks.cpp +++ b/src/platform/linux/cloud_hooks.cpp @@ -237,17 +237,31 @@ void CloudHooks::InstallGamesPlayedObserver(uintptr_t steamclientBase, size_t st LOG("[GamesPlayed] serializer not resolved -- playtime tracking disabled"); return; } - GamesPlayedHook::SetSerializer(&SerializeBodyTL); - GamesPlayedHook::Install(steamclientBase, steamclientSize); - // Resolve the playtime writer/message helpers and install the CUser-capture - // detour used to apply cross-device playtime updates live. - if (LivePlaytime::Resolve(steamclientBase, steamclientSize, g_parseFromArray)) - LivePlaytime::InstallUserCapture(); + const bool playtime = MetadataSync::syncPlaytime.load(std::memory_order_relaxed); + const bool achievements = MetadataSync::syncAchievements.load(std::memory_order_relaxed); - // Resolve the packet-wrap + job-routing functions used to serve the legacy - // CMsgClientGetUserStats (818) achievement fetch with a 819 response. - AchievementInject::Resolve(steamclientBase, steamclientSize, &SerializeBodyTL); + // Install only the detours a feature needs (a disabled one adds no trampoline). + // The CCMInterface::Send observer is shared (GamesPlayed 5410 = playtime; + // StoreUserStats2/GetUserStats 5466/818 = achievements), so install it if either + // wants it. + if (playtime || achievements) { + GamesPlayedHook::SetSerializer(&SerializeBodyTL); + GamesPlayedHook::Install(steamclientBase, steamclientSize); + } else { + LOG("[Stats] playtime + achievement sync off -- CCMInterface::Send observer not installed"); + } + + // CUser-capture detour serves live playtime only; skip when playtime is off. + if (playtime) { + if (LivePlaytime::Resolve(steamclientBase, steamclientSize, g_parseFromArray)) + LivePlaytime::InstallUserCapture(); + } + + // Resolve (no detour, just function pointers) the packet-wrap + job-routing + // used to serve the legacy 818 achievement fetch with a 819 response. + if (achievements) + AchievementInject::Resolve(steamclientBase, steamclientSize, &SerializeBodyTL); } static std::optional<CloudIntercept::RpcResult> DispatchCloudRpc( @@ -382,6 +396,8 @@ static void EnsureInitialized() { for (int i = 0; i < 60 && !g_shuttingDown.load(std::memory_order_acquire); ++i) sleep(1); if (g_shuttingDown.load(std::memory_order_acquire)) return; + // Pure playtime feature: skip the cloud pull + live push when off. + if (!MetadataSync::syncPlaytime.load(std::memory_order_relaxed)) continue; auto changed = StatsStore::RefreshFromCloud(CloudIntercept::GetNamespaceApps()); if (!changed.empty() && LivePlaytime::Ready()) { PB::Writer body = StatsHandlers::BuildLastPlayedNotificationBody(changed); diff --git a/src/platform/win/cloud_intercept.cpp b/src/platform/win/cloud_intercept.cpp index 29f20405..cfeccfb4 100644 --- a/src/platform/win/cloud_intercept.cpp +++ b/src/platform/win/cloud_intercept.cpp @@ -4283,6 +4283,8 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa for (int i = 0; i < 60 && !g_shuttingDown.load(); ++i) std::this_thread::sleep_for(std::chrono::seconds(1)); if (g_shuttingDown.load()) return; + // Pure playtime feature: skip the cloud pull + live push when off. + if (!MetadataSync::syncPlaytime.load(std::memory_order_relaxed)) continue; auto changed = StatsStore::RefreshFromCloud(GetNamespaceApps()); if (changed.empty()) continue; PB::Writer body = StatsHandlers::BuildLastPlayedNotificationBody(changed); From 8a58bb6b99c98973698c044db66863d553cead44 Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:22:56 -0400 Subject: [PATCH 16/24] Add Linux UI controls for stats sync --- flatpak/org.cloudredirect.CloudRedirect.yml | 2 +- src/common/metadata_sync.cpp | 6 +- src/platform/win/cloud_intercept.cpp | 4 +- ui-linux/CMakeLists.txt | 1 + ui-linux/qml/Main.qml | 2 + ui-linux/qml/pages/StatsPage.qml | 101 ++++++++++++++++++++ ui-linux/src/backend.cpp | 20 ++++ ui-linux/src/backend.h | 11 +++ 8 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 ui-linux/qml/pages/StatsPage.qml diff --git a/flatpak/org.cloudredirect.CloudRedirect.yml b/flatpak/org.cloudredirect.CloudRedirect.yml index 06b3560f..95ebeed4 100644 --- a/flatpak/org.cloudredirect.CloudRedirect.yml +++ b/flatpak/org.cloudredirect.CloudRedirect.yml @@ -45,7 +45,7 @@ modules: no-debuginfo: true config-opts: - -DCMAKE_BUILD_TYPE=Release - - -DCR_RELEASE_VERSION=2.1.5-final + - -DCR_RELEASE_VERSION=2.2.0-TEST1 sources: - type: dir path: ../ui-linux diff --git a/src/common/metadata_sync.cpp b/src/common/metadata_sync.cpp index 20745307..c3e277e1 100644 --- a/src/common/metadata_sync.cpp +++ b/src/common/metadata_sync.cpp @@ -4,9 +4,9 @@ namespace MetadataSync { std::atomic<bool> steamToolsPresent{false}; std::atomic<bool> syncLuas{false}; -// Default ON: stats/playtime sync is the expected behavior; the user opts out. -std::atomic<bool> syncAchievements{true}; -std::atomic<bool> syncPlaytime{true}; +// Default OFF: WIP opt-in features; the user enables them. +std::atomic<bool> syncAchievements{false}; +std::atomic<bool> syncPlaytime{false}; // Default OFF: experimental opt-in feature. std::atomic<bool> schemaFetch{false}; diff --git a/src/platform/win/cloud_intercept.cpp b/src/platform/win/cloud_intercept.cpp index cfeccfb4..d8ad9b29 100644 --- a/src/platform/win/cloud_intercept.cpp +++ b/src/platform/win/cloud_intercept.cpp @@ -4187,8 +4187,8 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa if (cfg["sync_luas"].type == Json::Type::Bool) MetadataSync::syncLuas = cfg["sync_luas"].boolean(); } - // Native stats/playtime sync gates. Absent -> keep default (ON). When - // off, the matching native path does not interfere with Steam at all. + // Native stats/playtime sync gates. Absent -> keep default (OFF, WIP). + // When off, the matching native path does not interfere with Steam at all. if (cfg["sync_achievements"].type == Json::Type::Bool) MetadataSync::syncAchievements = cfg["sync_achievements"].boolean(); if (cfg["sync_playtime"].type == Json::Type::Bool) diff --git a/ui-linux/CMakeLists.txt b/ui-linux/CMakeLists.txt index 83e697c7..05dcdc51 100644 --- a/ui-linux/CMakeLists.txt +++ b/ui-linux/CMakeLists.txt @@ -68,6 +68,7 @@ qt_add_qml_module(cloud-redirect-ui qml/pages/AppsPage.qml qml/pages/BackupsPage.qml qml/pages/CloudProviderPage.qml + qml/pages/StatsPage.qml ) target_include_directories(cloud-redirect-ui PRIVATE src) diff --git a/ui-linux/qml/Main.qml b/ui-linux/qml/Main.qml index 1807707b..ef03f197 100644 --- a/ui-linux/qml/Main.qml +++ b/ui-linux/qml/Main.qml @@ -175,6 +175,7 @@ ApplicationWindow { TabButton { text: "Backups" } TabButton { text: "Cloud Provider" } TabButton { text: "Setup" } + TabButton { text: "Stats Sync" } } StackLayout { @@ -187,6 +188,7 @@ ApplicationWindow { BackupsPage {} CloudProviderPage {} SetupPage {} + StatsPage {} } } } diff --git a/ui-linux/qml/pages/StatsPage.qml b/ui-linux/qml/pages/StatsPage.qml new file mode 100644 index 00000000..3556101f --- /dev/null +++ b/ui-linux/qml/pages/StatsPage.qml @@ -0,0 +1,101 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +Page { + title: "Stats Sync" + + ScrollView { + anchors.fill: parent + contentWidth: availableWidth + + ColumnLayout { + width: parent.width + spacing: 12 + + Item { height: 8 } + + Label { + text: "Stats Sync" + font.pointSize: 16 + font.bold: true + Layout.leftMargin: 20 + } + + Label { + text: "Changes apply the next time Steam starts." + opacity: 0.7 + Layout.leftMargin: 20 + Layout.rightMargin: 20 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + + Frame { + Layout.fillWidth: true + Layout.leftMargin: 20 + Layout.rightMargin: 20 + + RowLayout { + anchors.fill: parent + spacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Label { + text: "Sync Achievements" + font.bold: true + } + Label { + text: "blah blah wip wip" + opacity: 0.7 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + Switch { + checked: backend ? backend.syncAchievements : false + onToggled: { if (backend) backend.syncAchievements = checked } + } + } + } + + Frame { + Layout.fillWidth: true + Layout.leftMargin: 20 + Layout.rightMargin: 20 + + RowLayout { + anchors.fill: parent + spacing: 8 + + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + + Label { + text: "Sync Playtime" + font.bold: true + } + Label { + text: "blah blah wip wip" + opacity: 0.7 + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + + Switch { + checked: backend ? backend.syncPlaytime : false + onToggled: { if (backend) backend.syncPlaytime = checked } + } + } + } + + Item { Layout.fillHeight: true } + } + } +} diff --git a/ui-linux/src/backend.cpp b/ui-linux/src/backend.cpp index 0f52abd7..5361316e 100644 --- a/ui-linux/src/backend.cpp +++ b/ui-linux/src/backend.cpp @@ -286,6 +286,8 @@ void Backend::loadConfig() m_providerName = obj.value("provider").toString("local"); m_syncFolderPath = obj.value("sync_folder_path").toString(); m_notificationsEnabled = obj.value("notifications_enabled").toBool(true); + m_syncAchievements = obj.value("sync_achievements").toBool(false); + m_syncPlaytime = obj.value("sync_playtime").toBool(false); fprintf(stderr, "[Backend] loadConfig: provider=%s syncFolder=%s notifications=%s\n", m_providerName.toUtf8().constData(), m_syncFolderPath.toUtf8().constData(), m_notificationsEnabled ? "true" : "false"); @@ -354,6 +356,8 @@ void Backend::saveConfig() obj["provider"] = m_providerName; obj["sync_folder_path"] = m_syncFolderPath; obj["notifications_enabled"] = m_notificationsEnabled; + obj["sync_achievements"] = m_syncAchievements; + obj["sync_playtime"] = m_syncPlaytime; // Atomic write: write to temp, then rename QString tempPath = configPath + ".tmp"; @@ -408,6 +412,22 @@ void Backend::setNotificationsEnabled(bool enabled) saveConfig(); } +bool Backend::syncAchievements() const { return m_syncAchievements; } +void Backend::setSyncAchievements(bool enabled) +{ + if (m_syncAchievements == enabled) return; + m_syncAchievements = enabled; + saveConfig(); +} + +bool Backend::syncPlaytime() const { return m_syncPlaytime; } +void Backend::setSyncPlaytime(bool enabled) +{ + if (m_syncPlaytime == enabled) return; + m_syncPlaytime = enabled; + saveConfig(); +} + QVariantList Backend::getManagedApps() { QVariantList list; diff --git a/ui-linux/src/backend.h b/ui-linux/src/backend.h index 79f8d5fc..1d2a8103 100644 --- a/ui-linux/src/backend.h +++ b/ui-linux/src/backend.h @@ -21,6 +21,8 @@ class Backend : public QObject Q_PROPERTY(QString syncFolderPath READ syncFolderPath WRITE setSyncFolderPath NOTIFY settingsChanged) Q_PROPERTY(bool providerAuthenticated READ providerAuthenticated NOTIFY settingsChanged) Q_PROPERTY(bool notificationsEnabled READ notificationsEnabled WRITE setNotificationsEnabled NOTIFY settingsChanged) + Q_PROPERTY(bool syncAchievements READ syncAchievements WRITE setSyncAchievements NOTIFY settingsChanged) + Q_PROPERTY(bool syncPlaytime READ syncPlaytime WRITE setSyncPlaytime NOTIFY settingsChanged) Q_PROPERTY(QString accountId READ accountId NOTIFY statusChanged) Q_PROPERTY(QString accountName READ accountName NOTIFY statusChanged) Q_PROPERTY(QString version READ version CONSTANT) @@ -46,6 +48,10 @@ class Backend : public QObject bool providerAuthenticated() const; bool notificationsEnabled() const; void setNotificationsEnabled(bool enabled); + bool syncAchievements() const; + void setSyncAchievements(bool enabled); + bool syncPlaytime() const; + void setSyncPlaytime(bool enabled); Q_INVOKABLE QVariantList getManagedApps(); Q_INVOKABLE QVariantList getAppDetails(); @@ -111,6 +117,11 @@ class Backend : public QObject QString m_syncFolderPath; bool m_providerAuthenticated = false; bool m_notificationsEnabled = true; + // Stats sync gates -- mirror the native MetadataSync flags / config keys the + // Linux side honors (schema fetch is Windows-only, so it is not exposed here). + // Default OFF: WIP opt-in features. + bool m_syncAchievements = false; + bool m_syncPlaytime = false; struct AppInfo { uint32_t appId; From dacc503073443f279609cddc24c0f7d0928f297d Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:22:01 -0400 Subject: [PATCH 17/24] Add multi-root cloud sync, native-faithful eviction, and Steam 1781041600 support --- CMakeLists.txt | 22 -- cli-dotnet/SteamDetector.cs | 2 +- src/common/app_state.cpp | 107 +++++- src/common/app_state.h | 15 + src/common/autocloud_scan.cpp | 70 +--- src/common/autocloud_scan.h | 29 +- src/common/autocloud_util.h | 60 +--- src/common/cloud_storage.cpp | 141 +++++++- src/common/metadata_sync.cpp | 2 + src/common/metadata_sync.h | 25 ++ src/common/rpc_handlers.cpp | 509 ++++++++++++++++++++++----- src/common/stats_handlers.cpp | 18 +- src/common/stats_store.cpp | 257 ++++++++++++-- src/common/stats_store.h | 41 ++- src/common/steam_kv_injector.cpp | 208 ++++++----- src/common/steam_kv_injector.h | 14 +- src/platform/linux/cloud_hooks.cpp | 78 ++-- src/platform/win/cloud_intercept.cpp | 246 ++++++++++--- ui/Pages/SettingsPage.xaml | 16 + ui/Pages/SettingsPage.xaml.cs | 24 +- ui/Resources/Strings.resx | 2 +- ui/Services/SteamDetector.cs | 2 +- 22 files changed, 1388 insertions(+), 500 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 27e4c42b..552310b9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -341,28 +341,6 @@ if(EXISTS ${CMAKE_SOURCE_DIR}/tests/autocloud_native_tests.cpp) ) target_include_directories(vdf_tests PRIVATE src/common) add_test(NAME vdf_tests COMMAND vdf_tests) - - # Mixed-root quota multiplier collision accounting (header-only logic in - # autocloud_util.h). Pulls log + platform helpers because autocloud_util.h - # includes log.h / file_util.h transitively. - if(WIN32) - add_executable(rule_collision_tests - tests/rule_collision_tests.cpp - src/platform/win/log.cpp - src/platform/win/platform_win.cpp - ) - target_include_directories(rule_collision_tests PRIVATE src/common src/platform/win) - target_compile_definitions(rule_collision_tests PRIVATE CLOUDREDIRECT_TESTING) - target_link_libraries(rule_collision_tests PRIVATE Shlwapi Advapi32 Shell32 Ole32) - else() - add_executable(rule_collision_tests - tests/rule_collision_tests.cpp - src/platform/linux/log.cpp - ) - target_include_directories(rule_collision_tests PRIVATE src/common src/platform/linux) - target_compile_definitions(rule_collision_tests PRIVATE CLOUDREDIRECT_TESTING) - endif() - add_test(NAME rule_collision_tests COMMAND rule_collision_tests) endif() # ── UI (Windows only) ─────────────────────────────────────────────────── diff --git a/cli-dotnet/SteamDetector.cs b/cli-dotnet/SteamDetector.cs index 06ec9eda..a72e88ef 100644 --- a/cli-dotnet/SteamDetector.cs +++ b/cli-dotnet/SteamDetector.cs @@ -9,7 +9,7 @@ public static class SteamDetector { private static string? _cachedPath; - public static readonly long[] SupportedSteamVersions = { 1780352834, 1779918128, 1779486452, 1778281814, 1778003620 }; + public static readonly long[] SupportedSteamVersions = { 1781041600, 1780352834, 1779918128, 1779486452, 1778281814, 1778003620 }; public static long ExpectedSteamVersion => SupportedSteamVersions[0]; diff --git a/src/common/app_state.cpp b/src/common/app_state.cpp index 67d4eeaa..a70f2351 100644 --- a/src/common/app_state.cpp +++ b/src/common/app_state.cpp @@ -7,7 +7,10 @@ #include "log.h" #include "manifest_store.h" +#include <chrono> #include <ctime> +#include <mutex> +#include <unordered_map> using CloudIntercept::IsReservedBlobFilename; @@ -15,6 +18,74 @@ namespace CloudStorage { static ICloudProvider* g_stateProvider = nullptr; +// ---- Serve-path cloud-state cache ------------------------------------------- +// Backs FetchCloudStateForServe only. FetchCloudState never reads it; it only +// refreshes it on each live fetch. +namespace { + +// Staleness ceiling. Covers a download burst (serve runs right after the +// GetChangelist that warmed the cache) while bounding cross-machine staleness. +constexpr int64_t kServeCacheMaxAgeMs = 3000; + +struct ServeCacheEntry { + uint64_t cn = 0; + int64_t fetchedAtMs = 0; + bool foreignSession = false; // active session in the fetched state + StateFetchResult result; +}; + +std::mutex g_serveCacheMtx; +std::unordered_map<uint64_t, ServeCacheEntry> g_serveCache; // key = (acct<<32)|app + +// This client's id (set by NoteOwnClientId). Used to tell our own session from a +// foreign one: only a foreign session is contention. Counting ours would disable +// the cache during the very download burst it exists for. Not latched from +// PublishCloudState -- RMW paths can republish a foreign machine's session. +std::atomic<uint64_t> g_ownClientId{0}; + +inline uint64_t ServeCacheKey(uint32_t accountId, uint32_t appId) { + return (static_cast<uint64_t>(accountId) << 32) | appId; +} + +inline int64_t NowMs() { + using namespace std::chrono; + return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count(); +} + +} // namespace + +// See g_ownClientId. +void NoteOwnClientId(uint64_t clientId) { + if (clientId != 0) + g_ownClientId.store(clientId, std::memory_order_relaxed); +} + +// Drop the cached entry for an app. Called on every local state mutation so a +// stale snapshot can never outlive a change CR itself made. +static void InvalidateServeCache(uint32_t accountId, uint32_t appId) { + std::lock_guard<std::mutex> lk(g_serveCacheMtx); + g_serveCache.erase(ServeCacheKey(accountId, appId)); +} + +// Record a live fetch for the serve path to reuse. Latest good parse wins, even +// with a LOWER cn: that's a legitimate rewind (state wiped/recreated), not a +// partial read -- partial reads fail parse upstream and never reach here. +static void RefreshServeCache(uint32_t accountId, uint32_t appId, + const StateFetchResult& result) { + if (result.status != StateFetchStatus::Ok) return; // only cache good reads + std::lock_guard<std::mutex> lk(g_serveCacheMtx); + uint64_t key = ServeCacheKey(accountId, appId); + ServeCacheEntry e; + e.cn = result.state.cn; + e.fetchedAtMs = NowMs(); + // Unknown own id (0) -> any session counts as foreign (conservative). + uint64_t own = g_ownClientId.load(std::memory_order_relaxed); + e.foreignSession = result.state.hasActiveSession() && + result.state.session.clientId != own; + e.result = result; + g_serveCache[key] = std::move(e); +} + void AppState_Init(ICloudProvider* provider) { g_stateProvider = provider; } @@ -180,7 +251,7 @@ bool DeserializeState(const std::string& json, CloudAppState& outState) { static constexpr const char* kStateFilename = "state.cloudredirect"; static constexpr size_t MAX_STATE_SIZE = 16 * 1024 * 1024; // 16 MB -StateFetchResult FetchCloudState(uint32_t accountId, uint32_t appId) { +static StateFetchResult FetchCloudStateLive(uint32_t accountId, uint32_t appId) { InflightSyncScope guard; if (!guard) return { StateFetchStatus::FetchFailed, {}, {} }; if (!g_stateProvider || !g_stateProvider->IsAuthenticated()) @@ -295,6 +366,36 @@ StateFetchResult FetchCloudState(uint32_t accountId, uint32_t appId) { return { StateFetchStatus::FetchFailed, {}, {} }; } +// Public always-fresh fetch. Performs the live read AND refreshes the serve +// cache so the serve path always sees CR's most recent observation. +StateFetchResult FetchCloudState(uint32_t accountId, uint32_t appId) { + StateFetchResult result = FetchCloudStateLive(accountId, appId); + RefreshServeCache(accountId, appId, result); + return result; +} + +// Serve-path accessor: reuse a recent snapshot when provably safe, else live. +StateFetchResult FetchCloudStateForServe(uint32_t accountId, uint32_t appId) { + { + std::lock_guard<std::mutex> lk(g_serveCacheMtx); + auto it = g_serveCache.find(ServeCacheKey(accountId, appId)); + if (it != g_serveCache.end()) { + const ServeCacheEntry& e = it->second; + int64_t ageMs = NowMs() - e.fetchedAtMs; + // Reuse only when fresh and the snapshot had no foreign session; + // otherwise go live (under contention another machine may publish a + // newer state at any moment). + if (ageMs >= 0 && ageMs < kServeCacheMaxAgeMs && !e.foreignSession) { + LOG("[AppState] FetchCloudStateForServe app %u: cache hit (CN=%llu, age=%lldms)", + appId, (unsigned long long)e.cn, (long long)ageMs); + return e.result; + } + } + } + // Miss / stale / contention -> authoritative live fetch (also refreshes cache). + return FetchCloudState(accountId, appId); +} + bool PublishCloudState(uint32_t accountId, uint32_t appId, const CloudAppState& state, const std::string& /*etag*/) { @@ -323,6 +424,10 @@ bool PublishCloudState(uint32_t accountId, uint32_t appId, reinterpret_cast<const uint8_t*>(cnStr.data()), cnStr.size()); } + // A local mutation just landed: the serve cache's snapshot is now stale. + // Drop it so the next serve read re-fetches (and re-warms with the new cn). + InvalidateServeCache(accountId, appId); + LOG("[AppState] PublishCloudState app %u: published CN=%llu, %zu files", appId, state.cn, state.files.size()); return true; diff --git a/src/common/app_state.h b/src/common/app_state.h index ad89d205..1a105af6 100644 --- a/src/common/app_state.h +++ b/src/common/app_state.h @@ -72,8 +72,23 @@ void AppState_Init(ICloudProvider* provider); void AppState_Shutdown(); // Handles migration from old cn.cloudredirect + manifest.cloudredirect. +// Always performs a live provider fetch. Read-modify-write callers (any fetch +// that precedes a PublishCloudState) must use this, not the cached serve +// accessor, to avoid clobbering a concurrent cross-machine update with a stale base. StateFetchResult FetchCloudState(uint32_t accountId, uint32_t appId); +// Cache-aware accessor for the serve path only. Returns a recently-fetched state +// without re-hitting the provider, only when provably safe: +// - cached entry younger than the hard max-age, AND +// - no active foreign session in that snapshot. +// Otherwise delegates to FetchCloudState (live). Invalidated by every local +// mutation. Not for read-modify-write callers. +StateFetchResult FetchCloudStateForServe(uint32_t accountId, uint32_t appId); + +// Report this client's own Steam id so the serve cache treats only foreign-client +// sessions as contention. See g_ownClientId in app_state.cpp. +void NoteOwnClientId(uint64_t clientId); + // If etag is non-empty, uses conditional write (OneDrive). bool PublishCloudState(uint32_t accountId, uint32_t appId, const CloudAppState& state, diff --git a/src/common/autocloud_scan.cpp b/src/common/autocloud_scan.cpp index 096fb333..edee5f52 100644 --- a/src/common/autocloud_scan.cpp +++ b/src/common/autocloud_scan.cpp @@ -487,6 +487,9 @@ static std::vector<AutoCloudRuleNative> LoadAutoCloudRules(const std::string& st if (!overrideRule.root.empty() && (!overrideRule.useInstead.empty() || !overrideRule.addPath.empty() || !overrideRule.pathTransforms.empty())) { + LOG("GetAutoCloudFileList: app %u parsed override root='%s' os='%s' oscompare='%s' useinstead='%s'", + appId, overrideRule.root.c_str(), overrideRule.os.c_str(), + overrideRule.osCompare.c_str(), overrideRule.useInstead.c_str()); overrides.push_back(std::move(overrideRule)); } } @@ -632,6 +635,16 @@ ScanResult GetFileList(const std::string& steamPath, } outResult.hasRules = true; + // Final rule set after overrides. Two rules with the same root/path here were + // not collapsed by the override. + LOG("GetAutoCloudFileList: app %u final rule set (%zu rules) after overrides:", appId, rules.size()); + for (size_t i = 0; i < rules.size(); ++i) { + const auto& r = rules[i]; + LOG(" rule[%zu]: root='%s' cloudRoot='%s' path='%s' resolvedPath='%s' pattern='%s' recursive=%u platforms=0x%X siblings=%zu", + i, r.root.c_str(), r.cloudRoot.c_str(), r.path.c_str(), r.resolvedPath.c_str(), + r.pattern.c_str(), r.recursive ? 1u : 0u, r.platforms, r.siblings.size()); + } + std::filesystem::path appUserdataDir = FileUtil::Utf8ToPath(steamPath) / "userdata" / std::to_string(accountId) / std::to_string(appId); @@ -873,23 +886,6 @@ ScanResult GetFileList(const std::string& steamPath, // Sibling dedupe; separate from primary so siblings can't trip the abort. std::unordered_set<std::string> emittedSiblings; - // Per-rule double-count accounting (see ScanResult comment). YldOnAppExit - // counts each on-disk file once per matching rule, with NO cross-rule dedup -- - // unlike `files` below, which is deduped via seenRootsByCloudPath. We therefore - // tally claims here BEFORE that dedup, so countedInstances reflects what the - // native exit loop actually charges against maxnumfiles. - // - // Each rule that reaches a valid scan dir contributes one AutoCloudRuleClaimTally; - // AggregateRuleCollisions() turns these into countedInstances / collision factor - // / headroom. Keying by the resolved physical dir is what makes this correct - // across Windows/macOS/Linux roots: a Mac-only or Linux-only rule simply never - // resolves to a dir on this OS (platform-filtered or no root mapping), so it is - // absent from the tallies and correctly contributes nothing. - std::vector<AutoCloudUtil::AutoCloudRuleClaimTally> ruleClaimTallies; - // Index into ruleClaimTallies for the rule currently being walked, so the - // considerFile lambda can attribute claims without re-resolving the dir. - size_t activeRuleTallyIdx = static_cast<size_t>(-1); - bool hasRootCollision = false; bool scanLimitHit = false; size_t visitedFiles = 0; @@ -990,26 +986,6 @@ ScanResult GetFileList(const std::string& steamPath, std::string scanRootPrefix = FileUtil::MakePathPrefix(scanRootUtf8); - // Attribution key for double-count accounting: the resolved physical scan - // directory (normalized, case-insensitive). Two effective rules with this - // same key are the collision that YldOnAppExit double-counts. recursive is - // folded in because a recursive and non-recursive rule on the same dir do - // not claim an identical file set, so they should not be treated as one - // collision group for the multiplication factor. - { - // This rule reached a valid, existing scan dir -> it participates in the - // exit walk. Register one tally; claims accrue into it via considerFile. - // recursive is folded into the dir key because a recursive and a - // non-recursive rule on the same dir do not claim an identical file set, - // so they are not one collision group for the multiplication factor. - AutoCloudUtil::AutoCloudRuleClaimTally tally; - tally.physicalDirKey = ToLowerAscii(NormalizeSlashes(scanRootUtf8)) + - (rule.recursive ? "\x01r" : "\x01n"); - tally.siblingWeight = 1 + rule.siblings.size(); - activeRuleTallyIdx = ruleClaimTallies.size(); - ruleClaimTallies.push_back(std::move(tally)); - } - auto considerFile = [&](const std::filesystem::directory_entry& entry) { std::error_code fileEc; // Junction/symlink gate before is_regular_file. @@ -1039,13 +1015,6 @@ ScanResult GetFileList(const std::string& steamPath, if (WildcardMatchInsensitive(exPat, exTarget)) return; } - // This file matches the current rule's pattern -> YldOnAppExit's exit - // loop counts it for THIS rule. Tally the claim now, BEFORE the - // cross-rule dedup below (which only affects our unique `files` list, - // not the native exit count). - if (activeRuleTallyIdx < ruleClaimTallies.size()) - ++ruleClaimTallies[activeRuleTallyIdx].claimedFiles; - std::string cloudPath = normalizedCloudPath.empty() ? relFromRoot : normalizedCloudPath + "/" + relFromRoot; std::string collisionKey = ToLowerAscii(NormalizeSlashes(cloudPath)); auto seenIt = seenRootsByCloudPath.find(collisionKey); @@ -1081,10 +1050,6 @@ ScanResult GetFileList(const std::string& steamPath, siblingRel = NormalizeSlashes(FileUtil::PathToUtf8(siblingPath.filename())); } if (!IsSafeRelativePath(siblingRel)) continue; - // Existing sibling on disk -> YldOnAppExit counts it for this rule - // too. Tally before dedup, same as the primary claim above. - if (activeRuleTallyIdx < ruleClaimTallies.size()) - ++ruleClaimTallies[activeRuleTallyIdx].claimedFiles; std::string siblingCloudPath = normalizedCloudPath.empty() ? siblingRel : normalizedCloudPath + "/" + siblingRel; @@ -1143,15 +1108,6 @@ ScanResult GetFileList(const std::string& steamPath, LOG("GetAutoCloudFileList: aborting app %u bootstrap due to root/path collision", appId); } - // Finalize per-rule double-count accounting for the quota multiplier. These - // describe what CAutoCloudManager::YldOnAppExit charges against maxnumfiles, - // which (unlike outResult.files) is NOT cross-rule deduped on the exit path. - AutoCloudUtil::RuleCollisionAggregate agg = - AutoCloudUtil::AggregateRuleCollisions(ruleClaimTallies); - outResult.countedInstances = agg.countedInstances; - outResult.maxCollisionFactor = agg.maxCollisionFactor; - outResult.collisionSiblingHeadroom = agg.collisionSiblingHeadroom; - LOG("GetAutoCloudFileList: found %zu rule-matched Auto-Cloud files for app %u (scanLimitHit=%d, hasRootCollision=%d)", outResult.files.size(), appId, (int)scanLimitHit, (int)hasRootCollision); for (const auto& fe : outResult.files) { diff --git a/src/common/autocloud_scan.h b/src/common/autocloud_scan.h index f896b23d..0a0b1111 100644 --- a/src/common/autocloud_scan.h +++ b/src/common/autocloud_scan.h @@ -30,30 +30,11 @@ struct ScanResult { bool hasRules = false; // true if app has AutoCloud rules in appinfo.vdf bool hasRootCollision = false; // true if two rules resolved to same path under different roots - // Per-rule double-count accounting for the mixed-root quota multiplier. - // - // Steam's CAutoCloudManager::YldOnAppExit walks the savefiles rules and counts - // each matching file once PER RULE -- the native per-rule dedup (sub_1384D1DA0 - // @ 0x1384d221a) is dead on the exit path because YldOnAppExit seeds it with a - // null "previous path" (it is only live on the staging/save path). So when two - // effective-platform rules resolve to the same directory (via rootoverrides), - // every file there is counted twice against maxnumfiles -> false over-quota -> - // cloud wipe. `files` below is the UNIQUE set (cross-rule deduped at scan time); - // these two fields capture what the native exit loop actually counts so the - // multiplier can size the budget to the real worst case rather than a blunt - // fileCount*ruleCount. - - // Total file-claims summed across all effective rules (counts a file once for - // each rule, including siblings, whose resolved scan dir + pattern match it). - // This equals the instance count YldOnAppExit charges against maxnumfiles. - size_t countedInstances = 0; - // Largest number of effective rules that resolve to (and claim files in) the - // same physical directory. 1 = no collision (native dedup irrelevant, no wipe - // risk); >1 = the per-file multiplication factor on the exit path. - size_t maxCollisionFactor = 0; - // Sum of (1 + siblingCount) over rules that participate in a collision; mirrors - // the extra budget YldOnAppExit's loop consumes per file beyond the raw count. - size_t collisionSiblingHeadroom = 0; + // `files` is the unique set, cross-rule deduped by cloud-relative path. Steam's + // exit walk logs "Persisting" per matching rule but dedups by cloud path at the + // remotecache layer ("Skipping un-modified file"), so this is one entry per + // distinct cloud path. (Note: native's over-quota eviction does count per rule- + // instance against maxnumfiles -- see ApplyNativeOverQuotaEviction.) }; // Scan AutoCloud rules for an app and return matching files from disk. diff --git a/src/common/autocloud_util.h b/src/common/autocloud_util.h index c1c5609d..39847616 100644 --- a/src/common/autocloud_util.h +++ b/src/common/autocloud_util.h @@ -172,57 +172,6 @@ struct AutoCloudRootOverrideNative { std::vector<std::pair<std::string, std::string>> pathTransforms; }; -// Per-rule tally produced by the AutoCloud scan walk: how many on-disk files -// (primaries + existing siblings) the rule claimed, the resolved physical -// directory it scanned (case-normalized + recursive flag), and its sibling -// weight (1 + siblingCount). Feeds RuleCollisionAggregate below. -struct AutoCloudRuleClaimTally { - std::string physicalDirKey; // normalized resolved scan dir (+ recursion tag) - size_t claimedFiles = 0; // files this rule matched on disk (pre cross-rule dedup) - size_t siblingWeight = 1; // 1 + rule.siblings.size() -}; - -// What CAutoCloudManager::YldOnAppExit charges against maxnumfiles, derived from -// per-rule claim tallies. This is the pure, filesystem-free core of the mixed-root -// quota multiplier so it can be unit-tested across Windows/macOS/Linux rule shapes. -struct RuleCollisionAggregate { - // Sum of claimedFiles across all rules == the per-rule, NON-deduped instance - // count the exit walk charges (a file in a dir hit by N rules counts N times). - size_t countedInstances = 0; - // Largest number of distinct rules resolving to one physical directory == the - // per-file multiplication factor on the exit path. 1 = no collision. - size_t maxCollisionFactor = 0; - // Budget slack for the worst-colliding dir: (#rules there) * (max siblingWeight - // there), mirroring the (1 + siblingCount) the exit loop consumes per rule pass. - size_t collisionSiblingHeadroom = 0; -}; - -// Aggregate per-rule claim tallies into the exit-walk accounting. Rules that -// claimed zero files still count toward the collision factor IF they resolved to a -// shared directory (they participate in the walk), but only directories that other -// rules also resolve to can produce a factor > 1. macOS/Linux-only rules that -// never resolve to a dir on this OS are simply absent from `tallies` and thus -// correctly contribute nothing. -inline RuleCollisionAggregate AggregateRuleCollisions( - const std::vector<AutoCloudRuleClaimTally>& tallies) { - struct DirAgg { size_t rules = 0; size_t maxSiblingWeight = 1; }; - std::unordered_map<std::string, DirAgg> byDir; - RuleCollisionAggregate out; - for (const auto& t : tallies) { - out.countedInstances += t.claimedFiles; - DirAgg& d = byDir[t.physicalDirKey]; - ++d.rules; - if (t.siblingWeight > d.maxSiblingWeight) d.maxSiblingWeight = t.siblingWeight; - } - for (const auto& [dir, d] : byDir) { - if (d.rules > out.maxCollisionFactor) { - out.maxCollisionFactor = d.rules; - out.collisionSiblingHeadroom = d.rules * d.maxSiblingWeight; - } - } - return out; -} - struct AppInfoKVNode { std::string key; std::string stringValue; @@ -429,8 +378,13 @@ inline void ApplyRootOverridesForPlatform(AutoCloudRuleNative& rule, const std::vector<AutoCloudRootOverrideNative>& overrides, AutoCloudEffectivePlatform platform) { for (const auto& overrideRule : overrides) { - if (!IsRootOverrideActiveForPlatform(overrideRule, platform)) continue; - if (_stricmp(rule.root.c_str(), overrideRule.root.c_str()) != 0) continue; + bool active = IsRootOverrideActiveForPlatform(overrideRule, platform); + bool rootMatch = _stricmp(rule.root.c_str(), overrideRule.root.c_str()) == 0; + LOG("ApplyRootOverride: rule.root='%s' vs override.root='%s' os='%s' active=%d rootMatch=%d -> useinstead='%s'", + rule.root.c_str(), overrideRule.root.c_str(), overrideRule.os.c_str(), + active ? 1 : 0, rootMatch ? 1 : 0, overrideRule.useInstead.c_str()); + if (!active) continue; + if (!rootMatch) continue; if (!overrideRule.useInstead.empty()) { rule.root = overrideRule.useInstead; diff --git a/src/common/cloud_storage.cpp b/src/common/cloud_storage.cpp index fb033006..e099bfcc 100644 --- a/src/common/cloud_storage.cpp +++ b/src/common/cloud_storage.cpp @@ -61,6 +61,10 @@ static std::unordered_set<std::string> g_missingMetadataPaths; static std::mutex g_blobIndexMutex; struct BlobIndex { std::unordered_map<std::string, std::string> filenameToPath; // filename -> full cloud path + // sha1hex -> a cloud path holding that content. Identical-content files store + // bytes only under the FIRST filename that uploaded the sha; retrieval by another + // filename 404s. This map lets RetrieveBlob find bytes by content hash instead. + std::unordered_map<std::string, std::string> shaToPath; // sha1hex -> full cloud path bool populated = false; }; static std::unordered_map<uint64_t, BlobIndex> g_blobIndex; // key = (accountId<<32)|appId @@ -1159,12 +1163,17 @@ static std::string ResolveBlobCloudPath(uint32_t accountId, uint32_t appId, std::string fname = rel.substr(0, lastSlash); // Canonical always wins over legacy (unconditional overwrite). idx.filenameToPath[fname] = fi.path; + // first writer wins; all copies byte-identical. + idx.shaToPath.emplace(leaf, fi.path); continue; } } // Legacy SHA-only: blobs/{sha} - if (isHexSha(rel)) continue; // can't map to filename without manifest + if (isHexSha(rel)) { + idx.shaToPath.emplace(rel, fi.path); // usable by content hash + continue; // can't map to filename without manifest + } // Legacy filename-only: blobs/{filename} -- only if canonical not already set. idx.filenameToPath.emplace(rel, fi.path); @@ -1186,6 +1195,35 @@ static std::string ResolveBlobCloudPath(uint32_t accountId, uint32_t appId, return result; } +// Resolve a blob path by CONTENT HASH (sha1hex), independent of filename. Used +// as a fallback when the {filename}/{sha} path misses because the identical +// content was uploaded under a different filename. Reuses the same index/listing +// as ResolveBlobCloudPath (no extra network on a populated index). +static std::string ResolveBlobCloudPathBySHA(uint32_t accountId, uint32_t appId, + const std::string& shaHex) { + if (shaHex.size() != 40) return {}; + uint64_t key = (static_cast<uint64_t>(accountId) << 32) | appId; + { + std::lock_guard<std::mutex> lk(g_blobIndexMutex); + auto it = g_blobIndex.find(key); + if (it != g_blobIndex.end() && it->second.populated) { + auto sit = it->second.shaToPath.find(shaHex); + if (sit != it->second.shaToPath.end()) return sit->second; + return {}; + } + } + // Populate the index (filename resolver does the listing + fills shaToPath), + // then re-check by sha. + ResolveBlobCloudPath(accountId, appId, /*filename=*/std::string()); + std::lock_guard<std::mutex> lk(g_blobIndexMutex); + auto it = g_blobIndex.find(key); + if (it != g_blobIndex.end() && it->second.populated) { + auto sit = it->second.shaToPath.find(shaHex); + if (sit != it->second.shaToPath.end()) return sit->second; + } + return {}; +} + // Invalidate the blob index for an app (called after uploads/promotions change the layout). static void InvalidateBlobIndex(uint32_t accountId, uint32_t appId) { uint64_t key = (static_cast<uint64_t>(accountId) << 32) | appId; @@ -1229,14 +1267,35 @@ std::vector<uint8_t> RetrieveBlob(uint32_t accountId, uint32_t appId, return {}; } - // CAS: resolve filename->SHA from manifest, download by SHA. + // CAS: resolve filename->SHA, download by SHA. if (cloudActive) { + // Local manifest is only a cache; on a fresh machine it's empty, so SHA + // resolution must fall back to authoritative cloud state. Priority order: + // local manifest -> cloud state -> caller's expectedShaHex (also cloud-derived). Manifest manifest = LoadLocalManifest(accountId, appId); auto mit = manifest.find(filename); std::string shaHex; if (mit != manifest.end() && !mit->second.sha.empty()) { shaHex = ShaToHex(mit->second.sha); } + if (shaHex.empty()) { + // Local cache miss -> consult the authoritative cloud state. Serve + // path: use the cache-aware accessor (faithful to a single restore + // burst snapshot; falls back to live when stale or under contention). + auto cloud = FetchCloudStateForServe(accountId, appId); + if (cloud.status == StateFetchStatus::Ok) { + auto cit = cloud.state.files.find(filename); + if (cit != cloud.state.files.end() && !cit->second.sha.empty()) { + shaHex = ShaToHex(cit->second.sha); + LOG("[CloudStorage] RetrieveBlob: resolved SHA from cloud state for %s (local manifest miss)", + filename.c_str()); + } + } + } + if (shaHex.empty() && !expectedShaHex.empty()) { + shaHex = expectedShaHex; + LOG("[CloudStorage] RetrieveBlob: using caller expectedShaHex for %s", filename.c_str()); + } // Cache-first: caller supplied cloud-authoritative SHA (from FetchCloudState). // If local blob matches, skip network entirely. @@ -1388,6 +1447,38 @@ std::vector<uint8_t> RetrieveBlob(uint32_t accountId, uint32_t appId, return data; } } + + // 4. Content-hash fallback: bytes for this SHA may live under a different + // filename's CAS dir (see shaToPath). Serve any path holding the SHA -- + // byte-identical by definition. + std::string shaPath = ResolveBlobCloudPathBySHA(accountId, appId, shaHex); + if (!shaPath.empty() && shaPath != canonicalPath && + g_provider->Download(shaPath, data)) { + std::string actualSha = ShaToHex(FileUtil::SHA1(data.data(), data.size())); + if (actualSha != shaHex) { + LOG("[CloudStorage] RetrieveBlob: SHA MISMATCH on content-hash path %s: expected=%s actual=%s, rejecting", + shaPath.c_str(), shaHex.c_str(), actualSha.c_str()); + data.clear(); + } else { + LOG("[CloudStorage] RetrieveBlob: fetched by content hash (sha=%s, stored under other filename %s): %s (%zu bytes)", + shaHex.c_str(), shaPath.c_str(), filename.c_str(), data.size()); + const uint8_t* writeData = data.empty() ? nullptr : data.data(); + LocalStorage::WriteFileNoIncrement(accountId, appId, filename, + writeData, data.size()); + // Promote a copy to this filename's canonical path so the next + // request hits first try. Don't delete the source -- another + // filename references it. + CloudWorkQueue::WorkItem upload; + upload.type = CloudWorkQueue::WorkItem::Upload; + upload.cloudPath = canonicalPath; + upload.data = data; + upload.bestEffort = true; + CloudWorkQueue::EnqueueWork(std::move(upload)); + + if (found) *found = true; + return data; + } + } } else { // No SHA in manifest -- try legacy filename path. std::string legacyPath = CloudBlobPath(accountId, appId, filename); @@ -1753,25 +1844,44 @@ int GarbageCollectBlobs(uint32_t accountId, uint32_t appId) { return 0; } - // Build the set of paths the manifest expects to find on the provider. - Manifest manifest = LoadLocalManifest(accountId, appId); + // Build the keep-set from the authoritative cloud state, not the local manifest. + // The manifest is only a cache and is routinely incomplete (2nd device, post-wipe), + // so using it caused GC to delete blobs other machines still reference. If we + // can't see the authoritative reference set, we must not delete anything. + CloudStorage::StateFetchResult cloudState = FetchCloudState(accountId, appId); + if (cloudState.status != CloudStorage::StateFetchStatus::Ok) { + LOG("[GC] app=%u: cloud state unavailable (status=%d) -- refusing GC to avoid deleting referenced blobs", + appId, (int)cloudState.status); + return -1; + } + + // Keep-set = union of cloud-state files and local manifest (local may hold a + // freshly-uploaded file not yet in the fetched state). Keyed by both canonical + // path AND content SHA (see step 1 below for why the SHA key matters). std::unordered_set<std::string> keepCanonicalPaths; - std::unordered_set<std::string> keepLegacySHAs; - // filename -> (canonical cloud path, expected SHA hex) for promoting legacy blobs + std::unordered_set<std::string> keepLegacySHAs; // also: any referenced content SHA struct ManifestRef { std::string canonicalPath; std::string shaHex; }; std::unordered_map<std::string, ManifestRef> filenameToManifestRef; - for (const auto& [filename, entry] : manifest) { - if (entry.sha.empty()) continue; - std::string shaHex = ShaToHex(entry.sha); + + auto addRef = [&](const std::string& filename, const std::vector<uint8_t>& sha) { + if (sha.empty()) return; + std::string shaHex = ShaToHex(sha); std::string canonPath = CloudBlobPathByNameAndSHA(accountId, appId, filename, shaHex); keepCanonicalPaths.insert(canonPath); keepLegacySHAs.insert(shaHex); filenameToManifestRef[filename] = {canonPath, shaHex}; - } + }; + + for (const auto& [filename, fe] : cloudState.state.files) + addRef(filename, fe.sha); + + Manifest manifest = LoadLocalManifest(accountId, appId); + for (const auto& [filename, entry] : manifest) + addRef(filename, entry.sha); // Collect the set of canonical paths that actually exist on the provider. std::unordered_set<std::string> existingCanonicalPaths; @@ -1806,12 +1916,19 @@ int GarbageCollectBlobs(uint32_t accountId, uint32_t appId) { if (fi.path.size() <= blobPrefix.size()) continue; std::string rel = fi.path.substr(blobPrefix.size()); - // 1. Canonical path: keep iff (filename, sha) is in manifest. + // 1. Canonical path blobs/{filename}/{sha}: keep iff the exact path is + // referenced OR its content SHA is referenced by ANY file. The SHA + // check is essential: identical-content files share ONE physical blob + // (stored under the first filename), so the other filenames' canonical + // paths don't exist on Drive -- without the SHA keep, the shared blob + // would be deleted and every referencing file would 404. size_t lastSlash = rel.rfind('/'); if (lastSlash != std::string::npos) { std::string leaf = rel.substr(lastSlash + 1); if (isHexSha(leaf)) { - if (keepCanonicalPaths.count(fi.path) == 0) { + bool referenced = keepCanonicalPaths.count(fi.path) != 0 || + keepLegacySHAs.count(leaf) != 0; + if (!referenced) { orphans.push_back(fi.path); } continue; diff --git a/src/common/metadata_sync.cpp b/src/common/metadata_sync.cpp index c3e277e1..888b414e 100644 --- a/src/common/metadata_sync.cpp +++ b/src/common/metadata_sync.cpp @@ -9,5 +9,7 @@ std::atomic<bool> syncAchievements{false}; std::atomic<bool> syncPlaytime{false}; // Default OFF: experimental opt-in feature. std::atomic<bool> schemaFetch{false}; +// Default OFF: gate metadata features to ST clients (unsupported WIP override). +std::atomic<bool> overrideNonStGate{false}; } diff --git a/src/common/metadata_sync.h b/src/common/metadata_sync.h index d9cadd4b..b4c89ab5 100644 --- a/src/common/metadata_sync.h +++ b/src/common/metadata_sync.h @@ -19,9 +19,34 @@ extern std::atomic<bool> syncPlaytime; // Default false (opt-in experimental feature). extern std::atomic<bool> schemaFetch; +// UNSUPPORTED WIP OVERRIDE NON-ST CLIENT GATE. +// Metadata features (achievements, playtime, schema fetch, non-Steam-game spoof) +// are hard-gated to SteamTools clients. config override_non_st_client_gate lifts +// the gate so a non-ST client honors the per-feature flags. Default false. +extern std::atomic<bool> overrideNonStGate; + inline bool IsEnabled() { return steamToolsPresent.load(std::memory_order_relaxed) && syncLuas.load(std::memory_order_relaxed); } +// True when the ST-gate is open: either a SteamTools client, or the unsupported +// override is set. Metadata features must AND their per-feature flag with this. +inline bool StGateOpen() { + return steamToolsPresent.load(std::memory_order_relaxed) || + overrideNonStGate.load(std::memory_order_relaxed); +} + +// Per-feature flag AND'd with the ST-gate. Use these everywhere instead of the raw +// flags so a missed call site can't bypass the gate. +inline bool AchievementsEnabled() { + return syncAchievements.load(std::memory_order_relaxed) && StGateOpen(); +} +inline bool PlaytimeEnabled() { + return syncPlaytime.load(std::memory_order_relaxed) && StGateOpen(); +} +inline bool SchemaFetchEnabled() { + return schemaFetch.load(std::memory_order_relaxed) && StGateOpen(); +} + } diff --git a/src/common/rpc_handlers.cpp b/src/common/rpc_handlers.cpp index d3f60126..d4f4f515 100644 --- a/src/common/rpc_handlers.cpp +++ b/src/common/rpc_handlers.cpp @@ -23,6 +23,7 @@ #include <algorithm> #include <atomic> #include <chrono> +#include <climits> #include <condition_variable> #include <cstring> #include <ctime> @@ -165,71 +166,41 @@ void FlushPendingSyncStates() {} static constexpr uint64_t kFallbackQuotaBytes = 1073741824ULL; // 1 GB static constexpr uint32_t kFallbackMaxFiles = 10000; -// Steam's AC-exit disk walk (CAutoCloudManager::YldOnAppExit) counts each save -// file once PER RULE that matches it -- the native per-rule dedup is dead on the -// exit path (sub_1384D1DA0 @ 0x1384d221a is seeded with a null "previous path" -// there; it is only live on the staging/save path). So when two effective-platform -// rules resolve to the SAME physical directory (via rootoverrides), every file -// there is counted twice against maxnumfiles, tripping a false "over quota" that -// deletes all cloud files (e.g. app 1583520: 5 files x 2 colliding rules = 10 > -// maxnumfiles=5 -> wipe). -// -// We size the live maxnumfiles to the EXACT instance count that exit walk charges, -// computed by GetFileList (ScanResult.countedInstances) which mirrors the native -// per-rule, pre-dedup counting -- including siblings and macOS/Linux-only rules -// that simply never resolve to a dir on this OS and therefore add nothing. Unlike -// the old fileCount*ruleCount estimate this is collision-aware: an app whose rules -// resolve to DISTINCT paths reports maxCollisionFactor==1 and we no-op (no wipe -// risk), and an app with partial collisions (e.g. 3 rules, 2 colliding) gets x2, -// not x3. -// -// All inputs are immutable on-disk facts we never write, so the target can NEVER -// compound across sessions (a prior fix scaled the LIVE maxnumfiles and read its -// own previous bump back, running away 5->31->109->343). We only RAISE the budget. -static void EnsureQuotaSurvivesRuleMultiplier(uint32_t accountId, uint32_t appId, - uint64_t liveQuota, - uint32_t liveFiles) { +// Number of savefiles rules declared for this app. 2+ rules resolving to the same +// dir trigger native's multi-root collision (see ApplyNativeOverQuotaEviction). +static uint32_t CountSaveFilesRules(uint32_t accountId, uint32_t appId) { std::string steamPath = CloudIntercept::GetSteamPath(); - if (steamPath.empty()) return; - - AutoCloudScan::ScanResult scan; + if (steamPath.empty()) return 0; try { - scan = AutoCloudScan::GetFileList(steamPath, accountId, appId); - } catch (...) { return; } - - // A collision factor <= 1 means every effective rule resolves to a distinct - // physical directory: the native exit walk counts each file once, exactly like - // the dedup-protected staging path, so there is no over-quota risk to cover. - if (scan.maxCollisionFactor <= 1) return; - if (scan.countedInstances == 0) return; - - // neededFiles = exact instances the exit walk charges + derived slack. The - // slack replaces the old magic +16: YldOnAppExit's loop also consumes - // (1 + siblingCount) budget per colliding rule pass, captured per worst dir as - // collisionSiblingHeadroom. Keep a small floor so we are never under by 1-2. - size_t derivedHeadroom = scan.collisionSiblingHeadroom; - if (derivedHeadroom < scan.maxCollisionFactor) derivedHeadroom = scan.maxCollisionFactor; - uint64_t needed = static_cast<uint64_t>(scan.countedInstances) + derivedHeadroom; - - // Guard against pathological inputs producing an implausible budget. - constexpr uint64_t kMaxPlausibleFiles = 1000000ULL; - if (needed > kMaxPlausibleFiles) needed = kMaxPlausibleFiles; - uint32_t neededFiles = static_cast<uint32_t>(needed); - if (neededFiles <= liveFiles) return; // current budget already covers it - - // Raise quota bytes proportionally too (rough: keep per-file budget constant). - uint64_t neededQuota = liveFiles > 0 - ? liveQuota * neededFiles / liveFiles - : liveQuota; - if (neededQuota < liveQuota) neededQuota = liveQuota; - - SteamKvInjector::SetAppQuota(appId, neededQuota, neededFiles); - LOG("[NS] EnsureQuotaSurvivesRuleMultiplier app=%u: %zu unique files, " - "%zu counted instances x%zu collision (+%zu headroom) -> " - "raise maxnumfiles %u->%u quota %llu->%llu", - appId, scan.files.size(), scan.countedInstances, scan.maxCollisionFactor, - derivedHeadroom, liveFiles, neededFiles, - (unsigned long long)liveQuota, (unsigned long long)neededQuota); + auto rules = AutoCloudScan::GetRules(steamPath, appId, accountId); + return static_cast<uint32_t>(rules.size()); + } catch (...) { + return 0; + } +} + +// Fixed, idempotent per-root budget: generous enough that any real save game stays +// under it, and not derived from the live value (deriving from the current cap +// compounds: 20->80->320...). +static constexpr uint32_t kCollisionFilesPerRoot = 256; +static constexpr uint64_t kCollisionBytesPerRoot = 64ull * 1024 * 1024; // 64 MB + +// The effective maxnumfiles native sees at exit: the developer's PICS value, raised +// to ruleCount x kCollisionFilesPerRoot on a multi-root collision (2+ rules -> same +// dir). Used by both the floor injection (EnsureAppQuotaInjected) and eviction +// prediction (ApplyNativeOverQuotaEviction) so CR's manifest tracks native. Returns +// the input unchanged when no collision applies. +static void EffectiveQuotaForCollision(uint32_t accountId, uint32_t appId, + uint32_t picsFiles, uint64_t picsBytes, + uint32_t& outFiles, uint64_t& outBytes) { + outFiles = picsFiles; + outBytes = picsBytes; + uint32_t ruleCount = CountSaveFilesRules(accountId, appId); + if (ruleCount < 2) return; + uint32_t floorFiles = ruleCount * kCollisionFilesPerRoot; + uint64_t floorBytes = (uint64_t)ruleCount * kCollisionBytesPerRoot; + if (floorFiles > outFiles) outFiles = floorFiles; + if (floorBytes > outBytes) outBytes = floorBytes; } static bool EnsureAppQuotaInjected(uint32_t accountId, uint32_t appId, @@ -239,6 +210,12 @@ static bool EnsureAppQuotaInjected(uint32_t accountId, uint32_t appId, return false; } + // Native evicts over-quota files at app exit (counting files x matching-roots + // against maxnumfiles). Single-rule apps: leave the developer value alone; CR + // mirrors any real eviction at CompleteBatch. Multi-root collision apps (2+ + // rules -> same dir): native double-counts and wrongly evicts, so we raise a + // maxnumfiles floor below. See ApplyNativeOverQuotaEviction for the mechanism. + uint64_t existingQuota = 0; uint32_t existingFiles = 0; bool readOk = SteamKvInjector::ReadAppQuota(appId, existingQuota, existingFiles); @@ -257,7 +234,25 @@ static bool EnsureAppQuotaInjected(uint32_t accountId, uint32_t appId, } LOG("[NS] EnsureAppQuotaInjected app=%u: Steam has quota=%llu files=%u", appId, (unsigned long long)existingQuota, existingFiles); - EnsureQuotaSurvivesRuleMultiplier(accountId, appId, existingQuota, existingFiles); + + // Multi-root collision floor: raise the live maxnumfiles to the effective + // floor so native's exit-walk keeps all files. EffectiveQuotaForCollision + // computes the same value ApplyNativeOverQuotaEviction uses, keeping CR's + // manifest in sync. + { + uint32_t effFiles; uint64_t effBytes; + EffectiveQuotaForCollision(accountId, appId, existingFiles, existingQuota, + effFiles, effBytes); + if (effFiles > existingFiles) { + LOG("[NS] EnsureAppQuotaInjected app=%u: multi-root collision -- " + "raising maxnumfiles %u -> %u, quota -> %llu", + appId, existingFiles, effFiles, (unsigned long long)effBytes); + SteamKvInjector::EnsureMaxNumFilesFloor(appId, effFiles, effBytes); + // Do NOT write the inflated floor into cloudState->quota: that value + // propagates cross-machine and must stay the real developer PICS + // value (already cached above). + } + } return true; } @@ -903,6 +898,82 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB std::vector<RemotecacheCandidate> remotecacheCandidates; remotecacheCandidates.reserve(files.size()); + // Multi-root serving (mirror native). Native lists each file once per matching + // root, so a file under colliding rules appears under BOTH roots. That cross-root + // duplicate makes native's per-instance over-quota eviction lossless: if one + // root's copy is evicted, the file survives under the other. Serving under one + // root (old behavior) turned a harmless eviction into data loss. We store one + // blob but emit one wire entry per matching root. + // + // A file's roots = every savefiles rule (path-prefix, pattern, recursive) it + // matches, expressed as that rule's %RootName% token. + struct RuleRoot { + std::string token; // %WinAppDataLocal% + std::string cloudPath; // rule path, normalized, no leading/trailing slash + std::string pattern; // * etc. + bool recursive = false; + }; + std::vector<RuleRoot> ruleRoots; + { + std::string steamPath = CloudIntercept::GetSteamPath(); + if (!steamPath.empty()) { + auto rules = AutoCloudScan::GetRules(steamPath, appId, accountId); + for (const auto& r : rules) { + // Use the RAW rule root (cloudRoot), not the override-resolved + // rule.root. Native keys cloud storage by the raw savefiles root + // even when rootoverride resolves two rules to the same on-disk dir; + // collapsing to the resolved root would lose the cross-root duplicate + // that keeps over-quota eviction lossless. + const std::string& rawRoot = + !r.cloudRoot.empty() ? r.cloudRoot : r.root; + if (rawRoot.empty()) continue; + RuleRoot rr; + rr.token = "%" + rawRoot + "%"; + std::string p = r.path; // cloud path uses the rule's (untransformed) path + for (auto& c : p) if (c == '\\') c = '/'; + while (!p.empty() && p.front() == '/') p.erase(0, 1); + while (!p.empty() && p.back() == '/') p.pop_back(); + rr.cloudPath = p; + rr.pattern = r.pattern.empty() ? "*" : r.pattern; + rr.recursive = r.recursive; + ruleRoots.push_back(std::move(rr)); + } + } + } + + // Return the set of root tokens a file's cloud path belongs to, native-faithful. + // Falls back to the file's recorded token (or default) when rules are + // unavailable, preserving prior single-root behavior for non-collision apps. + auto rootsForFile = [&](const std::string& filename) -> std::vector<std::string> { + std::vector<std::string> out; + for (const auto& rr : ruleRoots) { + // file must live under the rule's cloud path prefix + std::string rel; + if (rr.cloudPath.empty()) { + rel = filename; + } else { + std::string pfx = rr.cloudPath + "/"; + if (filename.size() <= pfx.size() || + AutoCloudUtil::ToLowerAscii(filename.substr(0, pfx.size())) != + AutoCloudUtil::ToLowerAscii(pfx)) + continue; + rel = filename.substr(pfx.size()); + } + // non-recursive rules only match files directly in the dir + if (!rr.recursive && rel.find('/') != std::string::npos) continue; + // match pattern against the leaf (patterns here are leaf globs like *.sav) + std::string leafName = rel; + size_t s = leafName.rfind('/'); + if (s != std::string::npos) leafName = leafName.substr(s + 1); + const std::string& matchTarget = + rr.pattern.find('/') == std::string::npos ? leafName : rel; + if (!AutoCloudUtil::WildcardMatchInsensitive(rr.pattern, matchTarget)) continue; + if (std::find(out.begin(), out.end(), rr.token) == out.end()) + out.push_back(rr.token); + } + return out; + }; + SetRpcCrashContext("GetChangelist:prepare-files", "Cloud.GetAppFileChangelist#1", appId); for (auto& fe : files) { // split filename into directory prefix + leaf @@ -915,33 +986,52 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB leaf = fe.filename; } - std::string fileToken; - auto ftIt = fileTokenSnapshot.find(fe.filename); - bool hasRecordedFileToken = ftIt != fileTokenSnapshot.end(); - if (hasRecordedFileToken) fileToken = ftIt->second; - if (!hasRecordedFileToken) { - fileToken = defaultToken; - LOG("[NS-CL] file: %s has no recorded token, using default '%s'", - fe.filename.c_str(), fileToken.c_str()); + // Determine the roots this file is served under. Prefer native-faithful + // rule matching; fall back to recorded/default single token. + std::vector<std::string> fileRoots = rootsForFile(fe.filename); + if (fileRoots.empty()) { + std::string fileToken; + auto ftIt = fileTokenSnapshot.find(fe.filename); + if (ftIt != fileTokenSnapshot.end()) fileToken = ftIt->second; + else { + fileToken = defaultToken; + LOG("[NS-CL] file: %s has no recorded token, using default '%s'", + fe.filename.c_str(), fileToken.c_str()); + } + fileRoots.push_back(fileToken); } - std::string fullPrefix = fileToken + dirPrefix; + // The recorded/primary token is used for the remotecache candidate (one + // physical entry per file). Prefer it if present, else first root. + std::string primaryToken = fileRoots.front(); + { + auto ftIt = fileTokenSnapshot.find(fe.filename); + if (ftIt != fileTokenSnapshot.end() && + std::find(fileRoots.begin(), fileRoots.end(), ftIt->second) != fileRoots.end()) + primaryToken = ftIt->second; + } - uint32_t prefixIdx; - auto it = prefixMap.find(fullPrefix); - if (it != prefixMap.end()) { - prefixIdx = it->second; - } else { - prefixIdx = (uint32_t)prefixList.size(); - prefixMap[fullPrefix] = prefixIdx; - prefixList.push_back(fullPrefix); + // Emit one wire entry per matching root (native mirrors this exactly). + for (const auto& fileToken : fileRoots) { + std::string fullPrefix = fileToken + dirPrefix; + uint32_t prefixIdx; + auto it = prefixMap.find(fullPrefix); + if (it != prefixMap.end()) { + prefixIdx = it->second; + } else { + prefixIdx = (uint32_t)prefixList.size(); + prefixMap[fullPrefix] = prefixIdx; + prefixList.push_back(fullPrefix); + } + prepared.push_back({leaf, prefixIdx, &fe}); + LOG("[NS-CL] file: %s (prefix[%u]=%s, size=%llu, ts=%llu)%s", + fe.filename.c_str(), prefixIdx, fullPrefix.c_str(), fe.rawSize, fe.timestamp, + fileRoots.size() > 1 ? " [multi-root]" : ""); } - prepared.push_back({leaf, prefixIdx, &fe}); + // One physical blob per file -> one remotecache candidate (primary root). remotecacheCandidates.push_back( - { fe.filename, fileToken, fe.sha, fe.timestamp, fe.rawSize }); - LOG("[NS-CL] file: %s (prefix[%u]=%s, size=%llu, ts=%llu)", - fe.filename.c_str(), prefixIdx, fullPrefix.c_str(), fe.rawSize, fe.timestamp); + { fe.filename, primaryToken, fe.sha, fe.timestamp, fe.rawSize }); } // Don't pre-seed remotecache.vdf; let Steam manage it via GetChangelist diffs. @@ -1054,6 +1144,9 @@ RpcResult HandleLaunchIntent(uint32_t appId, const std::vector<PB::Field>& reqBo if (f.fieldNum == 5 && f.wireType == PB::Varint) currentSession.osType = static_cast<uint32_t>(f.varintVal); if (f.fieldNum == 6 && f.wireType == PB::Varint) currentSession.deviceType = static_cast<uint32_t>(f.varintVal); } + // This id came from OUR Steam client's request -- report it so the serve + // cache can tell our session apart from a foreign machine's. + CloudStorage::NoteOwnClientId(currentSession.clientId); // Cloud session management -- reuse stateResult from above (already fetched). PB::Writer body; @@ -1166,6 +1259,7 @@ RpcResult HandleSuspendSession(uint32_t appId, const std::vector<PB::Field>& req } if (f.fieldNum == 4 && f.wireType == PB::Varint) cloudSyncCompleted = f.varintVal != 0; } + CloudStorage::NoteOwnClientId(session.clientId); PendingOpsJournal::RecordSuspendState(accountId, appId, session, cloudSyncCompleted); return PB::Writer(); @@ -1185,6 +1279,7 @@ RpcResult HandleResumeSession(uint32_t appId, const std::vector<PB::Field>& reqB clientId = f.varintVal; } } + CloudStorage::NoteOwnClientId(clientId); PendingOpsJournal::RecordResumeState(accountId, appId, clientId); return PB::Writer(); @@ -1471,6 +1566,174 @@ RpcResult HandleCommitFileUpload(uint32_t appId, const std::vector<PB::Field>& r return body; } +// Mirror native Steam's over-quota eviction (CAutoCloudManager::YldOnAppExit). +// Native, at app exit, walks the file list in a fixed sort order keeping a +// running budget; once cumulative INSTANCES exceed maxnumfiles (or bytes exceed +// quota), every file past that point is logged "File %s is over quota. Removing +// from cloud" and dropped from the cloud set. +// +// Budget is charged per rule-instance -- a file matching N savefiles rules costs +// N against the budget. Measured against native Steam (CR removed): app 1583520 +// has two rules (%WinAppDataLocal% and %LinuxXdgConfigHome%) that both resolve to +// the same Proton dir, so native finds 5 files x 2 rules = 10 instances. On Linux +// (real PICS maxnumfiles=5) native counts 10 > 5 and evicts 3; on Windows (=17) it +// counts 10 <= 17 and keeps all 5. Native does not dedup by cloud path. +// +// To mirror native EXACTLY, CR must (a) count per-instance and (b) use the SAME +// maxnumfiles native sees at exit -- i.e. the EFFECTIVE value after CR's +// multi-root collision floor (EnsureAppQuotaInjected). The caller passes that +// effective value so CR's published manifest always equals native's on-disk +// result, even if the floor failed to apply (then both use raw PICS and evict +// the same files). +// +// Sort order: ascending root index, then case-insensitive path -- matches +// native's eviction ORDER. PICS reserves (apireservefiles/bytes) are subtracted +// from the budget exactly as native does. +// +// `files` is mutated in place: evicted entries are erased. Returns evicted names. +// maxNumFiles is the LIVE cap native's exit-walk will actually use -- read back +// from the KV by the caller (ReadAppQuota), so it reflects whether the collision +// floor actually applied. If the floor took, this is the raised value (keep all); +// if it silently failed, this is the raw PICS value (evict what native evicts). +// 0 means "unknown" -> never evict. +static std::vector<std::string> ApplyNativeOverQuotaEviction( + uint32_t accountId, uint32_t appId, + std::unordered_map<std::string, CloudStorage::FileEntry>& files, + uint64_t quotaBytes, uint32_t maxNumFiles) { + std::vector<std::string> evicted; + if (files.empty()) return evicted; + + // No authoritative developer file cap -> don't evict (matches native when the + // dev set no ufs limit; also the only safe choice when CR can't read PICS, + // e.g. Linux KvInjector cache-null and no quota propagated via cloud state). + if (maxNumFiles == 0) { + LOG("[NS] over-quota eviction app=%u: no authoritative maxnumfiles, skipping", appId); + return evicted; + } + // native also subtracts apireservefiles/apireservebytes; these are 0 for the + // namespace apps we handle (not present in PICS ufs), so treat as 0. If a + // future app sets them, add a SteamKvInjector::ReadAppReserves and subtract. + const uint64_t apiReserveBytes = 0; const uint32_t apiReserveFiles = 0; + + // Determine each file's root set (instances) using the SAME rule matching the + // changelist uses, so serving and eviction agree. + std::string steamPath = CloudIntercept::GetSteamPath(); + std::vector<AutoCloudUtil::AutoCloudRuleNative> rules; + if (!steamPath.empty()) { + try { rules = AutoCloudScan::GetRules(steamPath, appId, accountId); } + catch (...) {} + } + // Build the ordered, lowercased distinct root list (root index = native's + // sort key field). Order is the rule order, which is how native indexes roots. + std::vector<std::string> rootOrder; + auto rootIndexOf = [&](const std::string& tok) -> int { + for (size_t i = 0; i < rootOrder.size(); ++i) + if (rootOrder[i] == tok) return (int)i; + rootOrder.push_back(tok); + return (int)rootOrder.size() - 1; + }; + + struct RuleRoot { std::string token, cloudPath, pattern; bool recursive; }; + std::vector<RuleRoot> ruleRoots; + for (const auto& r : rules) { + const std::string& raw = !r.cloudRoot.empty() ? r.cloudRoot : r.root; + if (raw.empty()) continue; + RuleRoot rr; + rr.token = "%" + raw + "%"; + std::string p = r.path; for (auto& c : p) if (c == '\\') c = '/'; + while (!p.empty() && p.front() == '/') p.erase(0, 1); + while (!p.empty() && p.back() == '/') p.pop_back(); + rr.cloudPath = p; + rr.pattern = r.pattern.empty() ? "*" : r.pattern; + rr.recursive = r.recursive; + ruleRoots.push_back(std::move(rr)); + rootIndexOf(AutoCloudUtil::ToLowerAscii(raw)); + } + + auto rootsForFile = [&](const std::string& filename) -> std::vector<std::string> { + std::vector<std::string> out; + for (const auto& rr : ruleRoots) { + std::string rel; + if (rr.cloudPath.empty()) rel = filename; + else { + std::string pfx = rr.cloudPath + "/"; + if (filename.size() <= pfx.size() || + AutoCloudUtil::ToLowerAscii(filename.substr(0, pfx.size())) != + AutoCloudUtil::ToLowerAscii(pfx)) continue; + rel = filename.substr(pfx.size()); + } + if (!rr.recursive && rel.find('/') != std::string::npos) continue; + std::string leaf = rel; size_t s = leaf.rfind('/'); + if (s != std::string::npos) leaf = leaf.substr(s + 1); + const std::string& target = + rr.pattern.find('/') == std::string::npos ? leaf : rel; + if (!AutoCloudUtil::WildcardMatchInsensitive(rr.pattern, target)) continue; + if (std::find(out.begin(), out.end(), rr.token) == out.end()) + out.push_back(rr.token); + } + return out; + }; + + // Build the per-file descriptor and sort like native: ascending primary root + // index, then case-insensitive path. `instances` = number of savefiles rules + // the file matches (its cross-root siblings); native charges this many against + // the budget. `files` is keyed by cloud-relative path so each entry is one + // unique file, but a file matched by 2 rules costs 2 -- this is exactly what + // pure native Steam does (see function header). + struct FileInst { + std::string name; + int firstRootIdx; // smallest matching root index (native's sort key) + uint32_t instances; // = number of matching rule roots + uint64_t size; + }; + std::vector<FileInst> ordered; + ordered.reserve(files.size()); + for (const auto& [name, fe] : files) { + std::vector<std::string> roots = rootsForFile(name); + uint32_t inst = roots.empty() ? 1u : (uint32_t)roots.size(); + int firstIdx = INT_MAX; + for (const auto& tok : roots) { + std::string lower = AutoCloudUtil::ToLowerAscii( + tok.substr(1, tok.size() >= 2 ? tok.size() - 2 : 0)); // strip %% + for (size_t i = 0; i < rootOrder.size(); ++i) + if (rootOrder[i] == lower) { firstIdx = (std::min)(firstIdx, (int)i); break; } + } + if (firstIdx == INT_MAX) firstIdx = 0; + ordered.push_back({name, firstIdx, inst, fe.size}); + } + std::sort(ordered.begin(), ordered.end(), [](const FileInst& a, const FileInst& b) { + if (a.firstRootIdx != b.firstRootIdx) return a.firstRootIdx < b.firstRootIdx; + return AutoCloudUtil::ToLowerAscii(a.name) < AutoCloudUtil::ToLowerAscii(b.name); + }); + + // Budget walk: native decrements the file budget by `instances` and the byte + // budget by size*instances per file; the first file to push either below zero, + // and all after it, are evicted. + int64_t fileBudget = (int64_t)maxNumFiles - (int64_t)apiReserveFiles; + // Clamp to INT64_MAX so a pathological (e.g. cloud-propagated) quota can't + // wrap the signed cast into a negative budget and evict everything. + uint64_t rawByteBudget = (quotaBytes > apiReserveBytes) + ? (quotaBytes - apiReserveBytes) : 0; + int64_t byteBudget = (rawByteBudget > (uint64_t)INT64_MAX) + ? INT64_MAX : (int64_t)rawByteBudget; + bool evicting = false; + for (const auto& fi : ordered) { + if (!evicting) { + byteBudget -= (int64_t)(fi.size * (uint64_t)fi.instances); + fileBudget -= (int64_t)fi.instances; + if (byteBudget < 0 || fileBudget < 0) evicting = true; + } + if (evicting) { + LOG("[NS] over-quota eviction app=%u: removing '%s' from cloud (instances=%u, size=%llu, maxnumfiles=%u)", + appId, fi.name.c_str(), fi.instances, + (unsigned long long)fi.size, maxNumFiles); + files.erase(fi.name); + evicted.push_back(fi.name); + } + } + return evicted; +} + // CompleteAppUploadBatchBlocking RpcResult HandleCompleteBatch(uint32_t appId, const std::vector<PB::Field>& reqBody) { auto completeInfo = CloudRpcUtils::ParseCompleteBatchRequest(reqBody); @@ -1575,6 +1838,60 @@ RpcResult HandleCompleteBatch(uint32_t appId, const std::vector<PB::Field>& reqB state.files[filename] = std::move(fe); } + // Capture the DEVELOPER's PICS quota into cloud state so it propagates to + // machines that can't read PICS (Linux KvInjector cache-null). Source of + // truth, in order: existing cloud-state value (sticky once known) -> a + // CLEAN live PICS read on this box (ignoring CR's own injected fallback). + // The quota floor is gone, so live reads are no longer self-inflated; a + // value already in cloud state is never overwritten by a (possibly stale/ + // polluted) live read. + { + uint64_t q = 0; uint32_t f = 0; + if (state.quota.maxNumFiles == 0 && + SteamKvInjector::ReadAppQuota(appId, q, f) && f > 0 && q > 0 && + f != kFallbackMaxFiles) { + state.quota.quotaBytes = q; + state.quota.maxNumFiles = f; + state.quota.fetchedAtUnix = static_cast<uint64_t>(time(nullptr)); + state.quota.lastSeenBuildId = state.appBuildId; + LOG("[NS] CompleteBatch app=%u: captured PICS quota=%llu files=%u into cloud state", + appId, (unsigned long long)q, f); + } + } + + // Mirror native over-quota eviction so the published manifest matches what + // native keeps (no phantom entries -> no 404/conflict). Read the LIVE KV cap + // back rather than assuming the floor applied: if it took we keep all files, + // if it silently failed (Linux cache-null, injector down) we evict exactly + // what native will. Fall back to cloud-state PICS only if the live read fails + // entirely. + uint64_t evictBytes = state.quota.quotaBytes; + uint32_t evictFiles = state.quota.maxNumFiles; + { + uint64_t liveBytes = 0; uint32_t liveFiles = 0; + if (SteamKvInjector::ReadAppQuota(appId, liveBytes, liveFiles) && + liveFiles > 0) { + evictBytes = liveBytes; + evictFiles = liveFiles; + LOG("[NS] CompleteBatch app=%u: eviction uses live KV cap " + "maxnumfiles=%u quota=%llu (what native's exit-walk sees)", + appId, evictFiles, (unsigned long long)evictBytes); + } else { + LOG("[NS] CompleteBatch app=%u: live KV cap unreadable; eviction " + "falls back to cloud-state PICS maxnumfiles=%u", + appId, evictFiles); + } + } + auto evicted = ApplyNativeOverQuotaEviction(accountId, appId, state.files, + evictBytes, evictFiles); + if (!evicted.empty()) { + LOG("[NS] CompleteBatch app=%u: evicted %zu over-quota file(s) from cloud set", + appId, evicted.size()); + // Remove their local manifest entries too (their blobs become GC-eligible). + for (const auto& name : evicted) + CloudStorage::DeleteBlobStaged(accountId, appId, name); + } + state.cn = newCN; state.appBuildId = batch.appBuildId; @@ -1699,8 +2016,26 @@ RpcResult HandleFileDownload(uint32_t appId, const std::vector<PB::Field>& reqBo timestamp = entry->timestamp; sha = entry->sha; } else { - // Last resort: check blob size on disk - fileSize = HttpServer::GetBlobSize(accountId, appId, cleanName); + // The local manifest is only a CACHE -- empty on a fresh machine (new + // install, post-wipe, 2nd device). The download response MUST carry the + // SHA/size or Steam rejects the downloaded file ("Download Failure" + // after a successful HTTP transfer). Resolve from the authoritative + // cloud state, same as RetrieveBlob does. Without this, multi-device / + // fresh-install downloads fail. Serve path -> cache-aware accessor. + auto cloud = CloudStorage::FetchCloudStateForServe(accountId, appId); + if (cloud.status == CloudStorage::StateFetchStatus::Ok) { + auto cit = cloud.state.files.find(cleanName); + if (cit != cloud.state.files.end() && !cit->second.sha.empty()) { + fileSize = cit->second.size; + timestamp = cit->second.timestamp; + sha = cit->second.sha; + LOG("[NS-DL] FileDownload app=%u: resolved SHA/size from cloud state for %s (local manifest miss)", + appId, cleanName.c_str()); + } + } + // Last resort: blob size on disk (no SHA available). + if (sha.empty()) + fileSize = HttpServer::GetBlobSize(accountId, appId, cleanName); } } diff --git a/src/common/stats_handlers.cpp b/src/common/stats_handlers.cpp index 1a5fb2a5..2eab5744 100644 --- a/src/common/stats_handlers.cpp +++ b/src/common/stats_handlers.cpp @@ -42,9 +42,10 @@ CloudIntercept::RpcResult HandleGetUserStats(uint32_t appId, const std::vector<P LOG("[Stats] GetUserStats app=%u clientCrc=%u", appId, clientCrc); - // GetOrCreate seeds our store from Steam's native UserGameStats blob on - // first access, so for a real app `stats` now holds the authoritative data. - auto& stats = StatsStore::GetOrCreate(appId); + // Snapshot seeds our store from Steam's native UserGameStats blob on first + // access, then returns a COPY taken under the store lock -- safe to read here + // while background threads (poller / unlock capture) mutate the live entry. + StatsStore::AppStats stats = StatsStore::Snapshot(appId); PB::Writer resp; @@ -202,7 +203,7 @@ std::optional<std::vector<uint8_t>> HandleLegacyGetUserStats( LOG("[Stats] Legacy GetUserStats app=%u gameId=%llu clientCrc=%u schemaVer=%d", appId, (unsigned long long)gameId, clientCrc, schemaVersion); - auto& stats = StatsStore::GetOrCreate(appId); + StatsStore::AppStats stats = StatsStore::Snapshot(appId); // If client has no schema (version=-1) and we don't have one either, // pass through to let the real server provide the schema. @@ -268,10 +269,7 @@ std::optional<std::vector<uint8_t>> HandleLegacyStoreUserStats2( appId, (unsigned long long)gameId, explicitReset); if (explicitReset) { - auto& stats = StatsStore::GetOrCreate(appId); - stats.stats.clear(); - stats.achievements.clear(); - stats.crcStats = 0; + StatsStore::ResetStats(appId); // clears stats/achievements under the store lock } std::vector<StatsStore::StatEntry> entries; @@ -350,6 +348,10 @@ void ObserveGamesPlayed(const uint8_t* body, size_t bodyLen) { // unlock. The body has no timestamps, but Steam writes them to the native blob in // the same store job, so re-read the blob here to sync the new unlocks. void ObserveStoreUserStats(const uint8_t* body, size_t bodyLen) { + // Raw flag only -- this is SHARED (Windows+Linux) code. The ST-gate + // (AchievementsEnabled / StGateOpen) is applied by the WINDOWS callers at + // their hook sites; baking it in here would force Linux OFF since + // steamToolsPresent is structurally always-false on Linux. if (!MetadataSync::syncAchievements.load(std::memory_order_relaxed)) return; auto fields = PB::Parse(body, bodyLen); diff --git a/src/common/stats_store.cpp b/src/common/stats_store.cpp index abb254b2..12ff781c 100644 --- a/src/common/stats_store.cpp +++ b/src/common/stats_store.cpp @@ -39,6 +39,9 @@ static NamespacePredicate g_isNamespaceApp; // Persist to disk; pushCloud=false writes locally only (used by startup reconcile). static void WriteAppStats(uint32_t appId, const AppStats& stats, bool pushCloud); +// Playtime helpers (defined below; forward-declared for use in (de)serialization). +static void RecomputePlaytimeTotals(PlaytimeData& pt); + void SetCloudProvider(CloudPullFn pull, CloudPushFn push) { std::lock_guard<std::mutex> lock(g_mutex); g_cloudPull = std::move(pull); @@ -535,6 +538,30 @@ static bool ParseAppStatsJson(const std::string& content, AppStats& out) { out.playtime.playtimeWindows = (uint32_t)pt["windows"].integer(); out.playtime.playtimeMac = (uint32_t)pt["mac"].integer(); out.playtime.playtimeLinux = (uint32_t)pt["linux"].integer(); + + // Per-device sub-totals (authoritative). Object: deviceId -> {windows,mac,linux}. + const auto& pd = pt["per_device"]; + if (pd.type == Json::Type::Object) { + for (const auto& [dev, v] : pd.objVal) { + if (v.type != Json::Type::Object) continue; + DevicePlaytime dp; + dp.windows = (uint32_t)v["windows"].integer(); + dp.mac = (uint32_t)v["mac"].integer(); + dp.lin = (uint32_t)v["linux"].integer(); + out.playtime.perDevice[dev] = dp; + } + } else { + // Back-compat: a pre-per-device blob carried only platform totals. Shim + // each into a synthetic legacy bucket so sums survive and new writes + // accumulate. + if (out.playtime.playtimeWindows) + out.playtime.perDevice["__legacy_windows"].windows = out.playtime.playtimeWindows; + if (out.playtime.playtimeMac) + out.playtime.perDevice["__legacy_mac"].mac = out.playtime.playtimeMac; + if (out.playtime.playtimeLinux) + out.playtime.perDevice["__legacy_linux"].lin = out.playtime.playtimeLinux; + } + RecomputePlaytimeTotals(out.playtime); } return true; @@ -584,20 +611,124 @@ static std::string BuildAppStatsJson(const AppStats& stats) { pt.objVal["windows"] = Json::Number(stats.playtime.playtimeWindows); pt.objVal["mac"] = Json::Number(stats.playtime.playtimeMac); pt.objVal["linux"] = Json::Number(stats.playtime.playtimeLinux); + // Authoritative per-device sub-totals (the merge source of truth). + Json::Value perDev = Json::Object(); + for (const auto& [dev, dp] : stats.playtime.perDevice) { + Json::Value d = Json::Object(); + d.objVal["windows"] = Json::Number(dp.windows); + d.objVal["mac"] = Json::Number(dp.mac); + d.objVal["linux"] = Json::Number(dp.lin); + perDev.objVal[dev] = std::move(d); + } + pt.objVal["per_device"] = std::move(perDev); root.objVal["playtime"] = std::move(pt); return Json::Stringify(root); } -// Max-merge each platform's forever field (the total is their sum), so a stale -// local or older cloud blob can't regress another device's time. +// Stable per-device key. Hostname is what Steam itself uses for machine_names; +// distinct devices effectively never collide, and it's stable across restarts. +static const std::string& ThisDeviceId() { + static const std::string id = [] { +#ifdef _WIN32 + char buf[256]; DWORD len = sizeof(buf); + if (GetComputerNameA(buf, &len) && len > 0) return std::string(buf, len); +#else + char buf[256]; + if (gethostname(buf, sizeof(buf)) == 0) return std::string(buf); +#endif + return std::string("UNKNOWN"); + }(); + return id; +} + +// Recompute the derived totals from the authoritative per-device sub-totals. +static void RecomputePlaytimeTotals(PlaytimeData& pt) { + uint64_t win = 0, mac = 0, lin = 0; + for (const auto& [dev, dp] : pt.perDevice) { + win += dp.windows; mac += dp.mac; lin += dp.lin; + } + auto clamp32 = [](uint64_t v) -> uint32_t { + return v > 0xFFFFFFFFull ? 0xFFFFFFFFu : (uint32_t)v; + }; + pt.playtimeWindows = clamp32(win); + pt.playtimeMac = clamp32(mac); + pt.playtimeLinux = clamp32(lin); + pt.minutesForever = clamp32(win + mac + lin); +} + +// Accrue minutes onto THIS device's own per-device sub-total for this platform. +static void AccrueLocalPlaytime(PlaytimeData& pt, uint32_t minutes) { + DevicePlaytime& mine = pt.perDevice[ThisDeviceId()]; +#ifdef _WIN32 + mine.windows += minutes; +#elif defined(__APPLE__) + mine.mac += minutes; +#else + mine.lin += minutes; +#endif + RecomputePlaytimeTotals(pt); +} + +static bool IsLegacyDeviceKey(const std::string& key) { + return key.rfind("__legacy_", 0) == 0; +} + +// Merge a cloud playtime snapshot into the local one (cloud-pull path): union of +// device entries, max per (device, platform). Each device owns its key, so this +// neither clobbers another device's minutes nor double-counts our own, despite +// the cloud stats.json being last-writer-wins. +// +// Legacy reconciliation: an OLD client's blob carries only platform totals +// (shimmed into __legacy_* buckets) that may already include minutes we attribute +// to real device keys -> stacking would double-count permanently. So when src is +// legacy-ONLY, discount its legacy buckets by the sum of known real-device minutes +// before max-merging. Mixed blobs are disjoint -> no discount. static void MergePlaytime(PlaytimeData& dst, const PlaytimeData& src) { - dst.playtimeWindows = (std::max)(dst.playtimeWindows, src.playtimeWindows); - dst.playtimeMac = (std::max)(dst.playtimeMac, src.playtimeMac); - dst.playtimeLinux = (std::max)(dst.playtimeLinux, src.playtimeLinux); + bool srcHasRealKeys = false; + for (const auto& [dev, sdp] : src.perDevice) { + if (!IsLegacyDeviceKey(dev)) { srcHasRealKeys = true; break; } + } + bool srcLegacyOnly = !src.perDevice.empty() && !srcHasRealKeys; + + // Union real device keys first (so the discount below sees all of them). + for (const auto& [dev, sdp] : src.perDevice) { + if (IsLegacyDeviceKey(dev)) continue; + DevicePlaytime& ddp = dst.perDevice[dev]; + ddp.windows = (std::max)(ddp.windows, sdp.windows); + ddp.mac = (std::max)(ddp.mac, sdp.mac); + ddp.lin = (std::max)(ddp.lin, sdp.lin); + } + + // Per-platform sums of real (attributed) minutes, for the legacy discount. + uint64_t realWin = 0, realMac = 0, realLin = 0; + if (srcLegacyOnly) { + for (const auto& [dev, ddp] : dst.perDevice) { + if (IsLegacyDeviceKey(dev)) continue; + realWin += ddp.windows; realMac += ddp.mac; realLin += ddp.lin; + } + } + auto discounted = [](uint32_t legacyVal, uint64_t realSum) -> uint32_t { + return legacyVal > realSum ? (uint32_t)(legacyVal - realSum) : 0u; + }; + + for (const auto& [dev, sdp] : src.perDevice) { + if (!IsLegacyDeviceKey(dev)) continue; + DevicePlaytime eff = sdp; + if (srcLegacyOnly) { + eff.windows = discounted(sdp.windows, realWin); + eff.mac = discounted(sdp.mac, realMac); + eff.lin = discounted(sdp.lin, realLin); + } + DevicePlaytime& ddp = dst.perDevice[dev]; + ddp.windows = (std::max)(ddp.windows, eff.windows); + ddp.mac = (std::max)(ddp.mac, eff.mac); + ddp.lin = (std::max)(ddp.lin, eff.lin); + } + dst.minutesLastTwoWeeks = (std::max)(dst.minutesLastTwoWeeks, src.minutesLastTwoWeeks); - dst.lastPlayedTime = (std::max)(dst.lastPlayedTime, src.lastPlayedTime); - dst.minutesForever = dst.playtimeWindows + dst.playtimeMac + dst.playtimeLinux; + dst.lastPlayedTime = (std::max)(dst.lastPlayedTime, src.lastPlayedTime); + RecomputePlaytimeTotals(dst); } // Union-merge achievements: an unlock is monotonic, so a bit stays unlocked if @@ -648,6 +779,31 @@ static bool MergeStatValues(std::vector<StatEntry>& dst, return changed; } +// Disk-only load (stats json + schema sidecar), NO network. Safe to call while +// holding g_mutex. Returns true if local data existed. +static bool LoadAppStatsLocalOnly(uint32_t appId, AppStats& out) { + std::string path = StatsPath(appId); + bool haveLocal = false; + + std::ifstream f(path); + if (f.good()) { + std::string local((std::istreambuf_iterator<char>(f)), + std::istreambuf_iterator<char>()); + f.close(); + if (!local.empty() && ParseAppStatsJson(local, out)) + haveLocal = true; + } + if (haveLocal) { + std::string schemaPath = SchemaPath(appId); + std::ifstream sf(schemaPath, std::ios::binary); + if (sf.good()) { + out.schema.assign(std::istreambuf_iterator<char>(sf), + std::istreambuf_iterator<char>()); + } + } + return haveLocal; +} + bool LoadAppStats(uint32_t appId, AppStats& out) { std::string path = StatsPath(appId); bool haveLocal = false; @@ -672,11 +828,12 @@ bool LoadAppStats(uint32_t appId, AppStats& out) { haveLocal = true; } else { MergePlaytime(out.playtime, cloudStats.playtime); - // Adopt cloud achievements/stats/schema when we hold none. - if (out.stats.empty() && !cloudStats.stats.empty()) - out.stats = std::move(cloudStats.stats); - if (out.achievements.empty() && !cloudStats.achievements.empty()) - out.achievements = std::move(cloudStats.achievements); + // Union-merge achievements (unlocks are monotonic -- never let a + // local copy hide another device's unlock) and stat values, so + // cloud progress is preserved instead of clobbered on next push. + MergeAchievements(out.achievements, cloudStats.achievements); + MergeStatValues(out.stats, cloudStats.stats); + // Schema is descriptive; adopt cloud's only when we hold none. if (out.schema.empty() && !cloudStats.schema.empty()) out.schema = std::move(cloudStats.schema); } @@ -788,8 +945,8 @@ void CaptureNativeUnlocks(uint32_t appId) { } } -AppStats& GetOrCreate(uint32_t appId) { - std::lock_guard<std::mutex> lock(g_mutex); +// Core seed/lookup. Caller MUST hold g_mutex. Returns a live cache reference. +static AppStats& GetOrCreateLocked(uint32_t appId) { auto it = g_cache.find(appId); if (it != g_cache.end()) { // Cache hit, but a reconcile/session path may have created an empty @@ -809,6 +966,31 @@ AppStats& GetOrCreate(uint32_t appId) { return stats; } +AppStats& GetOrCreate(uint32_t appId) { + std::lock_guard<std::mutex> lock(g_mutex); + return GetOrCreateLocked(appId); +} + +// Thread-safe by-value snapshot: seed + copy entirely under the lock so the +// returned data can't be mutated out from under a read handler by a background +// thread (cloud poller / native-unlock capture). +AppStats Snapshot(uint32_t appId) { + std::lock_guard<std::mutex> lock(g_mutex); + return GetOrCreateLocked(appId); +} + +// Thread-safe explicit reset: clears stats/achievements only (playtime/schema +// survive, matching native explicit_reset). Seeds first so a cache miss can't +// flush an empty record (see RefreshFromCloud for the same operator[] hazard). +void ResetStats(uint32_t appId) { + std::lock_guard<std::mutex> lock(g_mutex); + AppStats& stats = GetOrCreateLocked(appId); + stats.stats.clear(); + stats.achievements.clear(); + stats.crcStats = 0; + g_dirty[appId] = true; +} + void SeedApps(const std::vector<uint32_t>& appIds) { for (uint32_t appId : appIds) { if (appId == 0) continue; @@ -827,18 +1009,38 @@ std::vector<uint32_t> RefreshFromCloud(const std::vector<uint32_t>& appIds) { if (!ParseAppStatsJson(cloud, cloudStats)) continue; std::lock_guard<std::mutex> lock(g_mutex); - AppStats& cur = g_cache[appId]; + // Hydrate from disk on a cache miss BEFORE merging: operator[] would + // otherwise default-construct an EMPTY record, and WriteAppStats would + // then truncate a populated local <appId>.json (stats/achievements wiped, + // crc=0), propagating the loss to the cloud on the next push. + auto cacheIt = g_cache.find(appId); + if (cacheIt == g_cache.end()) { + // Hydrate from DISK only -- the cloud blob is already in `cloudStats` + // and is merged below. LoadAppStats would do a second network pull + // while holding g_mutex, stalling game-facing store calls. + AppStats fresh; + LoadAppStatsLocalOnly(appId, fresh); + cacheIt = g_cache.emplace(appId, std::move(fresh)).first; + } + AppStats& cur = cacheIt->second; + PlaytimeData before = cur.playtime; MergePlaytime(cur.playtime, cloudStats.playtime); - // Another device advanced this app's playtime -> persist locally and report. - if (cur.playtime.minutesForever != before.minutesForever || - cur.playtime.lastPlayedTime != before.lastPlayedTime) { + // Achievements/stats are monotonic across devices -- union-merge them too, + // not just playtime, or a cloud unlock is dropped and the next local push + // overwrites it on the cloud. + bool achChanged = MergeAchievements(cur.achievements, cloudStats.achievements); + bool statChanged = MergeStatValues(cur.stats, cloudStats.stats); + bool playtimeChanged = (cur.playtime.minutesForever != before.minutesForever || + cur.playtime.lastPlayedTime != before.lastPlayedTime); + // Another device advanced this app -> persist locally and report. + if (playtimeChanged || achChanged || statChanged) { WriteAppStats(appId, cur, false); changed.push_back(appId); - LOG("[Stats] Cloud advanced app %u: forever %u -> %u (win=%u mac=%u linux=%u)", + LOG("[Stats] Cloud advanced app %u: forever %u -> %u (win=%u mac=%u linux=%u) ach=%d stat=%d", appId, before.minutesForever, cur.playtime.minutesForever, cur.playtime.playtimeWindows, cur.playtime.playtimeMac, - cur.playtime.playtimeLinux); + cur.playtime.playtimeLinux, achChanged ? 1 : 0, statChanged ? 1 : 0); } } return changed; @@ -998,17 +1200,10 @@ void EndSession(uint32_t appId) { g_activeSessions.erase(it); auto& stats = g_cache[appId]; - // Accrue only this platform's field; a session here can't overwrite another - // device's cloud playtime. -#ifdef _WIN32 - stats.playtime.playtimeWindows += minutes; -#elif defined(__APPLE__) - stats.playtime.playtimeMac += minutes; -#else - stats.playtime.playtimeLinux += minutes; -#endif - stats.playtime.minutesForever = stats.playtime.playtimeWindows - + stats.playtime.playtimeMac + stats.playtime.playtimeLinux; + // Accrue onto THIS device's own per-device sub-total (keyed by device id), so + // a session here can never overwrite another device's contribution -- even a + // same-platform device's -- under the last-writer-wins cloud blob. + AccrueLocalPlaytime(stats.playtime, minutes); stats.playtime.minutesLastTwoWeeks += minutes; stats.playtime.lastPlayedTime = now; diff --git a/src/common/stats_store.h b/src/common/stats_store.h index 42f89954..f72ed30b 100644 --- a/src/common/stats_store.h +++ b/src/common/stats_store.h @@ -2,6 +2,7 @@ #include <cstdint> #include <string> #include <vector> +#include <map> #include <unordered_map> #include <mutex> #include <functional> @@ -34,13 +35,29 @@ struct AchievementBlock { std::string names[32]; // per-bit human-readable display name (from schema) }; +// One device's own contribution to an app's playtime. A device only ever writes +// the field matching the platform it runs on; the other two stay whatever that +// device last observed (normally 0). Counters are monotonic per device. +struct DevicePlaytime { + uint32_t windows = 0; + uint32_t mac = 0; + uint32_t lin = 0; // NOTE: not 'linux' -- that's a predefined macro on Linux/GCC +}; + struct PlaytimeData { - uint32_t minutesForever; - uint32_t minutesLastTwoWeeks; - uint32_t lastPlayedTime; // unix timestamp - uint32_t playtimeWindows; - uint32_t playtimeMac; - uint32_t playtimeLinux; + // Derived aggregates (sum across all devices). Recomputed from perDevice on + // load/merge/accrue; serialized for readers (UI) that want the totals. + uint32_t minutesForever = 0; + uint32_t minutesLastTwoWeeks = 0; + uint32_t lastPlayedTime = 0; // unix timestamp (max across devices) + uint32_t playtimeWindows = 0; + uint32_t playtimeMac = 0; + uint32_t playtimeLinux = 0; + + // Authoritative per-device sub-totals, keyed by stable device id (hostname). + // Cloud stats.json is last-writer-wins; keying by device makes each writer own + // its key so merge (union + max-per-device) never clobbers or double-counts. + std::map<std::string, DevicePlaytime> perDevice; }; struct AppStats { @@ -88,8 +105,20 @@ bool LoadAppStats(uint32_t appId, AppStats& out); void SaveAppStats(uint32_t appId, const AppStats& stats); // Get or create stats entry for an app (thread-safe, cached in memory). +// Returns a live cache reference; caller must hold the store lock (background +// poller/unlock-capture mutate the same vectors). Read handlers should use +// Snapshot() instead. Kept for lock-holding/single-threaded-init callers. AppStats& GetOrCreate(uint32_t appId); +// Thread-safe by-value snapshot for read handlers: seeds the app (cloud pull + +// native import) like GetOrCreate, then returns a COPY taken under the store +// lock, so the caller can build a response without racing background mutations. +AppStats Snapshot(uint32_t appId); + +// Thread-safe explicit reset (CMsgClientStoreUserStats2 explicit_reset): clears +// stats/achievements and zeroes the crc under the store lock. +void ResetStats(uint32_t appId); + // Update a single stat value. Returns the new CRC. uint32_t SetStat(uint32_t appId, uint32_t statId, uint32_t value); diff --git a/src/common/steam_kv_injector.cpp b/src/common/steam_kv_injector.cpp index ce637014..a79a5ebc 100644 --- a/src/common/steam_kv_injector.cpp +++ b/src/common/steam_kv_injector.cpp @@ -35,7 +35,7 @@ static bool QuotaValueLooksValid(uint64_t quota, uint64_t files) { // Global CSteamEngine* pointer. Same global already used by cloud_intercept. // (SC_RVA_GLOBAL_ENGINE = 0x17A70E8 in that module.) -static constexpr uintptr_t SC_RVA_GLOBAL_ENGINE = 0x17BEC08; +static constexpr uintptr_t SC_RVA_GLOBAL_ENGINE = 0x17C2D08; // Offset from *qword_1397A70E8 to the CAppInfoCache instance. // Pattern observed in many callers: @@ -45,14 +45,14 @@ static constexpr uintptr_t SC_RVA_GLOBAL_ENGINE = 0x17BEC08; static constexpr uintptr_t APPINFOCACHE_OFFSET = 0xE68; // CAppInfoCache::GetAppInfo(appId) -> appInfo* -static constexpr uintptr_t SC_RVA_GET_APP_INFO = 0x49D920; +static constexpr uintptr_t SC_RVA_GET_APP_INFO = 0x49D9C0; // CAppInfoCache::GetSection(appInfo, sectionId) -> KeyValues* // sectionId 10 = "ufs" -static constexpr uintptr_t SC_RVA_GET_SECTION = 0x49FC50; +static constexpr uintptr_t SC_RVA_GET_SECTION = 0x49FCF0; // CAppInfoCache::ReadAppConfigUint64(cache, appId, sectionId, keyName, defaultVal) -static constexpr uintptr_t SC_RVA_READ_CONFIG_U64 = 0x49E990; +static constexpr uintptr_t SC_RVA_READ_CONFIG_U64 = 0x49EA30; // BlockOnInit -- calls CThread::Join off-engine-thread, crashes/deadlocks. Do not call. // Cache is already loaded before our RPC handlers run. @@ -60,22 +60,22 @@ static constexpr uintptr_t SC_RVA_READ_CONFIG_U64 = 0x49E990; // KeyValues::FindKey(parent, name, bCreate, out) // When bCreate=1 creates the key if not present. -static constexpr uintptr_t SC_RVA_KV_FIND_KEY = 0xCF91A0; +static constexpr uintptr_t SC_RVA_KV_FIND_KEY = 0xCFA7F0; // KeyValues::GetUint64(kv, defaultVal, key) -static constexpr uintptr_t SC_RVA_KV_GET_UINT64 = 0xCFA4F0; +static constexpr uintptr_t SC_RVA_KV_GET_UINT64 = 0xCFBB40; // KeyValues::GetInt(kv, defaultVal, key) -static constexpr uintptr_t SC_RVA_KV_GET_INT = 0xCFA0A0; +static constexpr uintptr_t SC_RVA_KV_GET_INT = 0xCFB6F0; // KeyValues::SetUint64(kv, value) -static constexpr uintptr_t SC_RVA_KV_SET_UINT64 = 0xCFA760; +static constexpr uintptr_t SC_RVA_KV_SET_UINT64 = 0xCFBDB0; // KeyValues::SetInt(kv, value) -static constexpr uintptr_t SC_RVA_KV_SET_INT = 0xCFA7A0; +static constexpr uintptr_t SC_RVA_KV_SET_INT = 0xCFBDF0; // KeyValues::SetString(kv, value) -- sets string value on a KV leaf node -static constexpr uintptr_t SC_RVA_KV_SET_STRING = 0xCFA7E0; +static constexpr uintptr_t SC_RVA_KV_SET_STRING = 0xCFBE30; // CAppInfoUpdater::RequestAppInfoUpdate -- not yet wired (offset unconfirmed). // Steam's background PICS populates KV on its own schedule; cached values suffice. @@ -190,29 +190,6 @@ bool ReadAppQuota(uint32_t appId, uint64_t& outQuotaBytes, uint32_t& outMaxNumFi return true; } -bool TriggerPicsAndWait(uint32_t appId, - uint64_t& outQuotaBytes, - uint32_t& outMaxNumFiles, - int timeoutMs) { - if (!g_ready.load(std::memory_order_acquire)) return false; - - // No PICS trigger wired yet -- just poll. Returns false so caller uses cached values. - - const auto deadline = std::chrono::steady_clock::now() + - std::chrono::milliseconds(timeoutMs); - uint64_t q = 0; - uint32_t f = 0; - while (std::chrono::steady_clock::now() < deadline) { - if (ReadAppQuota(appId, q, f) && q > 0 && f > 0) { - outQuotaBytes = q; - outMaxNumFiles = f; - return true; - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - return false; -} - bool InjectAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles) { if (!g_ready.load(std::memory_order_acquire)) return false; if (quotaBytes == 0 || maxNumFiles == 0) { @@ -285,12 +262,10 @@ bool InjectAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles) { return true; } -// Idempotently SET (not multiply) the live ufs quota/maxnumfiles to the given -// values, capped to plausible maxima. Used by the mixed-root rule-multiplier -// guard. Safe to call repeatedly with the same target. -bool SetAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles) { +bool EnsureMaxNumFilesFloor(uint32_t appId, uint32_t floorFiles, uint64_t floorBytes) { if (!g_ready.load(std::memory_order_acquire)) return false; - if (quotaBytes == 0 || maxNumFiles == 0) return false; + if (floorFiles == 0) return false; + if (floorFiles > kMaxPlausibleMaxFiles) return false; // guard pathological input void* cache = GetCachePtr(); if (!cache) return false; @@ -299,17 +274,33 @@ bool SetAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles) { void* ufs = g_r.getSection(appInfo, kSectionUfs); if (!ufs) return false; - uint64_t newQuota = quotaBytes; - uint64_t newFiles = maxNumFiles; - if (newQuota > kMaxPlausibleQuotaBytes) newQuota = kMaxPlausibleQuotaBytes; - if (newFiles > kMaxPlausibleMaxFiles) newFiles = kMaxPlausibleMaxFiles; - - bool ok = false; - void* filesKv = g_r.kvFindKey(ufs, "maxnumfiles", 1, nullptr); - if (filesKv) { g_r.kvSetInt(filesKv, static_cast<int>(newFiles)); ok = true; } - void* quotaKv = g_r.kvFindKey(ufs, "quota", 1, nullptr); - if (quotaKv) { g_r.kvSetUint64(quotaKv, newQuota); ok = true; } - return ok; + uint64_t curFiles = g_r.readConfigU64(cache, appId, kSectionUfs, "maxnumfiles", 0); + uint64_t curQuota = g_r.readConfigU64(cache, appId, kSectionUfs, "quota", 0); + + bool wrote = false; + if (curFiles < floorFiles) { + void* filesKv = g_r.kvFindKey(ufs, "maxnumfiles", 1, nullptr); + if (filesKv) { + g_r.kvSetInt(filesKv, static_cast<int>(floorFiles)); + wrote = true; + LOG("[KvInjector] EnsureMaxNumFilesFloor app=%u: raised maxnumfiles %llu -> %u", + appId, (unsigned long long)curFiles, floorFiles); + } + } + // Cap (not skip): floorBytes derives from real PICS facts; silently dropping + // the quota raise while still raising maxnumfiles would reopen byte-quota + // eviction in exactly the multi-root scenario the floor protects. + if (floorBytes > kMaxPlausibleQuotaBytes) floorBytes = kMaxPlausibleQuotaBytes; + if (floorBytes > 0 && curQuota < floorBytes) { + void* quotaKv = g_r.kvFindKey(ufs, "quota", 1, nullptr); + if (quotaKv) { + g_r.kvSetUint64(quotaKv, floorBytes); + wrote = true; + LOG("[KvInjector] EnsureMaxNumFilesFloor app=%u: raised quota %llu -> %llu", + appId, (unsigned long long)curQuota, (unsigned long long)floorBytes); + } + } + return wrote; } bool InjectSaveFiles(uint32_t appId, const std::vector<SaveFileRule>& rules) { @@ -553,13 +544,49 @@ static uintptr_t FindGlobalEnginePtr(uintptr_t textStart, uintptr_t textEnd, if (i < 8) continue; if (mem[i - 8] != 0x8D || mem[i - 7] != 0x83) continue; - // Found lea eax, [ebx + disp32] -- GOT-relative global address. - - LOG("[KvInjector] SigScan: found 'add reg, 0xB88' at 0x%lx (base+0x%lx)", - (unsigned long)addAddr, (unsigned long)(addAddr - soBase)); + // Found lea eax, [ebx + disp32] -- ebx-relative global address. + // In 32-bit PIC, ebx = _GLOBAL_OFFSET_TABLE_ set by the function's thunk + // (call __x86.get_pc_thunk.bx; add ebx, imm32). The lea computes the + // absolute address of the engine-global variable (.bss), which is the + // `void**` we need. Decode it for real instead of using a hardcoded fallback + // RVA -- the fallback is build-specific and silently wrong on other builds. + int32_t leaDisp; + memcpy(&leaDisp, &mem[i - 6], 4); // disp32 of lea eax,[ebx+disp] + + LOG("[KvInjector] SigScan: found 'add reg, 0xB88' at 0x%lx (base+0x%lx), " + "lea disp=0x%lx", + (unsigned long)addAddr, (unsigned long)(addAddr - soBase), + (unsigned long)(uint32_t)leaDisp); + + // Resolve ebx (GOT base) from the enclosing function's PIC thunk. Scan back + // up to 4KB for "add ebx, imm32" (81 C3 xx xx xx xx); get_pc_thunk.bx loads + // ebx = return address = the instruction right after the `call`, which is + // this very `add ebx` instruction. So ebx = addr(add ebx) + imm32. + uintptr_t leaAddr = textStart + (i - 8); + uintptr_t scanBack = (leaAddr > 0x1000) ? leaAddr - 0x1000 : textStart; + const uint8_t* sb = reinterpret_cast<const uint8_t*>(scanBack); + size_t sbLen = leaAddr - scanBack; + uintptr_t gotBase = 0; + for (size_t k = sbLen; k-- > 0;) { + if (sb[k] == 0x81 && sb[k + 1] == 0xC3) { + int32_t imm; + memcpy(&imm, &sb[k + 2], 4); + uintptr_t addEbxAddr = scanBack + k; + gotBase = addEbxAddr + (uint32_t)imm; // ebx after thunk + break; + } + } + if (!gotBase) { + LOG("[KvInjector] SigScan: could not resolve GOT base (ebx) for engine " + "global; falling back to RVA 0x%lx", + (unsigned long)FALLBACK_RVA_GLOBAL_ENGINE); + return soBase + FALLBACK_RVA_GLOBAL_ENGINE; + } - // GOT-relative decode is fragile without section headers; use fallback RVA. - return soBase + FALLBACK_RVA_GLOBAL_ENGINE; + uintptr_t engineVar = gotBase + (uint32_t)leaDisp; + LOG("[KvInjector] SigScan: decoded engine-global var at 0x%lx (base+0x%lx)", + (unsigned long)engineVar, (unsigned long)(engineVar - soBase)); + return engineVar; } return 0; } @@ -809,27 +836,6 @@ bool ReadAppQuota(uint32_t appId, uint64_t& outQuotaBytes, uint32_t& outMaxNumFi return true; } -bool TriggerPicsAndWait(uint32_t appId, - uint64_t& outQuotaBytes, - uint32_t& outMaxNumFiles, - int timeoutMs) { - if (!g_ready.load(std::memory_order_acquire)) return false; - // No active PICS-trigger plumbing yet on Linux; poll only. - const auto deadline = std::chrono::steady_clock::now() + - std::chrono::milliseconds(timeoutMs); - uint64_t q = 0; - uint32_t f = 0; - while (std::chrono::steady_clock::now() < deadline) { - if (ReadAppQuota(appId, q, f) && q > 0 && f > 0) { - outQuotaBytes = q; - outMaxNumFiles = f; - return true; - } - std::this_thread::sleep_for(std::chrono::milliseconds(50)); - } - return false; -} - // Inline BST walk: manager+76=rootIndex, manager+96=nodes (24-byte stride). // node+16=appId, node+20=appInfo. Returns null if appId not in cache. static void* ResolveAppInfo(void* cache, uint32_t appId) { @@ -927,12 +933,10 @@ bool InjectAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles) { return true; } -// Idempotently SET (not multiply) the live ufs quota/maxnumfiles to the given -// values, capped to plausible maxima. Used by the mixed-root rule-multiplier -// guard. Safe to call repeatedly with the same target. -bool SetAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles) { +bool EnsureMaxNumFilesFloor(uint32_t appId, uint32_t floorFiles, uint64_t floorBytes) { if (!g_ready.load(std::memory_order_acquire)) return false; - if (quotaBytes == 0 || maxNumFiles == 0) return false; + if (floorFiles == 0) return false; + if (floorFiles > kMaxPlausibleMaxFiles) return false; void* cache = GetCachePtr(); if (!cache) return false; @@ -941,22 +945,34 @@ bool SetAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles) { void* ufs = g_r.getSection(appInfo, kSectionUfs); if (!ufs) return false; - uint64_t newQuota = quotaBytes; - uint64_t newFiles = maxNumFiles; - if (newQuota > kMaxPlausibleQuotaBytes) newQuota = kMaxPlausibleQuotaBytes; - if (newFiles > kMaxPlausibleMaxFiles) newFiles = kMaxPlausibleMaxFiles; - - bool ok = false; - void* filesKv = g_r.kvFindKey(ufs, "maxnumfiles", 1, nullptr); - if (filesKv) { g_r.kvSetInt32(filesKv, static_cast<int32_t>(newFiles)); ok = true; } - void* quotaKv = g_r.kvFindKey(ufs, "quota", 1, nullptr); - if (quotaKv) { - g_r.kvSetUint64(quotaKv, - static_cast<uint32_t>(newQuota & 0xFFFFFFFFu), - static_cast<uint32_t>((newQuota >> 32) & 0xFFFFFFFFu)); - ok = true; + uint64_t curFiles = g_r.readConfigU64(cache, appId, kSectionUfs, "maxnumfiles", 0); + uint64_t curQuota = g_r.readConfigU64(cache, appId, kSectionUfs, "quota", 0); + + bool wrote = false; + if (curFiles < floorFiles) { + void* filesKv = g_r.kvFindKey(ufs, "maxnumfiles", 1, nullptr); + if (filesKv) { + g_r.kvSetInt32(filesKv, static_cast<int32_t>(floorFiles)); + wrote = true; + LOG("[KvInjector] EnsureMaxNumFilesFloor app=%u: raised maxnumfiles %llu -> %u", + appId, (unsigned long long)curFiles, floorFiles); + } + } + // Cap (not skip): see Windows impl -- dropping the quota raise while raising + // maxnumfiles would reopen byte-quota eviction for multi-root apps. + if (floorBytes > kMaxPlausibleQuotaBytes) floorBytes = kMaxPlausibleQuotaBytes; + if (floorBytes > 0 && curQuota < floorBytes) { + void* quotaKv = g_r.kvFindKey(ufs, "quota", 1, nullptr); + if (quotaKv) { + uint32_t lo = static_cast<uint32_t>(floorBytes & 0xFFFFFFFFu); + uint32_t hi = static_cast<uint32_t>((floorBytes >> 32) & 0xFFFFFFFFu); + g_r.kvSetUint64(quotaKv, lo, hi); + wrote = true; + LOG("[KvInjector] EnsureMaxNumFilesFloor app=%u: raised quota %llu -> %llu", + appId, (unsigned long long)curQuota, (unsigned long long)floorBytes); + } } - return ok; + return wrote; } bool InjectSaveFiles(uint32_t appId, const std::vector<SaveFileRule>& rules) { diff --git a/src/common/steam_kv_injector.h b/src/common/steam_kv_injector.h index 2c1d6be7..3097fe4d 100644 --- a/src/common/steam_kv_injector.h +++ b/src/common/steam_kv_injector.h @@ -15,18 +15,14 @@ bool IsReady(); // Read current ufs.quota/maxnumfiles from KV. False if injector not ready. bool ReadAppQuota(uint32_t appId, uint64_t& outQuotaBytes, uint32_t& outMaxNumFiles); -// Trigger PICS fetch and poll for results. False on timeout. -bool TriggerPicsAndWait(uint32_t appId, - uint64_t& outQuotaBytes, - uint32_t& outMaxNumFiles, - int timeoutMs = 500); - // Write quota/maxnumfiles into KV. Won't clobber existing non-zero values. bool InjectAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles); -// Idempotently SET the live ufs quota/maxnumfiles (capped to plausible maxima). -// Used by the mixed-root rule-multiplier guard; safe to call repeatedly. -bool SetAppQuota(uint32_t appId, uint64_t quotaBytes, uint32_t maxNumFiles); +// Raise live ufs maxnumfiles/quota to at least the given floor (only if below it). +// Idempotent and non-compounding: callers pass a floor derived from immutable facts, +// so repeated calls converge. Gives native's per-instance over-quota eviction enough +// budget to keep multi-root collision apps' files. +bool EnsureMaxNumFilesFloor(uint32_t appId, uint32_t floorFiles, uint64_t floorBytes); // A single AutoCloud save-file rule for KV injection. struct SaveFileRule { diff --git a/src/platform/linux/cloud_hooks.cpp b/src/platform/linux/cloud_hooks.cpp index 151293d1..a53939b5 100644 --- a/src/platform/linux/cloud_hooks.cpp +++ b/src/platform/linux/cloud_hooks.cpp @@ -29,6 +29,8 @@ #include <thread> #include <vector> #include <unistd.h> +#include <chrono> +#include <condition_variable> // 32-bit Linux cdecl: all args on stack using BYieldingSend_t = int(*)(void* pThis, const char* method, void* req, void* resp, int* flags); @@ -43,6 +45,16 @@ static std::atomic<bool> g_initialized{false}; static std::atomic<bool> g_shuttingDown{false}; static std::atomic<int> g_hookRefCount{0}; +// Cloud playtime poller. Tracked (not detached) so shutdown joins it before the +// .so's statics are torn down -- a detached thread blocked in RefreshFromCloud's +// curl would run freed code on Steam exit. Poller sets g_pollerExited + notifies +// the CV as its LAST action; BeginShutdown waits bounded, then join()s (instant) +// or detach()es if wedged in the network. +static std::thread g_cloudPollerThread; +static std::mutex g_pollerExitMtx; +static std::condition_variable g_pollerExitCv; +static std::atomic<bool> g_pollerExited{false}; + struct HookGuard { HookGuard() { g_hookRefCount.fetch_add(1, std::memory_order_acquire); } ~HookGuard() { g_hookRefCount.fetch_sub(1, std::memory_order_release); } @@ -238,30 +250,15 @@ void CloudHooks::InstallGamesPlayedObserver(uintptr_t steamclientBase, size_t st return; } - const bool playtime = MetadataSync::syncPlaytime.load(std::memory_order_relaxed); - const bool achievements = MetadataSync::syncAchievements.load(std::memory_order_relaxed); - - // Install only the detours a feature needs (a disabled one adds no trampoline). - // The CCMInterface::Send observer is shared (GamesPlayed 5410 = playtime; - // StoreUserStats2/GetUserStats 5466/818 = achievements), so install it if either - // wants it. - if (playtime || achievements) { - GamesPlayedHook::SetSerializer(&SerializeBodyTL); - GamesPlayedHook::Install(steamclientBase, steamclientSize); - } else { - LOG("[Stats] playtime + achievement sync off -- CCMInterface::Send observer not installed"); - } + // Always install -- runs before config sets the toggles; per-message paths + // already check the live toggle (matches Windows). + GamesPlayedHook::SetSerializer(&SerializeBodyTL); + GamesPlayedHook::Install(steamclientBase, steamclientSize); - // CUser-capture detour serves live playtime only; skip when playtime is off. - if (playtime) { - if (LivePlaytime::Resolve(steamclientBase, steamclientSize, g_parseFromArray)) - LivePlaytime::InstallUserCapture(); - } + if (LivePlaytime::Resolve(steamclientBase, steamclientSize, g_parseFromArray)) + LivePlaytime::InstallUserCapture(); - // Resolve (no detour, just function pointers) the packet-wrap + job-routing - // used to serve the legacy 818 achievement fetch with a 819 response. - if (achievements) - AchievementInject::Resolve(steamclientBase, steamclientSize, &SerializeBodyTL); + AchievementInject::Resolve(steamclientBase, steamclientSize, &SerializeBodyTL); } static std::optional<CloudIntercept::RpcResult> DispatchCloudRpc( @@ -385,17 +382,21 @@ static void EnsureInitialized() { StatsHandlers::Init(); // Seed managed apps so playtime/achievements are available before the // user launches anything: pulls each app's cloud stats blob and imports - // Steam's native data. - StatsStore::SeedApps(CloudIntercept::GetNamespaceApps()); + // Steam's native data. SeedApps also uploads imported stats, so only run it + // when a stats feature is enabled (both off = inert). + if (MetadataSync::syncAchievements.load(std::memory_order_relaxed) || + MetadataSync::syncPlaytime.load(std::memory_order_relaxed)) + StatsStore::SeedApps(CloudIntercept::GetNamespaceApps()); // Re-pull the cloud every 60s for another device's playtime advances. // RefreshFromCloud merges to disk; advanced apps are queued for a live // update applied on the network thread (LivePlaytime::DrainOnNetThread). - std::thread([] { + // Tracked thread (joined at shutdown), NOT detached -- see g_cloudPollerThread. + g_cloudPollerThread = std::thread([] { for (;;) { for (int i = 0; i < 60 && !g_shuttingDown.load(std::memory_order_acquire); ++i) sleep(1); - if (g_shuttingDown.load(std::memory_order_acquire)) return; + if (g_shuttingDown.load(std::memory_order_acquire)) break; // Pure playtime feature: skip the cloud pull + live push when off. if (!MetadataSync::syncPlaytime.load(std::memory_order_relaxed)) continue; auto changed = StatsStore::RefreshFromCloud(CloudIntercept::GetNamespaceApps()); @@ -405,7 +406,13 @@ static void EnsureInitialized() { LivePlaytime::Queue(body.Data()); } } - }).detach(); + // LAST action: signal shutdown that a join is now instantaneous. + { + std::lock_guard<std::mutex> lk(g_pollerExitMtx); + g_pollerExited.store(true, std::memory_order_release); + } + g_pollerExitCv.notify_all(); + }); StatsHooks::SetProtobufHelpers( [](void* msg) { return SerializeMessage(msg); }, [](void* msg, const uint8_t* data, size_t len) { @@ -764,4 +771,21 @@ void CloudHooks::BeginShutdown() { LivePlaytime::RemoveUserCapture(); for (int i = 0; i < 300 && g_hookRefCount.load(std::memory_order_acquire) > 0; ++i) usleep(10000); // 10ms, up to 3s total + + // Join the poller before statics are destroyed. Wait bounded on its exit + // signal: join() if it reached its end (instant), else detach if wedged in + // curl rather than hang Steam's shutdown. See g_cloudPollerThread. + if (g_cloudPollerThread.joinable()) { + { + std::unique_lock<std::mutex> lk(g_pollerExitMtx); + g_pollerExitCv.wait_for(lk, std::chrono::seconds(5), + [] { return g_pollerExited.load(std::memory_order_acquire); }); + } + if (g_pollerExited.load(std::memory_order_acquire)) { + g_cloudPollerThread.join(); + } else { + LOG("[CloudHooks] poller wedged in network call -- detaching"); + g_cloudPollerThread.detach(); + } + } } diff --git a/src/platform/win/cloud_intercept.cpp b/src/platform/win/cloud_intercept.cpp index d8ad9b29..de2b3925 100644 --- a/src/platform/win/cloud_intercept.cpp +++ b/src/platform/win/cloud_intercept.cpp @@ -95,12 +95,12 @@ static constexpr uint32_t GP_FIELD_OWNER_ID = 12; // uint32 // a5 ([rsp+28h]) = output depot vector (DLC/shared depots) // Depot vectors: *(QWORD*)vec = array base, *(int*)(vec+16) = count // Each entry is 32 bytes: {uint32 depotId, uint32 appId, uint64 manifestId, ...} -static constexpr uintptr_t SC_RVA_BUILD_DEPOT_DEPENDENCY = 0x4AC910; +static constexpr uintptr_t SC_RVA_BUILD_DEPOT_DEPENDENCY = 0x4AC9B0; static constexpr size_t SC_BDD_STOLEN_BYTES = 14; // first 14 bytes of prologue // CProtoBufMsg::BAsyncSend(uint32_t connectionHandle) // Hooks this to inject game_extra_info into CMsgClientGamesPlayed before serialization. -static constexpr uintptr_t SC_RVA_BASYNC_SEND = 0xCF0DF0; +static constexpr uintptr_t SC_RVA_BASYNC_SEND = 0xCF2440; static constexpr size_t SC_BAS_STOLEN_BYTES = 15; // 5+5+1+4 bytes of prologue // CProtoBufMsg layout offsets static constexpr uint32_t CPROTOBUFMSG_OFF_DESC = 0x08; // typed-body descriptor vtable* @@ -110,7 +110,7 @@ static constexpr uint32_t CPROTOBUFMSG_OFF_BODY = 0x30; // protobuf body obje // g_pJobCur (qword_1397DC0C0): the CJob coroutine currently running; its CJobID // is at CJob+32 (GetJobID, asserted at userremotestorage.cpp:3920). -static constexpr uintptr_t SC_RVA_JOBCUR_GLOBAL = 0x17DC0C0; +static constexpr uintptr_t SC_RVA_JOBCUR_GLOBAL = 0x17E02C0; static constexpr uint32_t JOB_OFF_JOBID = 32; // Schema-fetch injection: build a CMsgClientGetUserStats (EMsg 818) and send it @@ -118,38 +118,43 @@ static constexpr uint32_t JOB_OFF_JOBID = 32; // (schema_local_version=-1) on behalf of an owning SteamID (steam_id_for_user). // The server's 819 response is handled by Steam itself, which writes // appcache\stats\UserGameStatsSchema_<appid>.bin -- we just trigger the request. -// (IDA-verified against steamclient64: ctor sub_138CF07F0, finalize sub_138CF3390, -// cleanup sub_138CF0AA0, body descriptor off_1396E4460 @ RVA 0x16E4460.) -static constexpr uintptr_t SC_RVA_PBMSG_CTOR = 0xCF07F0; // CProtoBufMsgBase::ctor(this, emsg, 0) -static constexpr uintptr_t SC_RVA_PBMSG_FINALIZE = 0xCF3390; // allocate typed body -static constexpr uintptr_t SC_RVA_PBMSG_CLEANUP = 0xCF0AA0; // destroy msg -static constexpr uintptr_t SC_RVA_GETUSERSTATS_DESC = 0x16E4460; // CMsgClientGetUserStats body descriptor +// (IDA-verified against steamclient64 1781041600: ctor sub_138CF1E40, finalize +// sub_138CF49E0, cleanup sub_138CF20F0, body descriptor off_1396E84E0 @ RVA +// 0x16E84E0 -- all read from CAPIJobRequestUserStats sub_138A45010.) +static constexpr uintptr_t SC_RVA_PBMSG_CTOR = 0xCF1E40; // CProtoBufMsgBase::ctor(this, emsg, 0) +static constexpr uintptr_t SC_RVA_PBMSG_FINALIZE = 0xCF49E0; // allocate typed body +static constexpr uintptr_t SC_RVA_PBMSG_CLEANUP = 0xCF20F0; // destroy msg +static constexpr uintptr_t SC_RVA_GETUSERSTATS_DESC = 0x16E84E0; // CMsgClientGetUserStats body descriptor // Typed vtable for CProtoBufMsg<CMsgClientGetUserStats> (??_7?$CProtoBufMsg@VCMsgClientGetUserStats@@@@6B@). // MUST be installed at msg[0] after the base ctor: BAsyncSend dispatches GetSize // (vtbl+24) and Serialize (vtbl+32) through it. Leaving the base vftable there // serializes the message wrong -> pipes.cpp:881 BWrite failed -> client crash. -static constexpr uintptr_t SC_RVA_GETUSERSTATS_VFTABLE = 0x13368C8; +static constexpr uintptr_t SC_RVA_GETUSERSTATS_VFTABLE = 0x1338EB8; static constexpr uint32_t EMSG_CLIENT_GET_USER_STATS = 818; // steamclient64.dll RVAs for CCMInterface discovery // IDA image base: 0x138000000 // qword_1397A70E8 = global CSteamEngine* pointer -static constexpr uintptr_t SC_RVA_GLOBAL_ENGINE = 0x17BEC08; +static constexpr uintptr_t SC_RVA_GLOBAL_ENGINE = 0x17C2D08; // CCMInterface vtable RVA (for validation) -static constexpr uintptr_t SC_RVA_CCMINTERFACE_VT = 0x126A220; +static constexpr uintptr_t SC_RVA_CCMINTERFACE_VT = 0x126C518; // sub_138D199E0 = CNetPacket->CProtoBufNetPacket wrapper -static constexpr uintptr_t SC_RVA_WRAP_PACKET = 0xCF6310; +static constexpr uintptr_t SC_RVA_WRAP_PACKET = 0xCF7960; // sub_138D263B0 = CJobMgr::BRouteMsgToJob -static constexpr uintptr_t SC_RVA_BROUTEMSG = 0xD02320; +static constexpr uintptr_t SC_RVA_BROUTEMSG = 0xD03970; // sub_1380EB760 = Release wrapped packet (CProtoBufNetPacket ref-count release) -static constexpr uintptr_t SC_RVA_RELEASE_WRAPPED = 0x0EBF70; +static constexpr uintptr_t SC_RVA_RELEASE_WRAPPED = 0x0EC010; // CClientUnifiedServiceTransport vtable (RTTI resolves at runtime; RVA is fallback) -static constexpr uintptr_t SC_RVA_SERVICE_TRANSPORT_VT = 0x1247A70; -// sub_138BE7630 = protobuf ParseFromArray (fills body from raw bytes) -static constexpr uintptr_t SC_RVA_PARSE_FROM_ARRAY = 0xBC42F0; +static constexpr uintptr_t SC_RVA_SERVICE_TRANSPORT_VT = 0x1249C10; +// protobuf ParseFromArray, 3-arg (msgObj, data, int size) +static constexpr uintptr_t SC_RVA_PARSE_FROM_ARRAY = 0xBC5940; // sub_138BE7A40 = protobuf SerializeToArray (writes body to raw bytes) -static constexpr uintptr_t SC_RVA_SERIALIZE_TO_ARRAY = 0xBC4700; +static constexpr uintptr_t SC_RVA_SERIALIZE_TO_ARRAY = 0xBC5D50; +// CUser playtime state helpers +static constexpr uintptr_t SC_RVA_GET_APP_MINUTES_PLAYED_DATA = 0x9BB3C0; +static constexpr uintptr_t SC_RVA_FLUSH_APP_MINUTES_PLAYED = 0x9CB870; +static constexpr uintptr_t SC_RVA_SET_APP_LAST_PLAYED_TIME = 0x9CE6A0; // Live playtime update -- the write half of CUser's playtime refresh // (sub_1389DA1D0), driven from a synthesized response instead of a CM RPC. // sub_1389C7930 = the writer: iterates the parsed Game array, updates @@ -161,20 +166,21 @@ static constexpr uintptr_t SC_RVA_SERIALIZE_TO_ARRAY = 0xBC4700; // off_1396C1360 = CPlayer_GetLastPlayedTimes_Response type descriptor // ??_7CProtoBufMsg<...Response> = typed wrapper vtable // off_1396D3F48 = "Software\\Valve\\Steam\\LastPlayedTimesSyncTime" registry key -static constexpr uintptr_t SC_RVA_PLAYTIME_WRITER = 0x9C7930; -static constexpr uintptr_t SC_RVA_MSG_CTOR = 0xCF07F0; -static constexpr uintptr_t SC_RVA_MSG_INIT = 0xCF3390; -static constexpr uintptr_t SC_RVA_MSG_DTOR = 0xCF0AA0; -static constexpr uintptr_t SC_RVA_RESP_DESCRIPTOR = 0x16C1360; -static constexpr uintptr_t SC_RVA_RESP_WRAPPER_VT = 0x1323380; -// off_1396D3F48: pointer to "Software\\Valve\\Steam\\LastPlayedTimesSyncTime" -static constexpr uintptr_t SC_RVA_REGKEY_SYNCTIME = 0x16D3F48; +// 2.2.x-only playtime RVAs. Values below are stale June-1st offsets; re-resolve +// for 1781041600 before the playtime-writer path works. +static constexpr uintptr_t SC_RVA_PLAYTIME_WRITER = 0x9C79D0; +static constexpr uintptr_t SC_RVA_MSG_CTOR = 0xCF1E40; +static constexpr uintptr_t SC_RVA_MSG_INIT = 0xCF49E0; +static constexpr uintptr_t SC_RVA_MSG_DTOR = 0xCF20F0; +static constexpr uintptr_t SC_RVA_RESP_DESCRIPTOR = 0x16C5360; +static constexpr uintptr_t SC_RVA_RESP_WRAPPER_VT = 0x1325920; +// off_1396D7F48: pointer to "Software\\Valve\\Steam\\LastPlayedTimesSyncTime" +static constexpr uintptr_t SC_RVA_REGKEY_SYNCTIME = 0x16D7F48; // CUser member offsets used by the writer path static constexpr uint32_t USER_OFF_REGISTRY = 3272; // CUser+0xCC8: registry obj (sync-time write) // Inner CPlayer_GetLastPlayedTimes_Response message offsets static constexpr uint32_t RESP_OFF_GAMES_COUNT = 24; // repeated games: element count static constexpr uint32_t RESP_OFF_GAMES_ARRAY = 32; // repeated games: array base ptr - // CSteamEngine layout offsets static constexpr uint32_t ENGINE_OFF_JOBMGR = 592; // CJobMgr embedded at CSteamEngine+592 static constexpr uint32_t ENGINE_OFF_GLOBAL_HANDLE = 3144; // uint32_t: global user handle @@ -187,6 +193,10 @@ static constexpr uint32_t CCM_OFF_CONN_CONTEXT = 1688; // connection cont // Each entry: { DWORD handle, DWORD pad, QWORD CUser* } // CBaseUser layout static constexpr uint32_t USER_OFF_CCMINTERFACE = 72; // CCMInterface embedded at CBaseUser+0x48 +// Logged-in account id (uint32) in the CUser object. IDA-verified against the +// native blob writer sub_138A44400 (accountid = *(v10+572) -> UserGameStats_%u path) +// and a live scan. Read as a fallback when no RPC header has been scraped yet. +static constexpr uint32_t USER_OFF_ACCOUNTID = 572; // Function pointer types for BRouteMsgToJob bypass (Approach D - legacy) // sub_138D02530: wraps CNetPacket into CProtoBufNetPacket (parses protobuf header) @@ -259,11 +269,11 @@ static_assert(offsetof(JobRouteInfo, flags) == 20, ""); // This function takes rcx = pointer-to-pointer, reads *rcx to get a pointer, // then does InterlockedIncrement64 on that second pointer. // RecvPkt calls this with &unk_139797BD8 before calling BRouteMsgToJob. -static constexpr uintptr_t SC_RVA_REFCOUNT_HELPER = 0xDBA980; +static constexpr uintptr_t SC_RVA_REFCOUNT_HELPER = 0xDBBFD0; // Global that holds the pointer-to-counter for the refcount helper -static constexpr uintptr_t SC_RVA_REFCOUNT_GLOBAL = 0x17AE918; +static constexpr uintptr_t SC_RVA_REFCOUNT_GLOBAL = 0x17B2A18; // sub_138D28CD0 = CUtlSortedVector::Find (looks up a CJob by jobId) -static constexpr uintptr_t SC_RVA_FIND_JOB = 0xD04DC0; +static constexpr uintptr_t SC_RVA_FIND_JOB = 0xD06410; // SEH exception filter for crash diagnostics static thread_local uintptr_t s_crashFaultAddr = 0; @@ -604,6 +614,19 @@ static uintptr_t FindCurrentUser() { return userPtr; } +// Read the logged-in account id straight from steamclient's CUser object. Used +// when no RPC header has been scraped into g_steamId yet (e.g. a fast restart with +// a game already running): without it the first sync runs account-less and Steam +// evicts cloud saves as over-quota. SEH-isolated (raw cross-module reads). +static uint32_t ReadAccountIdFromUser() { + uintptr_t user = FindCurrentUser(); + if (!user) return 0; + uint32_t acct = 0; + __try { acct = *(uint32_t*)(user + USER_OFF_ACCOUNTID); } + __except(EXCEPTION_EXECUTE_HANDLER) { return 0; } + return acct; +} + // cave replacement buffer globals (still needed for passthrough SteamTools hook) @@ -611,6 +634,10 @@ static uintptr_t FindCurrentUser() { // SteamID extracted from first packet header static std::atomic<uint64_t> g_steamId{0}; static std::atomic<int32_t> g_sessionId{0}; +// Set once g_steamId came from an authoritative packet header. The CUser fallback +// seeds g_steamId early, but keep scraping until a real header arrives: it carries +// the session id and corrects a bad fallback read on a new build. +static std::atomic<bool> g_steamIdFromHeader{false}; void SetAccountId(uint32_t accountId) { // SteamID: universe=1, type=1, instance=1 @@ -903,7 +930,7 @@ static void DrainSchemaQueueOnNetThread() { #if !SCHEMA_FETCH_ENABLED return; #endif - if (!MetadataSync::schemaFetch.load(std::memory_order_relaxed)) return; + if (!MetadataSync::SchemaFetchEnabled()) return; if (t_drainingSchemaQueue) return; // prevent reentrancy via BAsyncSend if (g_shuttingDown.load(std::memory_order_acquire)) return; // Need the captured session header (steamid/sessionid) before any send, or @@ -1324,7 +1351,20 @@ static void ApplyLastPlayedUpdate(const std::vector<uint8_t>& respBody) { } uint32_t GetAccountId() { - return (uint32_t)(g_steamId.load() & 0xFFFFFFFF); + uint64_t sid = g_steamId.load(); + if (sid != 0) return (uint32_t)(sid & 0xFFFFFFFF); + + // No RPC header scraped yet -- read the account id from steamclient's CUser and + // seed g_steamId so the rest of the pipeline (RequireAccountId, quota inject) + // has it. SteamID64 reconstructed as individual/desktop (universe/type/instance=1). + uint32_t acct = ReadAccountIdFromUser(); + if (acct != 0) { + uint64_t full = (uint64_t)acct | (1ULL << 32) | (1ULL << 52) | (1ULL << 56); + uint64_t expected = 0; + if (g_steamId.compare_exchange_strong(expected, full)) + LOG("[NS] Account id %u read from CUser (no packet scraped yet)", acct); + } + return acct; } const std::string& GetSteamPath() { @@ -1519,7 +1559,7 @@ static bool __fastcall ServiceMethodDirectHook(void* thisptr, const char* method // Gated by sync_achievements: when off, do not interfere -- pass straight // through to Steam's real server. if (strcmp(methodName, StatsHandlers::RPC_GET_USER_STATS) == 0 - && MetadataSync::syncAchievements.load(std::memory_order_relaxed)) { + && MetadataSync::AchievementsEnabled()) { if (requestBody && responseBody && g_serializeToArray) { auto reqBytes = SerializeBodyToBytes(requestBody); auto reqFields = PB::Parse(reqBytes.data(), reqBytes.size()); @@ -1627,6 +1667,57 @@ static bool __fastcall ServiceMethodDirectHook(void* thisptr, const char* method return true; } +// ── Native Cloud spy ────────────────────────────────────────────────────── +// CR_SPY_APPID: log the native request + Valve's response for this non-namespace +// app's passthrough cloud RPCs, to inspect real Steam Cloud's per-root changelist +// encoding. Read-only. +static uint32_t g_spyAppId = []() -> uint32_t { + char buf[32] = {0}; + DWORD n = GetEnvironmentVariableA("CR_SPY_APPID", buf, sizeof(buf)); + if (n == 0 || n >= sizeof(buf)) return 0; + return (uint32_t)strtoul(buf, nullptr, 10); +}(); + +// Decode + log a Cloud.GetAppFileChangelist response: per-file path_prefix_index +// plus the path_prefixes table, which together reveal native's per-root entries. +static void SpyLogChangelistResponse(const char* tag, uint32_t appId, + const uint8_t* data, size_t len) { + auto fields = PB::Parse(data, len); + uint64_t cn = 0; uint32_t isDelta = 0; + std::vector<std::string> prefixes; + struct SpyFile { std::string leaf; uint32_t prefixIdx; uint32_t persist; uint32_t platforms; uint64_t size; }; + std::vector<SpyFile> files; + for (const auto& f : fields) { + if (f.fieldNum == 1 && f.wireType == PB::Varint) cn = f.varintVal; + else if (f.fieldNum == 3 && f.wireType == PB::Varint) isDelta = (uint32_t)f.varintVal; + else if (f.fieldNum == 4 && f.wireType == PB::LengthDelimited) + prefixes.emplace_back((const char*)f.data, f.dataLen); + else if (f.fieldNum == 2 && f.wireType == PB::LengthDelimited) { + auto sub = PB::Parse(f.data, f.dataLen); + SpyFile sf{}; + for (const auto& g : sub) { + if (g.fieldNum == 1 && g.wireType == PB::LengthDelimited) + sf.leaf.assign((const char*)g.data, g.dataLen); + else if (g.fieldNum == 4 && g.wireType == PB::Varint) sf.size = g.varintVal; + else if (g.fieldNum == 5 && g.wireType == PB::Varint) sf.persist = (uint32_t)g.varintVal; + else if (g.fieldNum == 6 && g.wireType == PB::Varint) sf.platforms = (uint32_t)g.varintVal; + else if (g.fieldNum == 7 && g.wireType == PB::Varint) sf.prefixIdx = (uint32_t)g.varintVal; + } + files.push_back(std::move(sf)); + } + } + LOG("[SPY-CL] %s app=%u CN=%llu is_delta=%u nfiles=%zu nprefixes=%zu", + tag, appId, (unsigned long long)cn, isDelta, files.size(), prefixes.size()); + for (size_t i = 0; i < prefixes.size(); ++i) + LOG("[SPY-CL] prefix[%zu] = '%s'", i, prefixes[i].c_str()); + for (const auto& sf : files) { + const char* pfx = sf.prefixIdx < prefixes.size() ? prefixes[sf.prefixIdx].c_str() : "<oob>"; + LOG("[SPY-CL] file leaf='%s' prefixIdx=%u prefix='%s' persist=%u platforms=0x%X size=%llu", + sf.leaf.c_str(), sf.prefixIdx, pfx, sf.persist, sf.platforms, + (unsigned long long)sf.size); + } +} + // The actual vtable hook function - replaces CClientUnifiedServiceTransport::vtable[5] static bool __fastcall ServiceMethodHook(void* thisptr, const char* methodName, void* request, void* response, int64_t* flags) { @@ -1670,7 +1761,7 @@ static bool __fastcall ServiceMethodHook(void* thisptr, const char* methodName, // then APPEND our namespace apps' playtime so Steam shows it. Real owned games // keep their server playtime (the client merges per-appid). See IDA notes. if (strcmp(methodName, StatsHandlers::RPC_GET_USER_STATS) == 0 - && MetadataSync::syncAchievements.load(std::memory_order_relaxed)) { + && MetadataSync::AchievementsEnabled()) { if (request && response) { void* reqBody = *(void**)((uintptr_t)request + 48); if (reqBody) { @@ -1701,7 +1792,7 @@ static bool __fastcall ServiceMethodHook(void* thisptr, const char* methodName, } if (strcmp(methodName, StatsHandlers::RPC_GET_LAST_PLAYED) == 0 - && MetadataSync::syncPlaytime.load(std::memory_order_relaxed)) { + && MetadataSync::PlaytimeEnabled()) { bool result = g_originalSlot5(thisptr, methodName, request, response, flags); LOG("[Stats] slot5 GetLastPlayedTimes seen: serverResult=%d", result ? 1 : 0); if (result && response) { @@ -1813,6 +1904,29 @@ static bool __fastcall ServiceMethodHook(void* thisptr, const char* methodName, // Suppress log for high-frequency non-namespace apps (e.g. 2371090 = Steam Game Notes) if (appId != 2371090) LOG("[VtHook] %s app=%u: not namespace, passing through", methodName, appId); + + // Native Cloud spy (see g_spyAppId): capture native request + response. + // Read-only. + if (g_spyAppId != 0 && appId == g_spyAppId) { + LOG("[SPY] %s app=%u native request (%zu bytes):", methodName, appId, reqBytes.size()); + SpyLogFields("[SPY-REQ]", reqBytes.data(), (uint32_t)reqBytes.size()); + bool spyResult = g_originalSlot5(thisptr, methodName, request, response, flags); + // Inspect the response body only on success: a failed call may leave the + // body slot without a constructed message. + void* spyRespBody = spyResult ? *(void**)((uintptr_t)response + 48) : nullptr; + if (spyRespBody) { + auto respBytes = SerializeBodyToBytes(spyRespBody); + LOG("[SPY] %s app=%u native response (%zu bytes, result=%d):", + methodName, appId, respBytes.size(), spyResult ? 1 : 0); + if (strcmp(methodName, RPC_GET_CHANGELIST) == 0) + SpyLogChangelistResponse("native-response", appId, + respBytes.data(), respBytes.size()); + else + SpyLogFields("[SPY-RESP]", respBytes.data(), (uint32_t)respBytes.size()); + } + return spyResult; + } + return g_originalSlot5(thisptr, methodName, request, response, flags); } @@ -1822,8 +1936,11 @@ static bool __fastcall ServiceMethodHook(void* thisptr, const char* methodName, SpyLogFields("[VtHook-REQ]", reqBytes.data(), (uint32_t)reqBytes.size()); #endif - // Capture SteamID from request header if not yet captured - if (g_steamId.load() == 0) { + // Capture SteamID from the request header. Gate on the steamid one-shot ONLY + // (not session id): in-process ServiceMethod headers may never carry + // client_sessionid, so waiting on it would re-serialize+parse on every Cloud + // RPC. SendPkt owns session-id capture. See g_steamIdFromHeader. + if (!g_steamIdFromHeader.load()) { void* reqHeader = *(void**)((uintptr_t)request + 40); if (reqHeader) { // CMsgProtoBufHeader: serialize-and-parse to extract steamid. @@ -1831,8 +1948,8 @@ static bool __fastcall ServiceMethodHook(void* thisptr, const char* methodName, if (!hdrBytes.empty()) { auto hdrFields = PB::Parse(hdrBytes.data(), hdrBytes.size()); auto* sidField = PB::FindField(hdrFields, HDR_STEAMID); - if (sidField) { - g_steamId.store(sidField->varintVal); + if (sidField && !g_steamIdFromHeader.exchange(true)) { + g_steamId.store(sidField->varintVal); // authoritative: overwrites fallback LOG("[VtHook] Captured SteamID: %llu (accountId=%u)", g_steamId.load(), GetAccountId()); HttpServer::SetAccountId(GetAccountId()); ScheduleStartupMetadataSync(); @@ -3279,7 +3396,7 @@ static void UploadLuaOnShutdown() { } // Supported Steam client versions - patches and RVAs are only valid for these builds. Index 0 is the newest. -static constexpr uint64_t SUPPORTED_STEAM_VERSIONS[] = { 1780352834ULL, 1779918128ULL, 1779486452ULL, 1778281814ULL }; +static constexpr uint64_t SUPPORTED_STEAM_VERSIONS[] = { 1781041600ULL, 1780352834ULL, 1779918128ULL, 1779486452ULL, 1778281814ULL }; static bool IsSupportedSteamVersion(uint64_t v) { for (uint64_t s : SUPPORTED_STEAM_VERSIONS) @@ -4196,10 +4313,18 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa // Experimental: proactive schema fetch (opt-in, default off). if (cfg["experimental_schema_fetch"].type == Json::Type::Bool) MetadataSync::schemaFetch = cfg["experimental_schema_fetch"].boolean(); - LOG("[Stats] Sync gates: achievements=%d, playtime=%d, schemaFetch=%d", + // UNSUPPORTED WIP OVERRIDE NON-ST CLIENT GATE (default off). Lets a non-ST + // client run the metadata features that are otherwise hard-gated to ST. + if (cfg["override_non_st_client_gate"].type == Json::Type::Bool) + MetadataSync::overrideNonStGate = cfg["override_non_st_client_gate"].boolean(); + LOG("[Stats] Sync gates: achievements=%d, playtime=%d, schemaFetch=%d, " + "steamTools=%d, overrideNonStGate=%d, stGateOpen=%d", MetadataSync::syncAchievements.load() ? 1 : 0, MetadataSync::syncPlaytime.load() ? 1 : 0, - MetadataSync::schemaFetch.load() ? 1 : 0); + MetadataSync::schemaFetch.load() ? 1 : 0, + MetadataSync::steamToolsPresent.load() ? 1 : 0, + MetadataSync::overrideNonStGate.load() ? 1 : 0, + MetadataSync::StGateOpen() ? 1 : 0); if (!cloudSaveOnly) { if (cfg["parental_bypass_playtime"].type == Json::Type::Bool) g_parentalBypassPlaytime = cfg["parental_bypass_playtime"].boolean(); @@ -4270,7 +4395,11 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa }); StatsStore::Init(cloudRoot, g_steamPath); StatsHandlers::Init(); - StatsStore::SeedApps(GetNamespaceApps()); + // Only seed when a stats feature is enabled: SeedApps also uploads imported + // stats, so with both off it must stay inert (no cloud reads or writes). + if (MetadataSync::AchievementsEnabled() || + MetadataSync::PlaytimeEnabled()) + StatsStore::SeedApps(GetNamespaceApps()); // Background: poll the cloud for another device's playtime advances and push the // new totals into the running client's tracking map + library UI -- mirroring @@ -4284,7 +4413,7 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa std::this_thread::sleep_for(std::chrono::seconds(1)); if (g_shuttingDown.load()) return; // Pure playtime feature: skip the cloud pull + live push when off. - if (!MetadataSync::syncPlaytime.load(std::memory_order_relaxed)) continue; + if (!MetadataSync::PlaytimeEnabled()) continue; auto changed = StatsStore::RefreshFromCloud(GetNamespaceApps()); if (changed.empty()) continue; PB::Writer body = StatsHandlers::BuildLastPlayedNotificationBody(changed); @@ -4483,7 +4612,7 @@ static void SweepNamespaceSchemas(); // fwd decl static std::atomic<bool> g_schemaSweepScheduled{false}; static void MaybeScheduleSchemaSweep() { // Experimental opt-in: only fetch schemas when the user enabled it. - if (!MetadataSync::schemaFetch.load(std::memory_order_relaxed)) return; + if (!MetadataSync::SchemaFetchEnabled()) return; if (g_schemaSweepScheduled.exchange(true)) return; // once per session std::thread([] { constexpr int kStartupSettleMs = 90000; // wait for startup to finish @@ -4551,7 +4680,7 @@ static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { // it starts/ends StatsStore sessions by appid. // Playtime session tracking is gated by sync_playtime: when // off, we do not observe games-played at all. - if (MetadataSync::syncPlaytime.load(std::memory_order_relaxed)) { + if (MetadataSync::PlaytimeEnabled()) { auto observeBytes = SerializeBodyToBytes(bodyObj); if (!observeBytes.empty()) { LOG("[Stats] GamesPlayed observed (emsg=%u, %zu bytes) -> session tracking", @@ -4559,12 +4688,16 @@ static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { StatsHandlers::ObserveGamesPlayed(observeBytes.data(), observeBytes.size()); } } - if (g_showNonSteamGame.load(std::memory_order_relaxed)) + // Friends "Playing non-Steam game" spoof is a metadata feature: + // hard-gated to ST clients (UNSUPPORTED WIP OVERRIDE NON-ST CLIENT + // GATE lifts it). + if (g_showNonSteamGame.load(std::memory_order_relaxed) && + MetadataSync::StGateOpen()) RewriteGamesPlayedBody(bodyObj); } } else if (emsg == EMSG_CLIENT_STORE_USER_STATS2 && - MetadataSync::syncAchievements.load(std::memory_order_relaxed)) { + MetadataSync::AchievementsEnabled()) { // The client sends this when a game unlocks an achievement / sets a // stat. The body has no unlock timestamps, but Steam writes the native // blob with fresh AchievementTimes in the same store job, so we re-read @@ -4580,7 +4713,7 @@ static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { } } else if (emsg == EMSG_CLIENT_GET_USER_STATS && - MetadataSync::syncAchievements.load(std::memory_order_relaxed)) { + MetadataSync::AchievementsEnabled()) { // Namespace apps fetch stats over the legacy 818 path (appid below the // service-method threshold; see CAPIJobRequestUserStats_BYieldingRun), // so serve a 819 from the store. The job MERGES our stats into its @@ -4816,7 +4949,7 @@ void RequestSchemaForApp(uint32_t appId) { #if !SCHEMA_FETCH_ENABLED (void)appId; return; // kill-switch: see SCHEMA_FETCH_ENABLED #endif - if (!MetadataSync::schemaFetch.load(std::memory_order_relaxed)) return; + if (!MetadataSync::SchemaFetchEnabled()) return; if (appId == 0) return; if (g_liveConnHandle.load(std::memory_order_relaxed) == 0) return; // no conn yet @@ -5136,11 +5269,14 @@ bool OnSendPkt(void* thisptr, const uint8_t* data, uint32_t size) { uint64_t jobSrc = GetJobIdSource(pkt.header); - // capture SteamID and SessionID from first packet - if (g_steamId.load() == 0) { + // Capture SteamID and SessionID from the first authoritative packet header. + // Gate must NOT be g_steamId==0: the CUser fallback in GetAccountId() may + // have pre-seeded it, and we still need the session id + the header's + // authoritative steamid (corrects a bad fallback read). + if (!g_steamIdFromHeader.load() || g_sessionId.load() == 0) { auto* sidField = PB::FindField(pkt.header, HDR_STEAMID); - if (sidField) { - g_steamId.store(sidField->varintVal); + if (sidField && !g_steamIdFromHeader.exchange(true)) { + g_steamId.store(sidField->varintVal); // authoritative: overwrites fallback LOG("[NS] Captured SteamID: %llu (accountId=%u)", g_steamId.load(), GetAccountId()); HttpServer::SetAccountId(GetAccountId()); ScheduleStartupMetadataSync(); diff --git a/ui/Pages/SettingsPage.xaml b/ui/Pages/SettingsPage.xaml index 62faded2..656444eb 100644 --- a/ui/Pages/SettingsPage.xaml +++ b/ui/Pages/SettingsPage.xaml @@ -151,6 +151,22 @@ Unchecked="SyncToggle_Changed" /> </ui:CardControl> + <ui:CardControl Margin="0,0,0,8"> + <ui:CardControl.Header> + <StackPanel> + <TextBlock Text="UNSUPPORTED WIP OVERRIDE NON-ST CLIENT GATE" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <TextBlock Text="BLAH BLAH TEST ONLY" + Foreground="{DynamicResource SystemFillColorAttentionBrush}" + TextWrapping="Wrap" /> + </StackPanel> + </ui:CardControl.Header> + <ui:ToggleSwitch x:Name="OverrideNonStGateToggle" + Checked="SyncToggle_Changed" + Unchecked="SyncToggle_Changed" /> + </ui:CardControl> + <ui:CardControl Margin="0,0,0,32"> <ui:CardControl.Header> <StackPanel> diff --git a/ui/Pages/SettingsPage.xaml.cs b/ui/Pages/SettingsPage.xaml.cs index caa6dde2..88823472 100644 --- a/ui/Pages/SettingsPage.xaml.cs +++ b/ui/Pages/SettingsPage.xaml.cs @@ -63,7 +63,8 @@ private sealed record SettingsSnapshot( bool? ShowNonSteamGame, bool? ParentalIgnorePlaytime, bool? ParentalBypassPlaytime, - bool? SchemaFetch); + bool? SchemaFetch, + bool? OverrideNonStGate); // M15: Move language/mode/sync-toggle config reads off the UI thread. // Loaded used to call ReadLanguageSetting + ReadModeSetting + @@ -77,11 +78,11 @@ private async Task LoadSettingsAsync() var lang = ReadLanguageSetting(); var mode = Services.SteamDetector.ReadModeSetting(); - bool? a = null, p = null, l = null, u = null, nsg = null, pip = null, pbp = null, sf = null; + bool? a = null, p = null, l = null, u = null, nsg = null, pip = null, pbp = null, sf = null, ovr = null; if (mode == "cloud_redirect") - ReadSyncTogglesInto(ref a, ref p, ref l, ref u, ref nsg, ref pip, ref pbp, ref sf); + ReadSyncTogglesInto(ref a, ref p, ref l, ref u, ref nsg, ref pip, ref pbp, ref sf, ref ovr); - return new SettingsSnapshot(lang, mode, a, p, l, u, nsg, pip, pbp, sf); + return new SettingsSnapshot(lang, mode, a, p, l, u, nsg, pip, pbp, sf, ovr); }); ApplySettingsSnapshot(snapshot); @@ -98,7 +99,7 @@ private void ApplySettingsSnapshot(SettingsSnapshot snap) ExtraSection.Visibility = Visibility.Visible; ApplySyncToggles(snap.SyncAchievements, snap.SyncPlaytime, snap.SyncLuas, snap.AutoUpdateDll, snap.ShowNonSteamGame, snap.ParentalIgnorePlaytime, snap.ParentalBypassPlaytime, - snap.SchemaFetch); + snap.SchemaFetch, snap.OverrideNonStGate); } else { @@ -107,7 +108,7 @@ private void ApplySettingsSnapshot(SettingsSnapshot snap) // Auto-update DLL lives in the always-visible Updates section, so it // reflects the real setting even when the sync/extra sections are hidden. ApplySyncToggles(false, false, false, snap.AutoUpdateDll, false, - snap.ParentalIgnorePlaytime, snap.ParentalBypassPlaytime, false); + snap.ParentalIgnorePlaytime, snap.ParentalBypassPlaytime, false, false); } } @@ -138,7 +139,7 @@ private void ApplyLanguageSelector(string saved) private void ApplySyncToggles(bool? achievements, bool? playtime, bool? luas, bool? autoUpdateDll, bool? showNonSteamGame, bool? parentalIgnorePlaytime, bool? parentalBypassPlaytime, - bool? schemaFetch) + bool? schemaFetch, bool? overrideNonStGate) { _syncLoading = true; try @@ -151,6 +152,7 @@ private void ApplySyncToggles(bool? achievements, bool? playtime, bool? luas, bo if (parentalIgnorePlaytime == true) ParentalIgnorePlaytimeToggle.IsChecked = true; if (parentalBypassPlaytime == true) ParentalBypassPlaytimeToggle.IsChecked = true; if (schemaFetch == true) GetAchievementDataToggle.IsChecked = true; + if (overrideNonStGate == true) OverrideNonStGateToggle.IsChecked = true; } finally { @@ -165,7 +167,7 @@ private void ApplySyncToggles(bool? achievements, bool? playtime, bool? luas, bo /// </summary> private static void ReadSyncTogglesInto(ref bool? achievements, ref bool? playtime, ref bool? luas, ref bool? autoUpdateDll, ref bool? showNonSteamGame, ref bool? parentalIgnorePlaytime, ref bool? parentalBypassPlaytime, - ref bool? schemaFetch) + ref bool? schemaFetch, ref bool? overrideNonStGate) { try { @@ -197,6 +199,9 @@ private static void ReadSyncTogglesInto(ref bool? achievements, ref bool? playti // Experimental schema fetch: default off when key absent. if (root.TryGetProperty("experimental_schema_fetch", out var sf) && sf.ValueKind == JsonValueKind.True) schemaFetch = true; + // UNSUPPORTED WIP OVERRIDE NON-ST CLIENT GATE: default off when absent. + if (root.TryGetProperty("override_non_st_client_gate", out var ovr) && ovr.ValueKind == JsonValueKind.True) + overrideNonStGate = true; } catch { } } @@ -375,7 +380,7 @@ private void SaveSyncToggles() Services.ConfigHelper.SaveConfig(path, new[] { "sync_achievements", "sync_playtime", "sync_luas", "auto_update_dll", "show_non_steam_game", "parental_ignore_playtime", "parental_bypass_playtime", - "experimental_schema_fetch" }, + "experimental_schema_fetch", "override_non_st_client_gate" }, writer => { writer.WriteBoolean("sync_achievements", SyncAchievementsToggle.IsChecked == true); @@ -386,6 +391,7 @@ private void SaveSyncToggles() writer.WriteBoolean("parental_ignore_playtime", ParentalIgnorePlaytimeToggle.IsChecked == true); writer.WriteBoolean("parental_bypass_playtime", ParentalBypassPlaytimeToggle.IsChecked == true); writer.WriteBoolean("experimental_schema_fetch", GetAchievementDataToggle.IsChecked == true); + writer.WriteBoolean("override_non_st_client_gate", OverrideNonStGateToggle.IsChecked == true); }); } diff --git a/ui/Resources/Strings.resx b/ui/Resources/Strings.resx index c1954910..2fdd2b2b 100644 --- a/ui/Resources/Strings.resx +++ b/ui/Resources/Strings.resx @@ -1693,7 +1693,7 @@ Are you sure?</value> <value>Quality of Life Placeholder</value> </data> <data name="Settings_ExtraHint" xml:space="preserve"> - <value>Optional extras that aren't part of cloud sync. Changes take effect on next game launch.</value> + <value>Optional extras that aren't part of cloud sync.</value> </data> <data name="Settings_ShowNonSteamGame" xml:space="preserve"> <value>Show Lua Game in Status</value> diff --git a/ui/Services/SteamDetector.cs b/ui/Services/SteamDetector.cs index a6129492..633d70eb 100644 --- a/ui/Services/SteamDetector.cs +++ b/ui/Services/SteamDetector.cs @@ -17,7 +17,7 @@ public static class SteamDetector /// <summary> /// Supported Steam client versions our patches and RVAs target. Index 0 is the newest. /// </summary> - public static readonly long[] SupportedSteamVersions = { 1780352834, 1779918128, 1779486452, 1778281814, 1778003620 }; + public static readonly long[] SupportedSteamVersions = { 1781041600, 1780352834, 1779918128, 1779486452, 1778281814, 1778003620 }; public static long ExpectedSteamVersion => SupportedSteamVersions[0]; From 84be7cf3cbab87bb76ec544ccb1df5956d4b2e79 Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:30:22 -0400 Subject: [PATCH 18/24] Fix Linux KV injector crash on Steam 1781043450 with updated RVAs and readConfigU64 guard --- CMakeLists.txt | 47 ++++++++++++++++++-------------- src/common/steam_kv_injector.cpp | 24 ++++++++-------- 2 files changed, 39 insertions(+), 32 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 552310b9..53fa9c1c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,28 +15,33 @@ if(NOT CR_RELEASE_VERSION) endif() # ── Generate version string with git SHA ──────────────────────────────── -execute_process( - COMMAND git rev-parse --short=7 HEAD - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - OUTPUT_VARIABLE GIT_SHA - OUTPUT_STRIP_TRAILING_WHITESPACE - ERROR_QUIET - RESULT_VARIABLE GIT_RESULT -) -if(NOT GIT_RESULT EQUAL 0) - set(GIT_SHA "unknown") -endif() +# Release builds may pass -DCR_GIT_SHA to pin the build id (e.g. when the build +# runs in a sandbox whose worktree shows spurious eol-only diffs). +if(NOT DEFINED CR_GIT_SHA) + execute_process( + COMMAND git rev-parse --short=7 HEAD + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + OUTPUT_VARIABLE GIT_SHA + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + RESULT_VARIABLE GIT_RESULT + ) + if(NOT GIT_RESULT EQUAL 0) + set(GIT_SHA "unknown") + endif() -# Check for dirty working tree -execute_process( - COMMAND git status --porcelain - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - OUTPUT_VARIABLE GIT_STATUS - OUTPUT_STRIP_TRAILING_WHITESPACE - ERROR_QUIET -) -if(GIT_STATUS) - set(GIT_SHA "${GIT_SHA}-dirty") + execute_process( + COMMAND git status --porcelain + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + OUTPUT_VARIABLE GIT_STATUS + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + ) + if(GIT_STATUS) + set(GIT_SHA "${GIT_SHA}-dirty") + endif() +else() + set(GIT_SHA "${CR_GIT_SHA}") endif() set(CR_SO_VERSION "${CR_RELEASE_VERSION}+${GIT_SHA}") diff --git a/src/common/steam_kv_injector.cpp b/src/common/steam_kv_injector.cpp index a79a5ebc..55796b36 100644 --- a/src/common/steam_kv_injector.cpp +++ b/src/common/steam_kv_injector.cpp @@ -402,16 +402,16 @@ bool InjectSaveFiles(uint32_t appId, const std::vector<SaveFileRule>& rules) { #else // !_WIN32 -- Linux 32-bit steamclient.so -// Linux steamclient.so -- runtime signature scanning; falls back to hardcoded RVAs (May 2026 build) +// Linux steamclient.so -- runtime signature scanning; falls back to hardcoded RVAs (build 1781043450) -// Fallback RVAs (June 2026 steamclient.so, IDA image base 0x0). -static constexpr uintptr_t FALLBACK_RVA_GLOBAL_ENGINE = 0x2ECD9C0; -static constexpr uintptr_t FALLBACK_RVA_READ_CONFIG_U64 = 0xF76960; -static constexpr uintptr_t FALLBACK_RVA_GET_SECTION = 0xF75BD0; -static constexpr uintptr_t FALLBACK_RVA_KV_FIND_KEY = 0x2513320; -static constexpr uintptr_t FALLBACK_RVA_KV_SET_UINT64 = 0x250E070; -static constexpr uintptr_t FALLBACK_RVA_KV_SET_INT32 = 0x250E040; -static constexpr uintptr_t FALLBACK_RVA_KV_SET_STRING = 0x250DEE0; +// Fallback RVAs (steamclient.so build 1781043450, IDA image base 0x0). +static constexpr uintptr_t FALLBACK_RVA_GLOBAL_ENGINE = 0x2ED1BC0; +static constexpr uintptr_t FALLBACK_RVA_READ_CONFIG_U64 = 0xF77960; +static constexpr uintptr_t FALLBACK_RVA_GET_SECTION = 0xF74EC0; +static constexpr uintptr_t FALLBACK_RVA_KV_FIND_KEY = 0x2517320; +static constexpr uintptr_t FALLBACK_RVA_KV_SET_UINT64 = 0x2512070; +static constexpr uintptr_t FALLBACK_RVA_KV_SET_INT32 = 0x2512040; +static constexpr uintptr_t FALLBACK_RVA_KV_SET_STRING = 0x2511EE0; // Offset from CSteamEngine* to CAppInfoCache instance. static constexpr uintptr_t APPINFOCACHE_OFFSET = 2952; // 0xB88 @@ -793,8 +793,10 @@ bool Init() { g_r.kvSetInt32 = reinterpret_cast<KvSetInt32Fn>(kvSetI32 ? kvSetI32 : (base + FALLBACK_RVA_KV_SET_INT32)); g_r.kvSetString = reinterpret_cast<KvSetStringFn>(kvSetStr ? kvSetStr : (base + FALLBACK_RVA_KV_SET_STRING)); - // If critical functions couldn't be sig-scanned, don't trust fallback RVAs - if (!globalEng || !getSect || !kvFind) { + // Don't trust fallback RVAs if criticals couldn't be sig-scanned. + // readConfigU64 is included: a stale fallback there crashes in + // steamclient's BST walk on the first GetChangelist RPC. + if (!globalEng || !getSect || !kvFind || !readCfg) { LOG("[KvInjector] Critical functions unresolved -- KV injector DISABLED (prevents crash on stale RVAs)"); return; } From d82425b602f6af4c58619c68da86a2b4c61d6f2c Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:48:40 -0400 Subject: [PATCH 19/24] Fix cloud-sync arrows, exit-sync latency, and the game-exit crash --- src/common/app_state.cpp | 68 +++--- src/common/app_state.h | 10 +- src/common/autocloud_util.h | 28 ++- src/common/batch_tracker.cpp | 5 +- src/common/batch_tracker.h | 18 +- src/common/cloud_storage.cpp | 139 +++++++++-- src/common/cloud_storage.h | 8 + src/common/rpc_handlers.cpp | 254 +++++++++---------- src/common/stats_store.cpp | 255 ++++++++++++++------ src/common/stats_store.h | 20 +- src/platform/linux/cloud_hooks.cpp | 56 ++++- src/platform/linux/http_transport_linux.cpp | 38 ++- src/platform/win/cloud_intercept.cpp | 59 ++++- 13 files changed, 646 insertions(+), 312 deletions(-) diff --git a/src/common/app_state.cpp b/src/common/app_state.cpp index a70f2351..fbff177c 100644 --- a/src/common/app_state.cpp +++ b/src/common/app_state.cpp @@ -253,9 +253,9 @@ static constexpr size_t MAX_STATE_SIZE = 16 * 1024 * 1024; // 16 MB static StateFetchResult FetchCloudStateLive(uint32_t accountId, uint32_t appId) { InflightSyncScope guard; - if (!guard) return { StateFetchStatus::FetchFailed, {}, {} }; + if (!guard) return { StateFetchStatus::FetchFailed, {} }; if (!g_stateProvider || !g_stateProvider->IsAuthenticated()) - return { StateFetchStatus::FetchFailed, {}, {} }; + return { StateFetchStatus::FetchFailed, {} }; std::string statePath = CloudMetadataPath(accountId, appId, kStateFilename); std::vector<uint8_t> data; @@ -263,19 +263,19 @@ static StateFetchResult FetchCloudStateLive(uint32_t accountId, uint32_t appId) if (data.size() > MAX_STATE_SIZE) { LOG("[AppState] FetchCloudState app %u: state file too large (%zu bytes)", appId, data.size()); - return { StateFetchStatus::ParseFailed, {}, {} }; + return { StateFetchStatus::ParseFailed, {} }; } std::string json(data.begin(), data.end()); CloudAppState state; if (!DeserializeState(json, state)) { LOG("[AppState] FetchCloudState app %u: parse failed", appId); - return { StateFetchStatus::ParseFailed, {}, {} }; + return { StateFetchStatus::ParseFailed, {} }; } // cn>0 with empty files is valid (user deleted all saves). // AutoCloudImport repopulates from disk if local files exist. LOG("[AppState] FetchCloudState app %u: loaded state CN=%llu, %zu files", appId, state.cn, state.files.size()); - return { StateFetchStatus::Ok, std::move(state), {} }; + return { StateFetchStatus::Ok, std::move(state) }; } auto existsStatus = g_stateProvider->CheckExists(statePath); @@ -355,15 +355,15 @@ static StateFetchResult FetchCloudStateLive(uint32_t accountId, uint32_t appId) LOG("[AppState] FetchCloudState app %u: legacy files cleaned up", appId); } - return { StateFetchStatus::Ok, std::move(state), {} }; + return { StateFetchStatus::Ok, std::move(state) }; } LOG("[AppState] FetchCloudState app %u: no state file and no legacy data", appId); - return { StateFetchStatus::NotFound, {}, {} }; + return { StateFetchStatus::NotFound, {} }; } LOG("[AppState] FetchCloudState app %u: download failed", appId); - return { StateFetchStatus::FetchFailed, {}, {} }; + return { StateFetchStatus::FetchFailed, {} }; } // Public always-fresh fetch. Performs the live read AND refreshes the serve @@ -397,8 +397,7 @@ StateFetchResult FetchCloudStateForServe(uint32_t accountId, uint32_t appId) { } bool PublishCloudState(uint32_t accountId, uint32_t appId, - const CloudAppState& state, - const std::string& /*etag*/) { + const CloudAppState& state, bool lockOnly) { InflightSyncScope guard; if (!guard) return false; if (!g_stateProvider || !g_stateProvider->IsAuthenticated()) { @@ -406,7 +405,30 @@ bool PublishCloudState(uint32_t accountId, uint32_t appId, return false; } - std::string json = SerializeState(state); + // Heal or drop any manifest entry whose blob isn't durable on the provider, so + // we never publish a state pointing at blobs that 404 elsewhere. lockOnly skips + // it: a session-release publish reuses the manifest CompleteBatch just verified. + CloudAppState verified = state; + if (!lockOnly && !VerifyAndHealManifestForPublish(accountId, appId, verified)) { + LOG("[AppState] PublishCloudState app %u: cannot verify blobs, deferring publish", appId); + return false; + } + + // Refuse to move the changenumber backward, like the real server. Re-fetch the + // cloud CN and reject a stale RMW (e.g. the session lock republish) that would + // clobber a newer CN another machine published in the window. Equal CN is fine. + { + auto current = FetchCloudStateLive(accountId, appId); + if (current.status == StateFetchStatus::Ok && current.state.cn > verified.cn) { + LOG("[AppState] PublishCloudState app %u: REFUSED -- cloud CN=%llu is newer than " + "publish CN=%llu (would regress changelist); leaving cloud state intact", + appId, (unsigned long long)current.state.cn, + (unsigned long long)verified.cn); + return false; + } + } + + std::string json = SerializeState(verified); std::string statePath = CloudMetadataPath(accountId, appId, kStateFilename); if (!g_stateProvider->Upload(statePath, @@ -429,7 +451,7 @@ bool PublishCloudState(uint32_t accountId, uint32_t appId, InvalidateServeCache(accountId, appId); LOG("[AppState] PublishCloudState app %u: published CN=%llu, %zu files", - appId, state.cn, state.files.size()); + appId, verified.cn, verified.files.size()); return true; } @@ -443,25 +465,11 @@ void ReleaseCloudSession(uint32_t accountId, uint32_t appId, uint64_t clientId) auto& state = result.state; if (state.session.clientId == clientId || clientId == 0) { - // Reconcile stale file list from local manifest if previous publish failed. - uint64_t localCN = LocalStorage::GetChangeNumber(accountId, appId); - if (localCN > state.cn) { - LOG("[AppState] ReleaseCloudSession app %u: local CN %llu > cloud CN %llu, reconciling file list", - appId, (unsigned long long)localCN, (unsigned long long)state.cn); - state.files.clear(); - auto localManifest = LoadLocalManifest(accountId, appId); - for (const auto& [name, me] : localManifest) { - FileEntry fe; - fe.sha = me.sha; - fe.timestamp = me.timestamp; - fe.size = me.size; - state.files[name] = std::move(fe); - } - state.cn = localCN; - } - + // Only release the lock; the file list and CN were already committed by the + // upload batch. Don't rebuild the manifest from local blobs here -- that + // advertises files before their blobs are durably uploaded. state.session = {}; - if (!PublishCloudState(accountId, appId, state, result.etag)) { + if (!PublishCloudState(accountId, appId, state, /*lockOnly=*/true)) { LOG("[AppState] ReleaseCloudSession app %u: publish failed (best-effort)", appId); } LOG("[AppState] ReleaseCloudSession app %u: session cleared (client=%llu)", diff --git a/src/common/app_state.h b/src/common/app_state.h index 1a105af6..b1dd3995 100644 --- a/src/common/app_state.h +++ b/src/common/app_state.h @@ -65,7 +65,6 @@ enum class StateFetchStatus { struct StateFetchResult { StateFetchStatus status = StateFetchStatus::FetchFailed; CloudAppState state; - std::string etag; // For conditional writes (OneDrive) }; void AppState_Init(ICloudProvider* provider); @@ -89,10 +88,13 @@ StateFetchResult FetchCloudStateForServe(uint32_t accountId, uint32_t appId); // sessions as contention. See g_ownClientId in app_state.cpp. void NoteOwnClientId(uint64_t clientId); -// If etag is non-empty, uses conditional write (OneDrive). +// Publishes the app's cloud state. Refuses to regress the changenumber (re-fetches +// and rejects if the provider already holds a newer CN) -- the only guard against +// a stale RMW on providers with no conditional-write primitive. +// lockOnly skips the blob verify/heal pass; use it only on the session-release +// publish, where the manifest and CN were just committed by the upload batch. bool PublishCloudState(uint32_t accountId, uint32_t appId, - const CloudAppState& state, - const std::string& etag = ""); + const CloudAppState& state, bool lockOnly = false); std::string SerializeState(const CloudAppState& state); bool DeserializeState(const std::string& json, CloudAppState& outState); diff --git a/src/common/autocloud_util.h b/src/common/autocloud_util.h index 39847616..26ff0dc5 100644 --- a/src/common/autocloud_util.h +++ b/src/common/autocloud_util.h @@ -92,20 +92,34 @@ inline bool IsSafeRelativePath(const std::string& path) { // Filesystem time conversion +// Convert a filesystem mtime to whole Unix seconds, rounded to nearest. Flooring +// recorded a time_stamp one second below the mtime Steam reads off the same file, +// so the sync-state eval saw the local copy as newer and showed a wrong arrow. inline uint64_t FileTimeToUnixSeconds(std::filesystem::file_time_type ftime) { +#if defined(__cpp_lib_chrono) && __cpp_lib_chrono >= 201907L + auto sysTime = std::chrono::clock_cast<std::chrono::system_clock>(ftime); +#else + // Pre-C++20: paired clock reads back-to-back to keep epoch jitter sub-second. auto fileNow = std::filesystem::file_time_type::clock::now(); - auto sysNow = std::chrono::system_clock::now(); - auto sctp = std::chrono::time_point_cast<std::chrono::seconds>( - ftime - fileNow + sysNow - ); - return (uint64_t)sctp.time_since_epoch().count(); + auto sysNow = std::chrono::system_clock::now(); + auto sysTime = sysNow + std::chrono::duration_cast<std::chrono::system_clock::duration>( + ftime - fileNow); +#endif + auto secs = std::chrono::duration_cast<std::chrono::milliseconds>( + sysTime.time_since_epoch()).count(); + return (uint64_t)((secs + 500) / 1000); } inline std::filesystem::file_time_type UnixSecondsToFileTime(uint64_t unixSeconds) { auto sysTime = std::chrono::system_clock::from_time_t((time_t)unixSeconds); - auto sysNow = std::chrono::system_clock::now(); +#if defined(__cpp_lib_chrono) && __cpp_lib_chrono >= 201907L + return std::chrono::clock_cast<std::filesystem::file_time_type::clock>(sysTime); +#else + auto sysNow = std::chrono::system_clock::now(); auto fileNow = std::filesystem::file_time_type::clock::now(); - return fileNow + (sysTime - sysNow); + return fileNow + std::chrono::duration_cast<std::filesystem::file_time_type::duration>( + sysTime - sysNow); +#endif } // Platform-specific path resolution diff --git a/src/common/batch_tracker.cpp b/src/common/batch_tracker.cpp index cad67a83..91c7dde3 100644 --- a/src/common/batch_tracker.cpp +++ b/src/common/batch_tracker.cpp @@ -39,13 +39,16 @@ void BatchTracker_Begin(uint32_t accountId, uint32_t appId, uint64_t batchId, ui } void BatchTracker_RecordUpload(uint32_t accountId, uint32_t appId, - const std::string& filename) { + const std::string& filename, + const std::vector<uint8_t>& sha, + uint64_t size, uint64_t timestamp) { uint64_t key = MakeAppAccountKey(accountId, appId); std::lock_guard<std::mutex> lock(g_uploadBatchMutex); auto it = g_activeUploadBatches.find(key); if (it == g_activeUploadBatches.end()) return; it->second.deletes.erase(filename); it->second.uploads.insert(filename); + it->second.uploadMeta[filename] = { sha, size, timestamp }; } void BatchTracker_RecordDelete(uint32_t accountId, uint32_t appId, diff --git a/src/common/batch_tracker.h b/src/common/batch_tracker.h index 4821c538..8f24afe7 100644 --- a/src/common/batch_tracker.h +++ b/src/common/batch_tracker.h @@ -3,6 +3,7 @@ #include <atomic> #include <mutex> #include <string> +#include <vector> #include <unordered_map> #include <unordered_set> @@ -10,6 +11,14 @@ namespace CloudIntercept { uint64_t MakeAppAccountKey(uint32_t accountId, uint32_t appId); +// SHA/size/timestamp captured from the exact bytes received at CommitFileUpload, +// so CompleteBatch publishes what was uploaded rather than re-stat'ing disk. +struct UploadFileMeta { + std::vector<uint8_t> sha; // SHA-1 over the uploaded bytes + uint64_t size = 0; + uint64_t timestamp = 0; +}; + struct UploadBatchState { uint64_t batchId = 0; uint64_t assignedCN = 0; // CN assigned by BeginAppUploadBatch (= currentCN + 1) @@ -17,6 +26,7 @@ struct UploadBatchState { std::unordered_set<std::string> uploads; std::unordered_set<std::string> deletes; std::unordered_map<std::string, uint32_t> filePlatforms; // filename -> platforms_to_sync + std::unordered_map<std::string, UploadFileMeta> uploadMeta; // filename -> uploaded sha/size/ts }; // Allocate the next unique batch ID (monotonic per process). @@ -28,9 +38,13 @@ uint64_t BatchTracker_ActiveId(uint32_t accountId, uint32_t appId); // Create a new batch for this (account, app) with the given batch ID. void BatchTracker_Begin(uint32_t accountId, uint32_t appId, uint64_t batchId, uint64_t assignedCN, uint64_t appBuildId); -// Record a file upload or delete in the active batch. No-op if no active batch. +// Record a file upload in the active batch with the uploaded bytes' SHA/size/ts. +// No-op if no active batch. void BatchTracker_RecordUpload(uint32_t accountId, uint32_t appId, - const std::string& filename); + const std::string& filename, + const std::vector<uint8_t>& sha, + uint64_t size, uint64_t timestamp); +// Record a file delete in the active batch. No-op if no active batch. void BatchTracker_RecordDelete(uint32_t accountId, uint32_t appId, const std::string& filename); diff --git a/src/common/cloud_storage.cpp b/src/common/cloud_storage.cpp index e099bfcc..826b3c48 100644 --- a/src/common/cloud_storage.cpp +++ b/src/common/cloud_storage.cpp @@ -1269,27 +1269,24 @@ std::vector<uint8_t> RetrieveBlob(uint32_t accountId, uint32_t appId, // CAS: resolve filename->SHA, download by SHA. if (cloudActive) { - // Local manifest is only a cache; on a fresh machine it's empty, so SHA - // resolution must fall back to authoritative cloud state. Priority order: - // local manifest -> cloud state -> caller's expectedShaHex (also cloud-derived). - Manifest manifest = LoadLocalManifest(accountId, appId); - auto mit = manifest.find(filename); + // Prefer the SHA from cloud state (the record the changelist served Steam); + // the local manifest is a cache and goes stale before a download. Priority: + // cloud state -> local manifest (offline) -> caller's expectedShaHex. std::string shaHex; - if (mit != manifest.end() && !mit->second.sha.empty()) { - shaHex = ShaToHex(mit->second.sha); + auto cloud = FetchCloudStateForServe(accountId, appId); + if (cloud.status == StateFetchStatus::Ok) { + auto cit = cloud.state.files.find(filename); + if (cit != cloud.state.files.end() && !cit->second.sha.empty()) + shaHex = ShaToHex(cit->second.sha); } if (shaHex.empty()) { - // Local cache miss -> consult the authoritative cloud state. Serve - // path: use the cache-aware accessor (faithful to a single restore - // burst snapshot; falls back to live when stale or under contention). - auto cloud = FetchCloudStateForServe(accountId, appId); - if (cloud.status == StateFetchStatus::Ok) { - auto cit = cloud.state.files.find(filename); - if (cit != cloud.state.files.end() && !cit->second.sha.empty()) { - shaHex = ShaToHex(cit->second.sha); - LOG("[CloudStorage] RetrieveBlob: resolved SHA from cloud state for %s (local manifest miss)", - filename.c_str()); - } + // Cloud state unavailable -> fall back to the local manifest cache. + Manifest manifest = LoadLocalManifest(accountId, appId); + auto mit = manifest.find(filename); + if (mit != manifest.end() && !mit->second.sha.empty()) { + shaHex = ShaToHex(mit->second.sha); + LOG("[CloudStorage] RetrieveBlob: cloud state unavailable, using local manifest SHA for %s", + filename.c_str()); } } if (shaHex.empty() && !expectedShaHex.empty()) { @@ -1497,21 +1494,23 @@ std::vector<uint8_t> RetrieveBlob(uint32_t accountId, uint32_t appId, } } - // Cloud failed -- fall back to local cache if SHA matches. + // Cloud failed -- fall back to the local cache only if its SHA matches what + // the server published. Serving mismatched bytes would look synced but be + // stale, so prefer failing the download (Steam retries) over that. std::vector<uint8_t> cached; if (TryReadCachedBlob(localPath, filename, cached)) { - if (mit != manifest.end() && !mit->second.sha.empty()) { - auto cachedSha = FileUtil::SHA1(cached.data(), cached.size()); - if (cachedSha == mit->second.sha) { + if (!shaHex.empty()) { + auto cachedSha = ShaToHex(FileUtil::SHA1(cached.data(), cached.size())); + if (cachedSha == shaHex) { LOG("[CloudStorage] RetrieveBlob: cloud unavailable, cache SHA valid: %s (%zu bytes)", filename.c_str(), cached.size()); if (found) *found = true; return cached; } - LOG("[CloudStorage] RetrieveBlob: cloud unavailable, cache SHA MISMATCH: %s", - filename.c_str()); + LOG("[CloudStorage] RetrieveBlob: cloud unavailable, cache SHA MISMATCH (have %s, need %s): %s -- not serving stale", + cachedSha.c_str(), shaHex.c_str(), filename.c_str()); } else { - LOG("[CloudStorage] RetrieveBlob: cloud unavailable, no manifest SHA for %s, serving cached (%zu bytes)", + LOG("[CloudStorage] RetrieveBlob: cloud unavailable, no authoritative SHA for %s, serving cached (%zu bytes)", filename.c_str(), cached.size()); if (found) *found = true; return cached; @@ -1576,6 +1575,96 @@ bool DeleteBlobStaged(uint32_t accountId, uint32_t appId, return true; } +// Ensure every file in `state` has its blob durable on the provider before the +// manifest is published: re-upload from the local cache (heal) or drop the entry +// (forget) rather than advertise a blob that 404s elsewhere. Returns false only +// when the cloud listing is unavailable (can't tell durable from phantom). +bool VerifyAndHealManifestForPublish(uint32_t accountId, uint32_t appId, + CloudAppState& state) { + if (state.files.empty()) return true; + + InflightSyncScope guard; + if (!guard) return true; // shutting down: leave state untouched + if (!g_provider || !g_provider->IsAuthenticated()) + return true; // local-only: nothing to verify against + + // Account-scope metadata is filename-addressed, not CAS; skip. + if (appId == CloudIntercept::kAccountScopeAppId) return true; + + std::string blobPrefix = std::to_string(accountId) + "/" + + std::to_string(appId) + "/blobs/"; + std::vector<ICloudProvider::FileInfo> remoteBlobs; + bool complete = false; + if (!g_provider->ListChecked(blobPrefix, remoteBlobs, &complete) || !complete) { + LOG("[CloudStorage] VerifyManifest app %u: blob listing unavailable; not publishing", + appId); + return false; + } + + // Build the set of SHAs and legacy paths actually present on the provider. + auto isHexSha = [](const std::string& s) { + return s.size() == 40 && + s.find_first_not_of("0123456789abcdef") == std::string::npos; + }; + std::unordered_set<std::string> cloudShas; // sha hex present (canonical or legacy CAS) + std::unordered_set<std::string> cloudFilenames; // legacy filename-addressed blobs present + for (const auto& fi : remoteBlobs) { + if (fi.path.size() <= blobPrefix.size()) continue; + std::string rel = fi.path.substr(blobPrefix.size()); + size_t lastSlash = rel.rfind('/'); + if (lastSlash != std::string::npos && isHexSha(rel.substr(lastSlash + 1))) { + cloudShas.insert(rel.substr(lastSlash + 1)); // canonical filename/sha + } else if (isHexSha(rel)) { + cloudShas.insert(rel); // legacy blobs/sha + } else { + cloudFilenames.insert(rel); // legacy blobs/filename + } + } + + std::vector<std::string> healed, dropped; + for (auto it = state.files.begin(); it != state.files.end(); ) { + const std::string& filename = it->first; + const FileEntry& fe = it->second; + + std::string shaHex = fe.sha.empty() ? std::string() : ShaToHex(fe.sha); + bool present = (!shaHex.empty() && cloudShas.count(shaHex) > 0) || + cloudFilenames.count(filename) > 0; + if (present) { ++it; continue; } + + // Blob missing on cloud. Heal from the local cache only if it still hashes + // to the entry's SHA; otherwise it diverged, so drop the entry. + if (!shaHex.empty()) { + std::vector<uint8_t> local = LocalStorage::ReadFile(accountId, appId, filename); + bool localMatches = (ShaToHex(FileUtil::SHA1(local.data(), local.size())) == shaHex); + if (localMatches) { + ICloudProvider::UploadItem item; + item.path = CloudBlobPathByNameAndSHA(accountId, appId, filename, shaHex); + item.data = std::move(local); + std::vector<ICloudProvider::UploadItem> one; + one.push_back(std::move(item)); + if (g_provider->UploadBatch(one)) { + healed.push_back(filename); + ++it; + continue; + } + LOG("[CloudStorage] VerifyManifest app %u: heal upload failed for %s", + appId, filename.c_str()); + // Upload failed -> not durable; drop so we never advertise it. + } + } + + dropped.push_back(filename); + it = state.files.erase(it); + } + + if (!healed.empty() || !dropped.empty()) { + InvalidateBlobIndex(accountId, appId); + LOG("[CloudStorage] VerifyManifest app %u: healed %zu, dropped %zu phantom file(s)", + appId, healed.size(), dropped.size()); + } + return true; +} + bool PromoteStagedBatchForCommit(uint32_t accountId, uint32_t appId, uint64_t batchId, const std::vector<std::string>& uploads, diff --git a/src/common/cloud_storage.h b/src/common/cloud_storage.h index fdd2921f..e3f8f4e1 100644 --- a/src/common/cloud_storage.h +++ b/src/common/cloud_storage.h @@ -15,6 +15,8 @@ namespace CloudStorage { +struct CloudAppState; // defined in app_state.h + void Init(const std::string& localRoot, std::unique_ptr<ICloudProvider> provider); void Shutdown(); bool IsCloudActive(); @@ -46,6 +48,12 @@ bool PromoteStagedBatchForCommit(uint32_t accountId, uint32_t appId, const std::vector<std::string>& uploads, const std::vector<std::string>& deletes); +// Verifies every file in `state` has its CAS blob durably on the provider before +// its manifest is published; heals from the local cache or drops phantom entries. +// Returns false only when the cloud blob listing is unavailable (don't publish). +bool VerifyAndHealManifestForPublish(uint32_t accountId, uint32_t appId, + CloudAppState& state); + std::vector<uint64_t> ListStagedBatchIds(uint32_t accountId, uint32_t appId); bool RemoveStagedBatch(uint32_t accountId, uint32_t appId, uint64_t batchId); diff --git a/src/common/rpc_handlers.cpp b/src/common/rpc_handlers.cpp index d4f4f515..b4227033 100644 --- a/src/common/rpc_handlers.cpp +++ b/src/common/rpc_handlers.cpp @@ -569,7 +569,6 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB uint64_t appBuildIdHwm = 0; CloudStorage::CloudAppState fetchedState; // retained for quota caching bool haveFetchedState = false; - bool cloudStateNotFound = false; // true ONLY on genuine NotFound (not fetch error) if (CloudStorage::IsCloudActive()) { SetRpcCrashContext("GetChangelist:fetch-cloud", "Cloud.GetAppFileChangelist#1", appId); @@ -590,18 +589,12 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB fetchedState = state; haveFetchedState = true; - uint64_t localCN = LocalStorage::GetChangeNumber(accountId, appId); - if (state.cn > localCN) { - LOG("[NS-CL] GetAppFileChangelist app=%u: cloud CN=%llu > local CN=%llu, syncing local", - appId, state.cn, localCN); - CloudStorage::SaveManifestLocal(accountId, appId, cloudManifest); - LocalStorage::SetChangeNumber(accountId, appId, state.cn); - } - + // Read-only: serve cloud state, don't advance localCN. Adopting it before + // the blobs are on disk would make a later sweep think we're in sync and + // skip downloads. SyncFromCloud advances localCN once blobs are durable. LOG("[NS-CL] GetAppFileChangelist app=%u: cloud state CN=%llu (%zu files)", appId, cloudCN, cloudManifest.size()); } else if (stateResult.status == CloudStorage::StateFetchStatus::NotFound) { - cloudStateNotFound = true; LOG("[NS-CL] GetAppFileChangelist app=%u: no cloud state (new app), using local", appId); } else { @@ -633,85 +626,9 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB appId, cloudCN, cloudManifest.size()); } - // Reconciliation safety net: if our local blob store contains non-reserved - // files the REAL cloud manifest is missing, publish a MERGED manifest at a - // non-rewinding CN. Native upload (CompleteBatch) is the primary publisher; - // this catches the case where blobs exist locally but cloud state was lost - // and no new upload has happened yet. Publishes at strictly-above-cloud CN so - // it can never rewind (issue #53 was caused by the old AutoCloud bootstrap - // publishing a stale CN; that component has been removed entirely). - // Only reconcile when we KNOW the real cloud contents: either a successful - // fetch (haveFetchedState) or a definitive NotFound (empty cloud). NEVER on a - // transient fetch failure -- treating that as "empty" could republish over - // good cloud state during a network blip. - if (CloudStorage::IsCloudActive() && (haveFetchedState || cloudStateNotFound)) { - SetRpcCrashContext("GetChangelist:reconcile-local", "Cloud.GetAppFileChangelist#1", appId); - CloudStorage::Manifest localBlobs = - CloudStorage::BuildManifestFromLocalBlobs(accountId, appId); - - // Compare against the REAL cloud manifest only. cloudManifest/cloudCN may - // have been pre-filled from the local-fallback path above (when no cloud - // state exists), which would mask missing files. cloudFileEntries is - // populated ONLY by an actual cloud fetch (haveFetchedState), so use it as - // the authoritative "what's really in the cloud" set. NotFound -> empty. - static const std::unordered_map<std::string, CloudStorage::FileEntry> kEmptyCloudFiles; - const auto& realCloudFiles = haveFetchedState ? cloudFileEntries : kEmptyCloudFiles; - - size_t missingFromCloud = 0; - for (const auto& [name, entry] : localBlobs) { - if (IsReservedBlobFilename(name)) continue; - if (realCloudFiles.find(name) == realCloudFiles.end()) ++missingFromCloud; - } - - if (missingFromCloud > 0) { - // Merge: start from real cloud files, add/refresh from local blobs. - CloudStorage::CloudAppState mergedState; - for (const auto& [name, fe] : realCloudFiles) { - if (IsReservedBlobFilename(name)) continue; - mergedState.files[name] = fe; - } - for (const auto& [name, me] : localBlobs) { - if (IsReservedBlobFilename(name)) continue; - CloudStorage::FileEntry fe; - fe.sha = me.sha; - fe.timestamp = me.timestamp; - fe.size = me.size; - mergedState.files[name] = std::move(fe); - } - uint64_t baseCN = cloudCN > LocalStorage::GetChangeNumber(accountId, appId) - ? cloudCN - : LocalStorage::GetChangeNumber(accountId, appId); - mergedState.cn = baseCN + 1; // strictly above any seen CN -> never rewinds - mergedState.appBuildId = appBuildIdHwm; - - LOG("[NS-CL] Reconcile app %u: %zu local file(s) missing from cloud; " - "publishing merged manifest (%zu files) at CN=%llu", - appId, missingFromCloud, mergedState.files.size(), - (unsigned long long)mergedState.cn); - - auto statePtr = std::make_shared<CloudStorage::CloudAppState>(std::move(mergedState)); - uint32_t asyncAcct = accountId; - uint32_t asyncApp = appId; - std::thread([statePtr, asyncAcct, asyncApp] { - CloudStorage::InflightSyncScope guard; - if (!guard.entered) return; - auto syncMtx = CloudStorage::AcquireAppSyncMutex(asyncAcct, asyncApp); - std::lock_guard<std::mutex> lock(*syncMtx); - // Re-fetch under lock; recompute CN above the freshest cloud CN so - // a concurrent native publish can't be rewound. - auto existing = CloudStorage::FetchCloudState(asyncAcct, asyncApp); - if (existing.status == CloudStorage::StateFetchStatus::Ok) { - if (existing.state.cn >= statePtr->cn) { - statePtr->cn = existing.state.cn + 1; - } - // Don't clobber an active session lock. - statePtr->session = existing.state.session; - } - CloudStorage::PublishCloudState(asyncAcct, asyncApp, *statePtr); - LocalStorage::SetChangeNumber(asyncAcct, asyncApp, statePtr->cn); - }).detach(); - } - } + // Answer with real cloud state and let Steam drive uploads via its own + // BeginBatch/CompleteBatch. Reconciling a merged manifest from local blobs here + // advertised files before their blobs were durable -- the phantom-manifest bug. // Build file list - either from cloud manifest (fast path) or local blobs std::vector<LocalStorage::FileEntry> files; @@ -1182,11 +1099,8 @@ RpcResult HandleLaunchIntent(uint32_t appId, const std::vector<PB::Field>& reqBo state.session.machineName = currentSession.machineName; state.session.timeLastUpdated = now; state.session.operation = "active"; - if (!CloudStorage::PublishCloudState(accountId, appId, state, stateResult.etag)) { - // Retry once without etag. - if (!CloudStorage::PublishCloudState(accountId, appId, state)) { - LOG("[NS] LaunchIntent app=%u: session override publish failed after retry", appId); - } + if (!CloudStorage::PublishCloudState(accountId, appId, state)) { + LOG("[NS] LaunchIntent app=%u: session override publish failed", appId); } LOG("[NS] LaunchIntent app=%u: forced session override (machine=%s, client=%llu)", appId, currentSession.machineName.c_str(), currentSession.clientId); @@ -1196,9 +1110,11 @@ RpcResult HandleLaunchIntent(uint32_t appId, const std::vector<PB::Field>& reqBo state.session.machineName = currentSession.machineName; state.session.timeLastUpdated = now; state.session.operation = "active"; - if (!CloudStorage::PublishCloudState(accountId, appId, state, stateResult.etag)) { - // Retry without etag (stale from racing ReleaseCloudSession). - LOG("[NS] LaunchIntent app=%u: session acquire publish failed, retrying without etag", appId); + if (!CloudStorage::PublishCloudState(accountId, appId, state)) { + // Publish refused/failed -- typically the CN-monotonic guard saw a + // newer cloud state (another machine published in the window). + // Re-fetch the fresh state and re-apply our session onto it. + LOG("[NS] LaunchIntent app=%u: session acquire publish failed, re-fetching to reconcile", appId); auto freshResult = CloudStorage::FetchCloudState(accountId, appId); if (freshResult.status == CloudStorage::StateFetchStatus::Ok) { auto& freshState = freshResult.state; @@ -1341,8 +1257,17 @@ RpcResult HandleBeginBatch(uint32_t appId, const std::vector<PB::Field>& reqBody return RpcResult(PB::Writer(), kEResultFail); } - uint64_t currentCN = LocalStorage::GetChangeNumber(accountId, appId); - uint64_t assignedCN = currentCN + 1; + // The CN returned here is what Steam records as synced and what we must publish + // at CompleteBatch -- they have to match, or Steam re-downloads what it just + // uploaded. Assign max(local, cloud)+1, strictly above whatever the cloud holds. + uint64_t localCN = LocalStorage::GetChangeNumber(accountId, appId); + uint64_t cloudCN = 0; + if (CloudStorage::IsCloudActive()) { + auto cloud = CloudStorage::FetchCloudStateForServe(accountId, appId); + if (cloud.status == CloudStorage::StateFetchStatus::Ok) + cloudCN = cloud.state.cn; + } + uint64_t assignedCN = (std::max)(localCN, cloudCN) + 1; uint64_t appBuildId = 0; PrepareBatchCanonicalTokens(accountId, appId); PendingOpsJournal::RecordUploadBatchStart(accountId, appId); @@ -1523,6 +1448,29 @@ RpcResult HandleCommitFileUpload(uint32_t appId, const std::vector<PB::Field>& r auto blobData = HttpServer::ReadBlob(accountId, appId, cleanName); LOG("[NS-UP] committed: %s (%zu bytes)", cleanName.c_str(), blobData.size()); + // Hash the exact bytes uploaded for the manifest; re-stat'ing the disk + // file at CompleteBatch can race a write and publish a mismatched SHA. + std::vector<uint8_t> uploadedSha = + FileUtil::SHA1(blobData.data(), blobData.size()); + uint64_t uploadedSize = blobData.size(); + + // Record the file mtime so a peer's localtime matches our remotetime + // (native keeps remotetime == time); else the eval shows a wrong arrow. + // The commit RPC has only the filename, so when the file isn't in our + // mirror, round the wall clock instead of flooring time(nullptr). + uint64_t uploadedTs = 0; + if (auto e = LocalStorage::GetFileEntry(accountId, appId, cleanName)) { + uploadedTs = e->timestamp; + } else { + struct timespec ts_now; + if (timespec_get(&ts_now, TIME_UTC) == TIME_UTC) + uploadedTs = (uint64_t)(ts_now.tv_sec + (ts_now.tv_nsec >= 500000000L ? 1 : 0)); + else + uploadedTs = (uint64_t)time(nullptr); + LOG("[NS-UP] note: no cached mtime for %s; recording wall-clock ts=%llu", + cleanName.c_str(), (unsigned long long)uploadedTs); + } + { const uint8_t* blobPtr = blobData.empty() ? nullptr : blobData.data(); uint64_t batchId = BatchTracker_ActiveId(accountId, appId); @@ -1537,11 +1485,10 @@ RpcResult HandleCommitFileUpload(uint32_t appId, const std::vector<PB::Field>& r committed = false; HttpServer::DeleteBlob(accountId, appId, cleanName); } else if (!isStaged) { - auto entry = LocalStorage::GetFileEntry(accountId, appId, cleanName); - if (entry) { - CloudStorage::UpdateManifestEntry(accountId, appId, cleanName, - entry->sha, entry->timestamp, entry->rawSize); - } + // Non-batch path: update the manifest with the uploaded bytes' + // SHA/size directly (not a disk re-read). + CloudStorage::UpdateManifestEntry(accountId, appId, cleanName, + uploadedSha, uploadedTs, uploadedSize); } } @@ -1549,7 +1496,8 @@ RpcResult HandleCommitFileUpload(uint32_t appId, const std::vector<PB::Field>& r if (RecordFileToken(accountId, appId, cleanName, rootToken)) { MarkFileTokensDirty(accountId, appId); } - BatchTracker_RecordUpload(accountId, appId, cleanName); + BatchTracker_RecordUpload(accountId, appId, cleanName, + uploadedSha, uploadedSize, uploadedTs); } } else { LOG("[NS-UP] WARNING: blob not found after PUT for %s (clean=%s)", filename.c_str(), cleanName.c_str()); @@ -1803,8 +1751,22 @@ RpcResult HandleCompleteBatch(uint32_t appId, const std::vector<PB::Field>& reqB haveCloudBase = true; } } - // If cloud CN is behind local, rebuild file list from manifest (keep session/quota). + // Publish at exactly the CN we gave Steam at BeginBatch. If the cloud + // advanced past it since (another device uploaded in the window), refuse the + // commit rather than bump to a CN Steam doesn't know, and let Steam re-sync. uint64_t localCN = LocalStorage::GetChangeNumber(accountId, appId); + if (haveCloudBase && state.cn >= newCN) { + LOG("[NS] CompleteBatch app %u: cloud CN %llu advanced to/past assignedCN %llu since " + "BeginBatch (concurrent update); refusing commit so Steam re-syncs", + appId, (unsigned long long)state.cn, (unsigned long long)newCN); + PendingOpsJournal::RecordUploadBatchInterrupted(accountId, appId); + BatchTracker_Clear(accountId, appId, batch.batchId); + ClearFileTokensDirty(accountId, appId); + ClearBatchCanonicalTokens(accountId, appId); + return PB::Writer(); + } + + // If cloud CN is behind local, rebuild file list from manifest (keep session/quota). if (!haveCloudBase || state.cn < localCN) { if (haveCloudBase && state.cn < localCN) { LOG("[NS] CompleteBatch app %u: cloud CN %llu < local CN %llu, rebuilding file list from local manifest", @@ -1826,12 +1788,23 @@ RpcResult HandleCompleteBatch(uint32_t appId, const std::vector<PB::Field>& reqB for (const auto& filename : uploads) { if (IsReservedBlobFilename(filename)) continue; - auto entry = LocalStorage::GetFileEntry(accountId, appId, filename); - if (!entry.has_value()) continue; CloudStorage::FileEntry fe; - fe.sha = entry->sha; - fe.timestamp = entry->timestamp; - fe.size = entry->rawSize; + // Prefer the SHA/size captured at CommitFileUpload; re-stat'ing disk here + // can read a racing file and publish a SHA that doesn't match the blob. + auto metaIt = batch.uploadMeta.find(filename); + if (metaIt != batch.uploadMeta.end()) { + fe.sha = metaIt->second.sha; + fe.timestamp = metaIt->second.timestamp; + fe.size = metaIt->second.size; + } else { + // No recorded upload meta (shouldn't happen for a real upload); + // fall back to disk so we don't silently drop the entry. + auto entry = LocalStorage::GetFileEntry(accountId, appId, filename); + if (!entry.has_value()) continue; + fe.sha = entry->sha; + fe.timestamp = entry->timestamp; + fe.size = entry->rawSize; + } auto ptIt = batch.filePlatforms.find(filename); fe.platformsToSync = (ptIt != batch.filePlatforms.end()) ? ptIt->second : 0xFFFFFFFFu; @@ -2000,44 +1973,41 @@ RpcResult HandleFileDownload(uint32_t appId, const std::vector<PB::Field>& reqBo uint64_t fileSize = 0; uint64_t timestamp = 0; std::vector<uint8_t> sha; - // Check local manifest FIRST - this is saved from cloud during GetChangelist - // and represents what we told Steam the file looks like. Must match what we serve. - auto manifest = CloudStorage::LoadLocalManifest(accountId, appId); - auto it = manifest.find(cleanName); - if (it != manifest.end()) { - fileSize = it->second.size; - timestamp = it->second.timestamp; - sha = it->second.sha; - } else { - // Fallback: check local storage (for files uploaded locally but not yet in manifest) + // Use the SHA from cloud state -- the record the changelist served Steam. The + // local manifest cache is stale on a device that hasn't downloaded the newer + // version, so serving its SHA would point at a blob that no longer exists. + auto cloud = CloudStorage::FetchCloudStateForServe(accountId, appId); + if (cloud.status == CloudStorage::StateFetchStatus::Ok) { + auto cit = cloud.state.files.find(cleanName); + if (cit != cloud.state.files.end() && !cit->second.sha.empty()) { + fileSize = cit->second.size; + timestamp = cit->second.timestamp; + sha = cit->second.sha; + } + } + + // Fallbacks only when cloud state is unavailable (offline / not-yet-published + // local upload). Local manifest cache, then a direct disk stat. + if (sha.empty()) { + auto manifest = CloudStorage::LoadLocalManifest(accountId, appId); + auto it = manifest.find(cleanName); + if (it != manifest.end()) { + fileSize = it->second.size; + timestamp = it->second.timestamp; + sha = it->second.sha; + } + } + if (sha.empty()) { auto entry = LocalStorage::GetFileEntry(accountId, appId, cleanName); if (entry) { fileSize = entry->rawSize; timestamp = entry->timestamp; sha = entry->sha; - } else { - // The local manifest is only a CACHE -- empty on a fresh machine (new - // install, post-wipe, 2nd device). The download response MUST carry the - // SHA/size or Steam rejects the downloaded file ("Download Failure" - // after a successful HTTP transfer). Resolve from the authoritative - // cloud state, same as RetrieveBlob does. Without this, multi-device / - // fresh-install downloads fail. Serve path -> cache-aware accessor. - auto cloud = CloudStorage::FetchCloudStateForServe(accountId, appId); - if (cloud.status == CloudStorage::StateFetchStatus::Ok) { - auto cit = cloud.state.files.find(cleanName); - if (cit != cloud.state.files.end() && !cit->second.sha.empty()) { - fileSize = cit->second.size; - timestamp = cit->second.timestamp; - sha = cit->second.sha; - LOG("[NS-DL] FileDownload app=%u: resolved SHA/size from cloud state for %s (local manifest miss)", - appId, cleanName.c_str()); - } - } - // Last resort: blob size on disk (no SHA available). - if (sha.empty()) - fileSize = HttpServer::GetBlobSize(accountId, appId, cleanName); } } + // Last resort: blob size on disk (no SHA available). + if (sha.empty()) + fileSize = HttpServer::GetBlobSize(accountId, appId, cleanName); LOG("[NS-DL] FileDownload app=%u file=%s (clean=%s) size=%llu -> %s%s", appId, filename.c_str(), cleanName.c_str(), fileSize, urlHost.c_str(), urlPath.c_str()); diff --git a/src/common/stats_store.cpp b/src/common/stats_store.cpp index 12ff781c..982f6d56 100644 --- a/src/common/stats_store.cpp +++ b/src/common/stats_store.cpp @@ -11,6 +11,7 @@ #include <chrono> #include <algorithm> #include <cstring> +#include <thread> namespace fs = std::filesystem; @@ -26,8 +27,16 @@ static std::unordered_map<uint32_t, AppStats> g_cache; static std::unordered_map<uint32_t, bool> g_dirty; // Cloud-backing provider (installed by the platform layer; see SetCloudProvider). -static CloudPullFn g_cloudPull; -static CloudPushFn g_cloudPush; +// Account-wide blob: one network read for every app, not one per app. +static CloudPullAllFn g_cloudPullAll; +static CloudPushAllFn g_cloudPushAll; + +// Last account blob pulled from cloud (appId -> stats JSON). Populated by one +// network read; per-app load/merge reads from here. Guarded by g_mutex. +static std::unordered_map<uint32_t, std::string> g_cloudBlobByApp; +// Set when an app's entry in g_cloudBlobByApp changed and the account blob needs +// to be re-uploaded; cleared by PushAccountBlobIfDirty. Guarded by g_mutex. +static bool g_accountBlobDirty = false; // Resolves the current Steam accountId for locating native UserGameStats blobs. static AccountIdProvider g_accountIdProvider; @@ -42,10 +51,35 @@ static void WriteAppStats(uint32_t appId, const AppStats& stats, bool pushCloud) // Playtime helpers (defined below; forward-declared for use in (de)serialization). static void RecomputePlaytimeTotals(PlaytimeData& pt); -void SetCloudProvider(CloudPullFn pull, CloudPushFn push) { +void SetCloudProvider(CloudPullAllFn pullAll, CloudPushAllFn pushAll) { + std::lock_guard<std::mutex> lock(g_mutex); + g_cloudPullAll = std::move(pullAll); + g_cloudPushAll = std::move(pushAll); +} + +// Pull the account-wide blob once into g_cloudBlobByApp. Network I/O; caller must +// not hold g_mutex. Returns true if fetched (even if empty). +static bool RefreshCloudBlobCache() { + CloudPullAllFn pull; + { + std::lock_guard<std::mutex> lock(g_mutex); + pull = g_cloudPullAll; + } + if (!pull) return false; + + std::unordered_map<uint32_t, std::string> fetched; + if (!pull(fetched)) return false; + std::lock_guard<std::mutex> lock(g_mutex); - g_cloudPull = std::move(pull); - g_cloudPush = std::move(push); + g_cloudBlobByApp = std::move(fetched); + return true; +} + +// Return the cached cloud JSON for one app (from the last account-blob pull). +// Caller holds g_mutex. Empty string if the app has no cloud stats. +static std::string CloudJsonForAppLocked(uint32_t appId) { + auto it = g_cloudBlobByApp.find(appId); + return it != g_cloudBlobByApp.end() ? it->second : std::string(); } void SetAccountIdProvider(AccountIdProvider provider) { @@ -333,21 +367,26 @@ static void ReconcileLocalConfig(const std::string& cloudRoot, const std::string if (!isNs) return true; LOG("[Stats] Reconcile: considering app %u (ns=1)", appId); - // localconfig "Apps\<id>\Playtime" is the server's cross-platform total - // (sub_1389C7930 -> sub_1389CB7D0 writes it from GetLastPlayedTimes - // response field 4), not this machine's minutes -- and we write it - // ourselves by answering those RPCs. So take only LastPlayed (a display - // hint) here; per-platform fields are owned by EndSession. + // CR normally writes localconfig Playtime itself, so reading it back is + // circular -- except on first run from a pre-playtime CR version, where + // it's the only record of past minutes. Seed it once so we don't serve + // zeros and wipe the displayed playtime. std::string appIdStr = std::to_string(appId); const char* appPath[] = {"UserLocalConfigStore", "Software", "Valve", "Steam", appsKey, appIdStr.c_str()}; uint32_t vdfLastPlayed = 0; + uint32_t vdfPlaytime = 0; + uint32_t vdfPlaytime2wks = 0; VdfUtil::ForEachFieldInSection(vdf, appPath, 6, [&](const VdfUtil::FieldInfo& fi) -> bool { if (fi.key == "LastPlayed") try { vdfLastPlayed = (uint32_t)std::stoul(std::string(fi.value)); } catch (...) {} + else if (fi.key == "Playtime") + try { vdfPlaytime = (uint32_t)std::stoul(std::string(fi.value)); } catch (...) {} + else if (fi.key == "Playtime2wks") + try { vdfPlaytime2wks = (uint32_t)std::stoul(std::string(fi.value)); } catch (...) {} return true; }); - if (vdfLastPlayed == 0) return true; + if (vdfLastPlayed == 0 && vdfPlaytime == 0) return true; auto cacheIt = g_cache.find(appId); if (cacheIt == g_cache.end()) { @@ -356,16 +395,40 @@ static void ReconcileLocalConfig(const std::string& cloudRoot, const std::string } AppStats& stats = cacheIt->second; - if (vdfLastPlayed <= stats.playtime.lastPlayedTime) return true; - stats.playtime.lastPlayedTime = vdfLastPlayed; + bool changed = false; + if (vdfLastPlayed > stats.playtime.lastPlayedTime) { + stats.playtime.lastPlayedTime = vdfLastPlayed; + changed = true; + } + + // First-run migration: seed the localconfig total into a dedicated + // bucket so it's surfaced and max'd across devices without + // double-counting later CR-tracked sessions (keyed by hostname). + static const std::string kMigratedBucket = "__migrated_localconfig"; + if (stats.playtime.perDevice.empty() && vdfPlaytime > 0) { + DevicePlaytime& mig = stats.playtime.perDevice[kMigratedBucket]; +#ifdef _WIN32 + mig.windows = vdfPlaytime; +#elif defined(__APPLE__) + mig.mac = vdfPlaytime; +#else + mig.lin = vdfPlaytime; +#endif + stats.playtime.minutesLastTwoWeeks = + (std::max)(stats.playtime.minutesLastTwoWeeks, vdfPlaytime2wks); + RecomputePlaytimeTotals(stats.playtime); + changed = true; + } + + if (!changed) return true; // Local-only persist: startup reconcile must not push to the cloud. WriteAppStats(appId, stats, false); reconciled++; - LOG("[Stats] Reconciled app %u lastPlayed=%u (playtime owned by session tracking: win=%u mac=%u linux=%u)", + LOG("[Stats] Reconciled app %u lastPlayed=%u (win=%u mac=%u linux=%u, migrated=%u)", appId, vdfLastPlayed, stats.playtime.playtimeWindows, stats.playtime.playtimeMac, - stats.playtime.playtimeLinux); + stats.playtime.playtimeLinux, vdfPlaytime); return true; }); } @@ -817,10 +880,12 @@ bool LoadAppStats(uint32_t appId, AppStats& out) { haveLocal = true; } - // Always consult the cloud and merge per-platform: a local copy from a - // prior session must not hide another device's playtime in the cloud. - std::string cloud; - if (g_cloudPull && g_cloudPull(appId, cloud) && !cloud.empty()) { + // Consult the cached account blob (pulled once by SeedApps/RefreshFromCloud) + // and merge per-platform: a local copy from a prior session must not hide + // another device's playtime/unlocks in the cloud. No per-app network read -- + // the whole account blob was fetched in one shot. + std::string cloud = CloudJsonForAppLocked(appId); + if (!cloud.empty()) { AppStats cloudStats; if (ParseAppStatsJson(cloud, cloudStats)) { if (!haveLocal) { @@ -873,7 +938,33 @@ static void WriteAppStats(uint32_t appId, const AppStats& stats, bool pushCloud) sf.write(reinterpret_cast<const char*>(stats.schema.data()), stats.schema.size()); } - if (pushCloud && g_cloudPush) g_cloudPush(appId, json); + if (pushCloud) { + // Update this app's entry in the cached account blob and flag the blob + // for upload. The actual cloud write is a single coalesced account-blob + // push (PushAccountBlobIfDirty), not a per-app round-trip. + g_cloudBlobByApp[appId] = json; + g_accountBlobDirty = true; + } +} + +// Push the account-wide stats blob if any app changed since the last push (one +// write for all apps). The push does blocking curl I/O, so it must run off the +// caller's thread -- EndSession runs on Steam's GamesPlayed net thread at exit, +// where a synchronous request crashed. Copy the snapshot under the lock and hand +// the RMW to a detached worker. +static void PushAccountBlobIfDirty() { + CloudPushAllFn push; + std::unordered_map<uint32_t, std::string> snapshot; + { + std::lock_guard<std::mutex> lock(g_mutex); + if (!g_accountBlobDirty || !g_cloudPushAll) return; + push = g_cloudPushAll; + snapshot = g_cloudBlobByApp; // copy under lock + g_accountBlobDirty = false; // clear before releasing (re-set on later change) + } + std::thread([push = std::move(push), snapshot = std::move(snapshot)]() { + push(snapshot); + }).detach(); } void SaveAppStats(uint32_t appId, const AppStats& stats) { @@ -933,16 +1024,22 @@ static bool ReimportNativeStatsLocked(uint32_t appId, AppStats& stats) { } void CaptureNativeUnlocks(uint32_t appId) { - std::lock_guard<std::mutex> lock(g_mutex); - auto& stats = g_cache[appId]; - // Make sure the base data exists (first observation may precede any import). - if (stats.stats.empty()) EnsureNativeImportLocked(appId, stats); - if (ReimportNativeStatsLocked(appId, stats)) { - g_dirty[appId] = true; - SaveAppStats(appId, stats); // pushes to cloud - g_dirty[appId] = false; - LOG("[Stats] Captured native unlocks for app %u (crc=%u)", appId, stats.crcStats); + bool changed = false; + { + std::lock_guard<std::mutex> lock(g_mutex); + auto& stats = g_cache[appId]; + // Make sure the base data exists (first observation may precede any import). + if (stats.stats.empty()) EnsureNativeImportLocked(appId, stats); + if (ReimportNativeStatsLocked(appId, stats)) { + g_dirty[appId] = true; + SaveAppStats(appId, stats); // updates account blob + dirty flag + g_dirty[appId] = false; + changed = true; + LOG("[Stats] Captured native unlocks for app %u (crc=%u)", appId, stats.crcStats); + } } + // A genuine unlock just landed -- push the account blob now (off-lock). + if (changed) PushAccountBlobIfDirty(); } // Core seed/lookup. Caller MUST hold g_mutex. Returns a live cache reference. @@ -992,23 +1089,29 @@ void ResetStats(uint32_t appId) { } void SeedApps(const std::vector<uint32_t>& appIds) { + // One network read for the whole account, not one per app. GetOrCreate then + // reads each app's entry from the cached blob (no further network). + RefreshCloudBlobCache(); for (uint32_t appId : appIds) { if (appId == 0) continue; - GetOrCreate(appId); // pulls cloud blob + imports native + loads local + GetOrCreate(appId); // merges cached cloud blob + imports native + loads local } + // SeedApps also materializes imported native stats; flush the account blob + // once so newly-seeded local stats reach the cloud. + PushAccountBlobIfDirty(); } std::vector<uint32_t> RefreshFromCloud(const std::vector<uint32_t>& appIds) { std::vector<uint32_t> changed; - if (!g_cloudPull) return changed; + // One network read for the whole account, then iterate from the cache. + if (!RefreshCloudBlobCache()) return changed; for (uint32_t appId : appIds) { if (appId == 0) continue; - std::string cloud; - if (!g_cloudPull(appId, cloud) || cloud.empty()) continue; - AppStats cloudStats; - if (!ParseAppStatsJson(cloud, cloudStats)) continue; std::lock_guard<std::mutex> lock(g_mutex); + AppStats cloudStats; + std::string cloud = CloudJsonForAppLocked(appId); + if (cloud.empty() || !ParseAppStatsJson(cloud, cloudStats)) continue; // Hydrate from disk on a cache miss BEFORE merging: operator[] would // otherwise default-construct an EMPTY record, and WriteAppStats would // then truncate a populated local <appId>.json (stats/achievements wiped, @@ -1190,37 +1293,41 @@ void StartSession(uint32_t appId) { } void EndSession(uint32_t appId) { - std::lock_guard<std::mutex> lock(g_mutex); - auto it = g_activeSessions.find(appId); - if (it == g_activeSessions.end()) return; + { + std::lock_guard<std::mutex> lock(g_mutex); + auto it = g_activeSessions.find(appId); + if (it == g_activeSessions.end()) return; - uint32_t now = NowUnix(); - uint32_t elapsed = (now > it->second) ? (now - it->second) : 0; - uint32_t minutes = elapsed / 60; - g_activeSessions.erase(it); + uint32_t now = NowUnix(); + uint32_t elapsed = (now > it->second) ? (now - it->second) : 0; + uint32_t minutes = elapsed / 60; + g_activeSessions.erase(it); + + auto& stats = g_cache[appId]; + // Accrue onto THIS device's own per-device sub-total (keyed by device id), so + // a session here can never overwrite another device's contribution -- even a + // same-platform device's -- under the last-writer-wins cloud blob. + AccrueLocalPlaytime(stats.playtime, minutes); + stats.playtime.minutesLastTwoWeeks += minutes; + stats.playtime.lastPlayedTime = now; + + // Steam flushes the native blob on game close; merge any new unlocks (also + // catches another device's). Gated on sync_achievements, not sync_playtime + // (EndSession runs under the latter). + if (MetadataSync::syncAchievements.load(std::memory_order_relaxed) && + ReimportNativeStatsLocked(appId, stats)) + LOG("[Stats] Session end: merged new native achievements/stats for app %u (crc=%u)", + appId, stats.crcStats); - auto& stats = g_cache[appId]; - // Accrue onto THIS device's own per-device sub-total (keyed by device id), so - // a session here can never overwrite another device's contribution -- even a - // same-platform device's -- under the last-writer-wins cloud blob. - AccrueLocalPlaytime(stats.playtime, minutes); - stats.playtime.minutesLastTwoWeeks += minutes; - stats.playtime.lastPlayedTime = now; - - // Steam flushes the native blob on game close; merge any new unlocks (also - // catches another device's). Gated on sync_achievements, not sync_playtime - // (EndSession runs under the latter). - if (MetadataSync::syncAchievements.load(std::memory_order_relaxed) && - ReimportNativeStatsLocked(appId, stats)) - LOG("[Stats] Session end: merged new native achievements/stats for app %u (crc=%u)", - appId, stats.crcStats); - - // Queue the push (not a blocking curl on the net thread, which raced at close). - g_dirty[appId] = true; - SaveAppStats(appId, stats); - g_dirty[appId] = false; - LOG("[Stats] Session ended for app %u: +%u min (total %u)", - appId, minutes, stats.playtime.minutesForever); + g_dirty[appId] = true; + SaveAppStats(appId, stats); // updates account blob + dirty flag + g_dirty[appId] = false; + LOG("[Stats] Session ended for app %u: +%u min (total %u)", + appId, minutes, stats.playtime.minutesForever); + } + // Push the account blob off-lock (the platform pushAll queues it async, so + // this never blocks the net thread at game close). + PushAccountBlobIfDirty(); } PlaytimeData GetPlaytime(uint32_t appId) { @@ -1251,17 +1358,21 @@ std::vector<uint32_t> GetTrackedApps() { } void FlushAll() { - std::lock_guard<std::mutex> lock(g_mutex); - for (auto& [appId, dirty] : g_dirty) { - if (dirty) { - auto it = g_cache.find(appId); - if (it != g_cache.end()) { - SaveAppStats(appId, it->second); - LOG("[Stats] Flushed app %u to disk", appId); + { + std::lock_guard<std::mutex> lock(g_mutex); + for (auto& [appId, dirty] : g_dirty) { + if (dirty) { + auto it = g_cache.find(appId); + if (it != g_cache.end()) { + SaveAppStats(appId, it->second); // updates account blob + dirty flag + LOG("[Stats] Flushed app %u to disk", appId); + } + dirty = false; } - dirty = false; } } + // Push the account blob once for all flushed apps (outside the lock). + PushAccountBlobIfDirty(); } } // namespace StatsStore diff --git a/src/common/stats_store.h b/src/common/stats_store.h index f72ed30b..52446686 100644 --- a/src/common/stats_store.h +++ b/src/common/stats_store.h @@ -9,14 +9,18 @@ namespace StatsStore { -// Cloud-backing provider callbacks. The store stays decoupled from the -// platform CloudStorage / account-id plumbing: the platform layer installs -// these so per-app stats blobs are pulled on first access and pushed on save. -// pull: return true and fill outJson if a cloud blob exists for appId. -// push: persist the JSON blob for appId to the cloud (fire-and-forget). -using CloudPullFn = std::function<bool(uint32_t appId, std::string& outJson)>; -using CloudPushFn = std::function<void(uint32_t appId, const std::string& json)>; -void SetCloudProvider(CloudPullFn pull, CloudPushFn push); +// Cloud-backing provider callbacks; the platform layer installs these. +// +// Stats sync as a single account-wide blob keyed by appId, not one blob per app +// (which cost a Drive round-trip per app at every startup/poll, stalling launch). +// pullAll: fill `out` (appId -> stats JSON) from the account blob. One read. +// pushAll: persist the snapshot; the platform layer RMW-merges against the live +// blob (so another device isn't clobbered) and skips unchanged uploads. +using CloudPullAllFn = + std::function<bool(std::unordered_map<uint32_t, std::string>& out)>; +using CloudPushAllFn = + std::function<void(const std::unordered_map<uint32_t, std::string>& all)>; +void SetCloudProvider(CloudPullAllFn pullAll, CloudPushAllFn pushAll); struct StatEntry { uint32_t statId; diff --git a/src/platform/linux/cloud_hooks.cpp b/src/platform/linux/cloud_hooks.cpp index a53939b5..643ed5ec 100644 --- a/src/platform/linux/cloud_hooks.cpp +++ b/src/platform/linux/cloud_hooks.cpp @@ -351,24 +351,62 @@ static void EnsureInitialized() { PendingOpsJournal::Init(storageRoot); HttpServer::Start(storageRoot, CloudIntercept::GetAccountId()); - // Native stats / playtime store (cloud-backed). Per-app stats blobs ride - // each app's Steam Cloud sync, same as the CN/root-token metadata blobs. + // Stats sync as one account-wide blob at <accountId>/0/stats.json (appId -> + // stats JSON), not one blob per app (a Drive round-trip per app at startup). StatsStore::SetCloudProvider( - [](uint32_t appId, std::string& outJson) -> bool { + // pullAll: one download of the account blob, split into per-app entries. + [](std::unordered_map<uint32_t, std::string>& out) -> bool { uint32_t accountId = CloudIntercept::GetAccountId(); if (accountId == 0) return false; std::vector<uint8_t> data; if (!CloudStorage::DownloadCloudMetadataWithLegacyFallback( - accountId, appId, "stats.json", nullptr, data) || data.empty()) - return false; - outJson.assign(reinterpret_cast<const char*>(data.data()), data.size()); + accountId, CloudIntercept::kAccountScopeAppId, "stats.json", + nullptr, data) || data.empty()) + return true; // no account blob yet -> empty (not a failure) + Json::Value root = Json::Parse( + std::string(reinterpret_cast<const char*>(data.data()), data.size())); + if (root.type != Json::Type::Object) return true; + for (const auto& [appIdStr, appVal] : root.objVal) { + uint32_t appId = (uint32_t)strtoul(appIdStr.c_str(), nullptr, 10); + if (appId == 0) continue; + out[appId] = Json::Stringify(appVal); + } return true; }, - [](uint32_t appId, const std::string& json) { + // pushAll: RMW-merge our snapshot onto the live blob (don't clobber + // another device) and upload once; skip if nothing changed. + [](const std::unordered_map<uint32_t, std::string>& all) { uint32_t accountId = CloudIntercept::GetAccountId(); if (accountId == 0) return; - // Queued: serializes on the cloud work queue (safe from any thread). - CloudStorage::UploadCloudMetadataTextAsync(accountId, appId, "stats.json", json); + + Json::Value root = Json::Object(); + std::vector<uint8_t> cur; + if (CloudStorage::DownloadCloudMetadataWithLegacyFallback( + accountId, CloudIntercept::kAccountScopeAppId, "stats.json", + nullptr, cur) && !cur.empty()) { + Json::Value parsed = Json::Parse( + std::string(reinterpret_cast<const char*>(cur.data()), cur.size())); + if (parsed.type == Json::Type::Object) root = std::move(parsed); + } + + bool changed = false; + for (const auto& [appId, json] : all) { + if (appId == 0) continue; + std::string key = std::to_string(appId); + Json::Value appVal = Json::Parse(json); + std::string existing = root.has(key) + ? Json::Stringify(root.objVal[key]) : std::string(); + std::string updated = Json::Stringify(appVal); + if (existing != updated) { + root.objVal[key] = std::move(appVal); + changed = true; + } + } + if (!changed) return; + + std::string merged = Json::Stringify(root); + CloudStorage::UploadCloudMetadataTextAsync( + accountId, CloudIntercept::kAccountScopeAppId, "stats.json", merged); }); // Track playtime/stats for namespace (lua) apps only -- real owned games // must never have their playtime recorded or synced. diff --git a/src/platform/linux/http_transport_linux.cpp b/src/platform/linux/http_transport_linux.cpp index 92d2eae4..7b0dd099 100644 --- a/src/platform/linux/http_transport_linux.cpp +++ b/src/platform/linux/http_transport_linux.cpp @@ -7,6 +7,7 @@ #include <cstring> #include <dlfcn.h> #include <memory> +#include <mutex> #include <string> #include <unistd.h> #include <vector> @@ -33,6 +34,7 @@ typedef int CURLoption; #define CURLOPT_HEADERDATA 10029 #define CURLINFO_RESPONSE_CODE 0x200002 +typedef int (*curl_global_init_fn)(long); typedef CURL* (*curl_easy_init_fn)(void); typedef CURLcode (*curl_easy_setopt_fn)(CURL*, CURLoption, ...); typedef CURLcode (*curl_easy_perform_fn)(CURL*); @@ -41,8 +43,11 @@ typedef void (*curl_easy_cleanup_fn)(CURL*); typedef struct curl_slist* (*curl_slist_append_fn)(struct curl_slist*, const char*); typedef void (*curl_slist_free_all_fn)(struct curl_slist*); +#define CURL_GLOBAL_ALL 3 // CURL_GLOBAL_SSL | CURL_GLOBAL_WIN32 + struct CurlAPI { void* handle = nullptr; + curl_global_init_fn global_init = nullptr; curl_easy_init_fn easy_init = nullptr; curl_easy_setopt_fn easy_setopt = nullptr; curl_easy_perform_fn easy_perform = nullptr; @@ -54,8 +59,20 @@ struct CurlAPI { static CurlAPI g_curl{}; static bool g_curlInitAttempted = false; +static std::mutex g_curlInitMutex; + +// Steam's bundled 32-bit libcurl has a non-thread-safe curl_easy_init/cleanup +// pair: two threads creating handles concurrently race the shared SSL tables and +// segfault in curl_easy_init. global_init under a mutex isn't enough -- the +// handle lifecycle must be serialized too. perform is safe per-handle, so we +// guard only init + cleanup. +static std::mutex g_curlHandleMutex; static bool InitCurl() { + // Serialize init and call curl_global_init() explicitly here -- libcurl's lazy + // global init off the first curl_easy_init isn't thread-safe and crashed when + // EndSession raced a background worker. + std::lock_guard<std::mutex> lock(g_curlInitMutex); if (g_curlInitAttempted) return g_curl.handle != nullptr; g_curlInitAttempted = true; @@ -87,6 +104,7 @@ static bool InitCurl() { return false; } + g_curl.global_init = (curl_global_init_fn)dlsym(g_curl.handle, "curl_global_init"); g_curl.easy_init = (curl_easy_init_fn)dlsym(g_curl.handle, "curl_easy_init"); g_curl.easy_setopt = (curl_easy_setopt_fn)dlsym(g_curl.handle, "curl_easy_setopt"); g_curl.easy_perform = (curl_easy_perform_fn)dlsym(g_curl.handle, "curl_easy_perform"); @@ -103,6 +121,15 @@ static bool InitCurl() { return false; } + // Explicit global init (once, under the mutex) -- required before any + // curl_easy_init and must not be left to libcurl's non-thread-safe lazy path. + if (g_curl.global_init) { + g_curl.global_init(CURL_GLOBAL_ALL); + LOG("[HTTP] curl_global_init done"); + } else { + LOG("[HTTP] WARNING: curl_global_init symbol missing; relying on lazy init"); + } + return true; } @@ -157,7 +184,11 @@ static HttpUtil::HttpResp CurlRequest(const char* logTag, const char* method, else safeUrl += c; } - CURL* curl = g_curl.easy_init(); + CURL* curl; + { + std::lock_guard<std::mutex> lock(g_curlHandleMutex); + curl = g_curl.easy_init(); + } if (!curl) return resp; std::string responseBody; @@ -198,7 +229,10 @@ static HttpUtil::HttpResp CurlRequest(const char* logTag, const char* method, g_curl.easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); if (slist && g_curl.slist_free_all) g_curl.slist_free_all(slist); - g_curl.easy_cleanup(curl); + { + std::lock_guard<std::mutex> lock(g_curlHandleMutex); + g_curl.easy_cleanup(curl); + } if (res != 0) { LOG("%s curl failed: %d (%s %s)", logTag, res, method, url.c_str()); diff --git a/src/platform/win/cloud_intercept.cpp b/src/platform/win/cloud_intercept.cpp index de2b3925..24070409 100644 --- a/src/platform/win/cloud_intercept.cpp +++ b/src/platform/win/cloud_intercept.cpp @@ -4360,25 +4360,64 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa g_startupMetadataScheduled.store(false); // Native stats / playtime store (cloud-backed). Must come after CloudStorage. - // Install cloud-backing callbacks so per-app stats blobs ride each app's - // Steam Cloud sync (same mechanism as CN/root-token metadata blobs). + // Stats sync as one account-wide blob at <accountId>/0/stats.json (appId -> + // stats JSON), not one blob per app (a Drive round-trip per app at startup). StatsStore::SetCloudProvider( - // pull: download the per-app stats blob as text - [](uint32_t appId, std::string& outJson) -> bool { + // pullAll: one download of the account blob, split into per-app entries. + [](std::unordered_map<uint32_t, std::string>& out) -> bool { uint32_t accountId = GetAccountId(); if (accountId == 0) return false; std::vector<uint8_t> data; if (!CloudStorage::DownloadCloudMetadataWithLegacyFallback( - accountId, appId, "stats.json", nullptr, data) || data.empty()) - return false; - outJson.assign(reinterpret_cast<const char*>(data.data()), data.size()); + accountId, CloudIntercept::kAccountScopeAppId, "stats.json", + nullptr, data) || data.empty()) + return true; // no account blob yet -> empty (not a failure) + Json::Value root = Json::Parse( + std::string(reinterpret_cast<const char*>(data.data()), data.size())); + if (root.type != Json::Type::Object) return true; + for (const auto& [appIdStr, appVal] : root.objVal) { + uint32_t appId = (uint32_t)strtoul(appIdStr.c_str(), nullptr, 10); + if (appId == 0) continue; + out[appId] = Json::Stringify(appVal); + } return true; }, - // push: queue the per-app stats blob (serialized on the cloud work queue) - [](uint32_t appId, const std::string& json) { + // pushAll: RMW-merge our snapshot onto the live blob (don't clobber + // another device) and upload once; skip if nothing changed. + [](const std::unordered_map<uint32_t, std::string>& all) { uint32_t accountId = GetAccountId(); if (accountId == 0) return; - CloudStorage::UploadCloudMetadataTextAsync(accountId, appId, "stats.json", json); + + // Read the current account blob as the merge base. + Json::Value root = Json::Object(); + std::vector<uint8_t> cur; + if (CloudStorage::DownloadCloudMetadataWithLegacyFallback( + accountId, CloudIntercept::kAccountScopeAppId, "stats.json", + nullptr, cur) && !cur.empty()) { + Json::Value parsed = Json::Parse( + std::string(reinterpret_cast<const char*>(cur.data()), cur.size())); + if (parsed.type == Json::Type::Object) root = std::move(parsed); + } + + // Overlay our app entries (already content-merged in the store). + bool changed = false; + for (const auto& [appId, json] : all) { + if (appId == 0) continue; + std::string key = std::to_string(appId); + Json::Value appVal = Json::Parse(json); + std::string existing = root.has(key) + ? Json::Stringify(root.objVal[key]) : std::string(); + std::string updated = Json::Stringify(appVal); + if (existing != updated) { + root.objVal[key] = std::move(appVal); + changed = true; + } + } + if (!changed) return; // nothing to write + + std::string merged = Json::Stringify(root); + CloudStorage::UploadCloudMetadataTextAsync( + accountId, CloudIntercept::kAccountScopeAppId, "stats.json", merged); }); // Restrict all playtime/stats tracking to namespace/lua apps only -- real // owned games must never have their playtime recorded or synced. From 8df63812a674e81f365616f0ce01127d08277d9a Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Fri, 12 Jun 2026 19:41:49 -0400 Subject: [PATCH 20/24] Fix sync hang, cross-device achievement sync, playtime migration, and assorted fixes --- Version.props | 2 +- flatpak/org.cloudredirect.CloudRedirect.yml | 2 +- src/common/app_state.cpp | 102 +++- src/common/app_state.h | 9 + src/common/cloud_storage.cpp | 34 +- src/common/cloud_storage.h | 4 + src/common/json.cpp | 23 + src/common/json.h | 5 + src/common/rpc_handlers.cpp | 435 +++++++++-------- src/common/stats_handlers.cpp | 19 +- src/common/stats_store.cpp | 498 +++++++++++++++++--- src/common/stats_store.h | 15 +- src/platform/linux/cloud_hooks.cpp | 34 +- src/platform/win/cloud_intercept.cpp | 34 +- src/providers/google_drive.cpp | 85 +++- ui/Pages/DashboardPage.xaml.cs | 5 +- ui/Resources/Strings.resx | 2 +- 17 files changed, 999 insertions(+), 309 deletions(-) diff --git a/Version.props b/Version.props index d91e9840..8955604b 100644 --- a/Version.props +++ b/Version.props @@ -5,7 +5,7 @@ <!-- Optional pre-release suffix (e.g. -TEST1, -beta). Empty for stable releases. Appended to the user-facing version shown in Settings; AssemblyVersion stays numeric. --> - <ReleasePrerelease>-TEST1</ReleasePrerelease> + <ReleasePrerelease>-TEST2</ReleasePrerelease> <!-- Sync engine generation - increment on breaking protocol changes --> <CoreGeneration>1.0</CoreGeneration> diff --git a/flatpak/org.cloudredirect.CloudRedirect.yml b/flatpak/org.cloudredirect.CloudRedirect.yml index 95ebeed4..1822163d 100644 --- a/flatpak/org.cloudredirect.CloudRedirect.yml +++ b/flatpak/org.cloudredirect.CloudRedirect.yml @@ -45,7 +45,7 @@ modules: no-debuginfo: true config-opts: - -DCMAKE_BUILD_TYPE=Release - - -DCR_RELEASE_VERSION=2.2.0-TEST1 + - -DCR_RELEASE_VERSION=2.2.0-TEST2 sources: - type: dir path: ../ui-linux diff --git a/src/common/app_state.cpp b/src/common/app_state.cpp index fbff177c..7dfd6f31 100644 --- a/src/common/app_state.cpp +++ b/src/common/app_state.cpp @@ -7,10 +7,15 @@ #include "log.h" #include "manifest_store.h" +#include <atomic> #include <chrono> #include <ctime> +#include <future> +#include <memory> #include <mutex> +#include <thread> #include <unordered_map> +#include <unordered_set> using CloudIntercept::IsReservedBlobFilename; @@ -34,8 +39,12 @@ struct ServeCacheEntry { StateFetchResult result; }; -std::mutex g_serveCacheMtx; -std::unordered_map<uint64_t, ServeCacheEntry> g_serveCache; // key = (acct<<32)|app +// Leaked, never-destructed: a detached bounded-fetch worker can still touch these +// during static destruction at exit, so heap-backing them (no destructor) avoids a +// UAF on a destroyed mutex/map. +std::mutex& g_serveCacheMtx = *new std::mutex(); +std::unordered_map<uint64_t, ServeCacheEntry>& g_serveCache = + *new std::unordered_map<uint64_t, ServeCacheEntry>(); // key = (acct<<32)|app // This client's id (set by NoteOwnClientId). Used to tell our own session from a // foreign one: only a foreign session is contention. Counting ours would disable @@ -67,14 +76,23 @@ static void InvalidateServeCache(uint32_t accountId, uint32_t appId) { g_serveCache.erase(ServeCacheKey(accountId, appId)); } -// Record a live fetch for the serve path to reuse. Latest good parse wins, even -// with a LOWER cn: that's a legitimate rewind (state wiped/recreated), not a -// partial read -- partial reads fail parse upstream and never reach here. +// Record a live fetch for the serve path. Latest good parse normally wins (even a +// lower cn -- a legitimate rewind), but concurrent bounded fetches can finish out of +// order, so reject a lower-cn write while the existing entry is still fresh; a real +// rewind re-asserts once the duplicates quiesce. +static constexpr int64_t kConcurrentFetchWindowMs = 10000; static void RefreshServeCache(uint32_t accountId, uint32_t appId, const StateFetchResult& result) { if (result.status != StateFetchStatus::Ok) return; // only cache good reads std::lock_guard<std::mutex> lk(g_serveCacheMtx); uint64_t key = ServeCacheKey(accountId, appId); + auto existing = g_serveCache.find(key); + if (existing != g_serveCache.end() && + existing->second.result.status == StateFetchStatus::Ok && + result.state.cn < existing->second.cn && + (NowMs() - existing->second.fetchedAtMs) < kConcurrentFetchWindowMs) { + return; // older out-of-order completion -- keep the fresher higher-CN entry + } ServeCacheEntry e; e.cn = result.state.cn; e.fetchedAtMs = NowMs(); @@ -374,6 +392,80 @@ StateFetchResult FetchCloudState(uint32_t accountId, uint32_t appId) { return result; } +// Bounded-fetch coordination. The changelist RPC runs sequentially per app on the +// main-loop thread, so N timeouts would sum past the 15s watchdog -- the circuit +// breaker serves local immediately after the first timeout. Per-app dedup + a worker +// cap bound the thread count. +// Leaked, never-destructed (same reason as g_serveCache*): the detached worker +// re-locks g_boundedMtx on completion, possibly during static destruction. +static std::mutex& g_boundedMtx = *new std::mutex(); +static std::unordered_set<uint64_t>& g_boundedInflightKeys = + *new std::unordered_set<uint64_t>(); // apps with a live worker +static std::atomic<int> g_boundedWorkerCount{0}; +static std::atomic<int64_t> g_providerSlowUntilMs{0}; // circuit-breaker deadline +static constexpr int kMaxBoundedWorkers = 4; +static constexpr int kCircuitCooldownMs = 30000; // serve-local window after a timeout + +StateFetchResult FetchCloudStateBounded(uint32_t accountId, uint32_t appId, + int deadlineMs) { + uint64_t key = ((uint64_t)accountId << 32) | appId; + bool spawn = true; + // Circuit open: provider recently timed out -> don't wait, serve local now. + if (NowMs() < g_providerSlowUntilMs.load(std::memory_order_relaxed)) { + return { StateFetchStatus::Timeout, {} }; + } + { + std::lock_guard<std::mutex> lk(g_boundedMtx); + // Coalesce duplicate fetches and cap total workers; either way, don't block. + if (g_boundedInflightKeys.count(key) || + g_boundedWorkerCount.load(std::memory_order_relaxed) >= kMaxBoundedWorkers) { + spawn = false; + } else { + g_boundedInflightKeys.insert(key); + g_boundedWorkerCount.fetch_add(1, std::memory_order_relaxed); + } + } + if (!spawn) return { StateFetchStatus::Timeout, {} }; + + // Run the blocking live fetch on a detached worker; wait up to deadlineMs. The + // shared state outlives this call so a late completion still warms the serve + // cache for the next changelist -- it just doesn't hold Steam's thread. + auto promise = std::make_shared<std::promise<StateFetchResult>>(); + auto future = promise->get_future(); + std::thread([accountId, appId, key, promise]() { + // RAII so the inflight slot is always released even if the fetch throws + // (e.g. bad_alloc): otherwise the key/count leak wedges the worker cap, and + // an exception escaping a std::thread entry calls std::terminate. + struct SlotGuard { + uint64_t key; + ~SlotGuard() { + std::lock_guard<std::mutex> lk(g_boundedMtx); + g_boundedInflightKeys.erase(key); + g_boundedWorkerCount.fetch_sub(1, std::memory_order_relaxed); + } + } slotGuard{key}; + try { + StateFetchResult r = FetchCloudStateLive(accountId, appId); + RefreshServeCache(accountId, appId, r); // warm cache regardless of timeout + promise->set_value(std::move(r)); // ignored if caller abandoned + } catch (...) { + try { promise->set_value({ StateFetchStatus::FetchFailed, {} }); } catch (...) {} + } + }).detach(); + + if (future.wait_for(std::chrono::milliseconds(deadlineMs)) == + std::future_status::ready) { + return future.get(); + } + // Timed out: open the circuit so the rest of this changelist burst serves local + // immediately instead of each waiting another full deadline. + g_providerSlowUntilMs.store(NowMs() + kCircuitCooldownMs, std::memory_order_relaxed); + LOG("[AppState] FetchCloudStateBounded app %u: provider exceeded %dms -- serving " + "local, circuit open %dms, background fetch continues", + appId, deadlineMs, kCircuitCooldownMs); + return { StateFetchStatus::Timeout, {} }; +} + // Serve-path accessor: reuse a recent snapshot when provably safe, else live. StateFetchResult FetchCloudStateForServe(uint32_t accountId, uint32_t appId) { { diff --git a/src/common/app_state.h b/src/common/app_state.h index b1dd3995..3724da6c 100644 --- a/src/common/app_state.h +++ b/src/common/app_state.h @@ -60,6 +60,8 @@ enum class StateFetchStatus { NotFound, // State file does not exist on provider (new app or pre-migration) FetchFailed, ParseFailed, + Timeout, // Bounded fetch exceeded its deadline (provider slow); caller should + // serve local/last-known state and let the background fetch finish. }; struct StateFetchResult { @@ -76,6 +78,13 @@ void AppState_Shutdown(); // accessor, to avoid clobbering a concurrent cross-machine update with a stale base. StateFetchResult FetchCloudState(uint32_t accountId, uint32_t appId); +// Time-bounded live fetch for the serve path (runs on Steam's main-loop thread, +// where a slow download stalls BMainLoop). Runs the fetch on a worker, waits up to +// deadlineMs, and on timeout returns Timeout -- the still-running fetch warms the +// serve cache for next time, matching native's non-blocking yielding job. +StateFetchResult FetchCloudStateBounded(uint32_t accountId, uint32_t appId, + int deadlineMs); + // Cache-aware accessor for the serve path only. Returns a recently-fetched state // without re-hitting the provider, only when provably safe: // - cached entry younger than the hard max-age, AND diff --git a/src/common/cloud_storage.cpp b/src/common/cloud_storage.cpp index 826b3c48..e07e615b 100644 --- a/src/common/cloud_storage.cpp +++ b/src/common/cloud_storage.cpp @@ -579,6 +579,15 @@ bool DownloadCloudMetadataWithLegacyFallback(uint32_t accountId, uint32_t appId, return true; } +bool DownloadLegacyPlaytimeBlob(uint32_t accountId, uint32_t appId, + std::vector<uint8_t>& outData) { + outData.clear(); + if (!g_provider || !g_provider->IsAuthenticated()) return false; + std::string path = CloudBlobPath(accountId, CloudIntercept::kAccountScopeAppId, + CloudIntercept::AccountPlaytimeFilename(appId)); + return g_provider->Download(path, outData) && !outData.empty(); +} + static void EnqueueCloudDelete(const std::string& cloudPath) { CloudWorkQueue::WorkItem wi; wi.type = CloudWorkQueue::WorkItem::Delete; @@ -1502,8 +1511,29 @@ std::vector<uint8_t> RetrieveBlob(uint32_t accountId, uint32_t appId, if (!shaHex.empty()) { auto cachedSha = ShaToHex(FileUtil::SHA1(cached.data(), cached.size())); if (cachedSha == shaHex) { - LOG("[CloudStorage] RetrieveBlob: cloud unavailable, cache SHA valid: %s (%zu bytes)", - filename.c_str(), cached.size()); + // The download failed but our cache matches the manifest SHA. + // Native, on a download 404 with a local copy present, flags the + // file to UPLOAD (heal). Mirror that only when the blob is + // genuinely absent (confirmed Missing) -- not on a transient + // provider error, where the blob still exists and re-uploading + // would be wasteful. CheckExists is one extra round-trip, but we + // are already on the degraded path (every Download above failed). + std::string casPath = + CloudBlobPathByNameAndSHA(accountId, appId, filename, shaHex); + if (g_provider->CheckExists(casPath) == + ICloudProvider::ExistsStatus::Missing) { + LOG("[CloudStorage] RetrieveBlob: cloud blob missing, healing from cache " + "(re-upload) and serving: %s (%zu bytes)", filename.c_str(), cached.size()); + CloudWorkQueue::WorkItem heal; + heal.type = CloudWorkQueue::WorkItem::Upload; + heal.cloudPath = std::move(casPath); + heal.data = cached; + heal.bestEffort = true; + CloudWorkQueue::EnqueueWork(std::move(heal)); + } else { + LOG("[CloudStorage] RetrieveBlob: cloud unavailable (transient), serving cache: " + "%s (%zu bytes)", filename.c_str(), cached.size()); + } if (found) *found = true; return cached; } diff --git a/src/common/cloud_storage.h b/src/common/cloud_storage.h index e3f8f4e1..29660b08 100644 --- a/src/common/cloud_storage.h +++ b/src/common/cloud_storage.h @@ -108,6 +108,10 @@ std::string CloudMetadataPath(uint32_t accountId, uint32_t appId, const std::str bool DownloadCloudMetadataWithLegacyFallback(uint32_t accountId, uint32_t appId, const char* canonicalName, const char* legacyName, std::vector<uint8_t>& outData, bool* outUsedLegacy = nullptr); +// Download the first-format per-app playtime blob (account-scope +// <acct>/0/blobs/Playtime/<appId>.bin). False if absent/unavailable. Migration-only. +bool DownloadLegacyPlaytimeBlob(uint32_t accountId, uint32_t appId, + std::vector<uint8_t>& outData); bool UploadCloudMetadataText(uint32_t accountId, uint32_t appId, const char* name, const std::string& content); // Queued (thread-safe) variant: serializes on the cloud work queue. diff --git a/src/common/json.cpp b/src/common/json.cpp index 6d01ae29..e75bc692 100644 --- a/src/common/json.cpp +++ b/src/common/json.cpp @@ -314,4 +314,27 @@ Value Object() { Value v; v.type = Type::Object; return v; } +bool DeepEqual(const Value& a, const Value& b) { + if (a.type != b.type) return false; + switch (a.type) { + case Type::Null: return true; + case Type::Bool: return a.boolVal == b.boolVal; + case Type::Number: return a.numVal == b.numVal; + case Type::String: return a.strVal == b.strVal; + case Type::Array: + if (a.arrVal.size() != b.arrVal.size()) return false; + for (size_t i = 0; i < a.arrVal.size(); ++i) + if (!DeepEqual(a.arrVal[i], b.arrVal[i])) return false; + return true; + case Type::Object: + if (a.objVal.size() != b.objVal.size()) return false; + for (const auto& [key, av] : a.objVal) { + auto it = b.objVal.find(key); + if (it == b.objVal.end() || !DeepEqual(av, it->second)) return false; + } + return true; + } + return false; +} + } // namespace Json diff --git a/src/common/json.h b/src/common/json.h index 9d213f21..caeab23f 100644 --- a/src/common/json.h +++ b/src/common/json.h @@ -32,6 +32,11 @@ struct Value { Value Parse(const std::string& json); std::string Stringify(const Value& val); +// Structural equality, independent of object key order. Stringify can't be used +// to compare values because objVal is an unordered_map and serializes in bucket +// order, so two equal objects may produce different strings. +bool DeepEqual(const Value& a, const Value& b); + // builders Value String(const std::string& s); Value Number(double n); diff --git a/src/common/rpc_handlers.cpp b/src/common/rpc_handlers.cpp index b4227033..10cae677 100644 --- a/src/common/rpc_handlers.cpp +++ b/src/common/rpc_handlers.cpp @@ -572,7 +572,12 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB if (CloudStorage::IsCloudActive()) { SetRpcCrashContext("GetChangelist:fetch-cloud", "Cloud.GetAppFileChangelist#1", appId); - auto stateResult = CloudStorage::FetchCloudState(accountId, appId); + // Bounded -- runs on Steam's main-loop thread, where a slow download used to + // stall BMainLoop past the 15s watchdog. On timeout we fall through to the + // local-manifest fallback and the background fetch warms the cache. + static constexpr int kChangelistFetchDeadlineMs = 5000; + auto stateResult = CloudStorage::FetchCloudStateBounded( + accountId, appId, kChangelistFetchDeadlineMs); if (stateResult.status == CloudStorage::StateFetchStatus::Ok) { auto& state = stateResult.state; cloudCN = state.cn; @@ -597,6 +602,9 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB } else if (stateResult.status == CloudStorage::StateFetchStatus::NotFound) { LOG("[NS-CL] GetAppFileChangelist app=%u: no cloud state (new app), using local", appId); + } else if (stateResult.status == CloudStorage::StateFetchStatus::Timeout) { + LOG("[NS-CL] GetAppFileChangelist app=%u: cloud fetch timed out, using local " + "(avoids BMainLoop stall; cache warms in background)", appId); } else { LOG("[NS-CL] GetAppFileChangelist app=%u: cloud state fetch failed (status=%d), using local", appId, static_cast<int>(stateResult.status)); @@ -1263,7 +1271,15 @@ RpcResult HandleBeginBatch(uint32_t appId, const std::vector<PB::Field>& reqBody uint64_t localCN = LocalStorage::GetChangeNumber(accountId, appId); uint64_t cloudCN = 0; if (CloudStorage::IsCloudActive()) { + // Runs on Steam's BeginAppUploadBatch thread; time it (serve-cached, usually + // fast, but a cache miss does a live fetch that could stall the main loop). + auto tbb = std::chrono::steady_clock::now(); auto cloud = CloudStorage::FetchCloudStateForServe(accountId, appId); + auto bbMs = std::chrono::duration_cast<std::chrono::milliseconds>( + std::chrono::steady_clock::now() - tbb).count(); + if (bbMs > 500) + LOG("[NS] BeginBatch app=%u: FetchCloudStateForServe took %lldms " + "(on Steam's thread)", appId, (long long)bbMs); if (cloud.status == CloudStorage::StateFetchStatus::Ok) cloudCN = cloud.state.cn; } @@ -1686,6 +1702,14 @@ static std::vector<std::string> ApplyNativeOverQuotaEviction( RpcResult HandleCompleteBatch(uint32_t appId, const std::vector<PB::Field>& reqBody) { auto completeInfo = CloudRpcUtils::ParseCompleteBatchRequest(reqBody); + // Times each step ([T+Nms] in the log) -- runs on Steam's BMainLoop thread, so a + // total near the 15s watchdog pinpoints what stalled. + auto t0 = std::chrono::steady_clock::now(); + auto elapsedMs = [t0]() -> long long { + return std::chrono::duration_cast<std::chrono::milliseconds>( + std::chrono::steady_clock::now() - t0).count(); + }; + // Increment CN once per batch; cloud publish detached. uint32_t accountId = 0; if (!RequireAccountId("CompleteAppUploadBatchBlocking", appId, accountId)) { @@ -1724,225 +1748,236 @@ RpcResult HandleCompleteBatch(uint32_t appId, const std::vector<PB::Field>& reqB } std::vector<std::string> uploads(batch.uploads.begin(), batch.uploads.end()); std::vector<std::string> deletes(batch.deletes.begin(), batch.deletes.end()); - if (!CloudStorage::PromoteStagedBatchForCommit(accountId, appId, - batch.batchId, uploads, deletes)) { - LOG("[NS] CompleteBatch app=%u refused CN advance: staged promotion failed", - appId); - PendingOpsJournal::RecordUploadBatchInterrupted(accountId, appId); - BatchTracker_Clear(accountId, appId, batch.batchId); - ClearFileTokensDirty(accountId, appId); - ClearBatchCanonicalTokens(accountId, appId); - return PB::Writer(); - } + // Native runs upload+complete as a yielding job; doing it synchronously here + // blocked BMainLoop past the 15s watchdog on large saves. Detach it instead. uint64_t newCN = batch.assignedCN; - { - // Sync mutex: serialize CN set + state publish. - auto syncMtx = CloudStorage::AcquireAppSyncMutex(accountId, appId); - std::lock_guard<std::mutex> syncLock(*syncMtx); - // Fetch existing cloud state; fall back to local manifest. - CloudStorage::CloudAppState state; - bool haveCloudBase = false; - if (CloudStorage::IsCloudActive()) { - auto result = CloudStorage::FetchCloudState(accountId, appId); - if (result.status == CloudStorage::StateFetchStatus::Ok) { - state = std::move(result.state); - haveCloudBase = true; + // Worker order: promote -> advance local CN/manifest -> publish. Advancing the + // manifest only after promote avoids advertising a not-yet-uploaded SHA. + { + uint64_t publishCN = newCN; + uint64_t publishBuildId = batch.appBuildId; + uint64_t workerBatchId = batch.batchId; + // Copies of the per-file metadata the publish stage rebuilds cloud state from. + auto uploadMeta = std::make_shared< + std::unordered_map<std::string, CloudIntercept::UploadFileMeta>>(batch.uploadMeta); + auto filePlatforms = std::make_shared< + std::unordered_map<std::string, uint32_t>>(batch.filePlatforms); + auto uploadsCopy = std::make_shared<std::vector<std::string>>(uploads); + auto deletesCopy = std::make_shared<std::vector<std::string>>(deletes); + + std::thread([accountId, appId, publishCN, publishBuildId, workerBatchId, + uploadMeta, filePlatforms, uploadsCopy, deletesCopy] { + // Inflight-sync scope first: drains before provider teardown (UAF guard). + CloudStorage::InflightSyncScope guard; + if (!guard.entered) return; + + // Promote: the blob upload (the slow part). + auto tPromote = std::chrono::steady_clock::now(); + bool promoteOk = CloudStorage::PromoteStagedBatchForCommit( + accountId, appId, workerBatchId, *uploadsCopy, *deletesCopy); + LOG("[NS] CompleteBatch(async) app=%u: promote done in %lldms (ok=%d)", + appId, + (long long)std::chrono::duration_cast<std::chrono::milliseconds>( + std::chrono::steady_clock::now() - tPromote).count(), + promoteOk ? 1 : 0); + if (!promoteOk) { + // Upload failed: leave the CN so the next BeginBatch reassigns it and + // Steam re-uploads. + LOG("[NS] CompleteBatch(async) app=%u: staged promotion failed; " + "leaving CN unchanged (Steam re-uploads next sync)", appId); + PendingOpsJournal::RecordUploadBatchInterrupted(accountId, appId); + return; } - } - // Publish at exactly the CN we gave Steam at BeginBatch. If the cloud - // advanced past it since (another device uploaded in the window), refuse the - // commit rather than bump to a CN Steam doesn't know, and let Steam re-sync. - uint64_t localCN = LocalStorage::GetChangeNumber(accountId, appId); - if (haveCloudBase && state.cn >= newCN) { - LOG("[NS] CompleteBatch app %u: cloud CN %llu advanced to/past assignedCN %llu since " - "BeginBatch (concurrent update); refusing commit so Steam re-syncs", - appId, (unsigned long long)state.cn, (unsigned long long)newCN); - PendingOpsJournal::RecordUploadBatchInterrupted(accountId, appId); - BatchTracker_Clear(accountId, appId, batch.batchId); - ClearFileTokensDirty(accountId, appId); - ClearBatchCanonicalTokens(accountId, appId); - return PB::Writer(); - } - // If cloud CN is behind local, rebuild file list from manifest (keep session/quota). - if (!haveCloudBase || state.cn < localCN) { - if (haveCloudBase && state.cn < localCN) { - LOG("[NS] CompleteBatch app %u: cloud CN %llu < local CN %llu, rebuilding file list from local manifest", - appId, (unsigned long long)state.cn, (unsigned long long)localCN); - } - state.files.clear(); - auto localManifest = CloudStorage::LoadLocalManifest(accountId, appId); - for (const auto& [name, me] : localManifest) { - CloudStorage::FileEntry fe; - fe.sha = me.sha; - fe.timestamp = me.timestamp; - fe.size = me.size; - state.files[name] = std::move(fe); + // Advance the local CN/manifest only now that the blobs are durable, so + // the fallback-serve manifest never references a not-yet-uploaded SHA. + { + auto syncMtx = CloudStorage::AcquireAppSyncMutex(accountId, appId); + std::lock_guard<std::mutex> lock(*syncMtx); + auto localManifest = CloudStorage::LoadLocalManifest(accountId, appId); + for (const auto& filename : *deletesCopy) + localManifest.erase(filename); + for (const auto& filename : *uploadsCopy) { + if (IsReservedBlobFilename(filename)) continue; + CloudStorage::ManifestEntry me; + auto metaIt = uploadMeta->find(filename); + if (metaIt != uploadMeta->end()) { + me.sha = metaIt->second.sha; + me.timestamp = metaIt->second.timestamp; + me.size = metaIt->second.size; + } else { + auto entry = LocalStorage::GetFileEntry(accountId, appId, filename); + if (!entry.has_value()) continue; + me.sha = entry->sha; + me.timestamp = entry->timestamp; + me.size = entry->rawSize; + } + localManifest[filename] = std::move(me); + } + LocalStorage::SetChangeNumber(accountId, appId, publishCN); + CloudStorage::SaveManifestLocal(accountId, appId, localManifest); + CloudStorage::SaveManifestSnapshot(accountId, appId, publishCN); + LOG("[NS] CompleteBatch(async) app=%u: local CN advanced to %llu + manifest saved " + "(blobs durable)", appId, (unsigned long long)publishCN); } - } - for (const auto& filename : deletes) - state.files.erase(filename); - - for (const auto& filename : uploads) { - if (IsReservedBlobFilename(filename)) continue; - CloudStorage::FileEntry fe; - // Prefer the SHA/size captured at CommitFileUpload; re-stat'ing disk here - // can read a racing file and publish a SHA that doesn't match the blob. - auto metaIt = batch.uploadMeta.find(filename); - if (metaIt != batch.uploadMeta.end()) { - fe.sha = metaIt->second.sha; - fe.timestamp = metaIt->second.timestamp; - fe.size = metaIt->second.size; - } else { - // No recorded upload meta (shouldn't happen for a real upload); - // fall back to disk so we don't silently drop the entry. - auto entry = LocalStorage::GetFileEntry(accountId, appId, filename); - if (!entry.has_value()) continue; - fe.sha = entry->sha; - fe.timestamp = entry->timestamp; - fe.size = entry->rawSize; - } - auto ptIt = batch.filePlatforms.find(filename); - fe.platformsToSync = (ptIt != batch.filePlatforms.end()) - ? ptIt->second : 0xFFFFFFFFu; - state.files[filename] = std::move(fe); - } + // Publish: re-fetch live state, CN-monotonic guard, then write. + constexpr int kMaxAttempts = 4; // 1 immediate + 3 backoff retries + constexpr int kBaseDelayMs = 2000; + for (int attempt = 1; attempt <= kMaxAttempts; ++attempt) { + if (attempt > 1) + std::this_thread::sleep_for( + std::chrono::milliseconds(kBaseDelayMs * (attempt - 1))); + // Re-fetch live state under sync mutex to preserve session changes. + auto syncMtx = CloudStorage::AcquireAppSyncMutex(accountId, appId); + std::lock_guard<std::mutex> lock(*syncMtx); + auto result = CloudStorage::FetchCloudState(accountId, appId); + if (result.status != CloudStorage::StateFetchStatus::Ok) { + // Cloud fetch failed; skip to avoid erasing session lock. + LOG("[NS] CompleteBatch(async): publish %d/%d skipped for app %u: cloud fetch failed", + attempt, kMaxAttempts, appId); + continue; + } + CloudStorage::CloudAppState publishState = std::move(result.state); + // Refuse if another device already committed at >= our CN. This is + // always a fresh commit (never a same-CN republish), so equality aborts. + if (publishState.cn >= publishCN) { + LOG("[NS] CompleteBatch(async): publish aborted for app %u: cloud CN %llu >= batch CN %llu", + appId, publishState.cn, publishCN); + return; + } - // Capture the DEVELOPER's PICS quota into cloud state so it propagates to - // machines that can't read PICS (Linux KvInjector cache-null). Source of - // truth, in order: existing cloud-state value (sticky once known) -> a - // CLEAN live PICS read on this box (ignoring CR's own injected fallback). - // The quota floor is gone, so live reads are no longer self-inflated; a - // value already in cloud state is never overwritten by a (possibly stale/ - // polluted) live read. - { - uint64_t q = 0; uint32_t f = 0; - if (state.quota.maxNumFiles == 0 && - SteamKvInjector::ReadAppQuota(appId, q, f) && f > 0 && q > 0 && - f != kFallbackMaxFiles) { - state.quota.quotaBytes = q; - state.quota.maxNumFiles = f; - state.quota.fetchedAtUnix = static_cast<uint64_t>(time(nullptr)); - state.quota.lastSeenBuildId = state.appBuildId; - LOG("[NS] CompleteBatch app=%u: captured PICS quota=%llu files=%u into cloud state", - appId, (unsigned long long)q, f); - } - } + // Rebuild the file list from the cloud base (or local manifest if + // behind) and apply this batch's deletes/uploads from captured meta. + uint64_t localCN = LocalStorage::GetChangeNumber(accountId, appId); + if (publishState.cn < localCN) { + LOG("[NS] CompleteBatch(async) app %u: cloud CN %llu < local CN %llu, " + "rebuilding file list from local manifest", + appId, (unsigned long long)publishState.cn, + (unsigned long long)localCN); + publishState.files.clear(); + auto localManifest = CloudStorage::LoadLocalManifest(accountId, appId); + for (const auto& [name, me] : localManifest) { + CloudStorage::FileEntry fe; + fe.sha = me.sha; + fe.timestamp = me.timestamp; + fe.size = me.size; + publishState.files[name] = std::move(fe); + } + } - // Mirror native over-quota eviction so the published manifest matches what - // native keeps (no phantom entries -> no 404/conflict). Read the LIVE KV cap - // back rather than assuming the floor applied: if it took we keep all files, - // if it silently failed (Linux cache-null, injector down) we evict exactly - // what native will. Fall back to cloud-state PICS only if the live read fails - // entirely. - uint64_t evictBytes = state.quota.quotaBytes; - uint32_t evictFiles = state.quota.maxNumFiles; - { - uint64_t liveBytes = 0; uint32_t liveFiles = 0; - if (SteamKvInjector::ReadAppQuota(appId, liveBytes, liveFiles) && - liveFiles > 0) { - evictBytes = liveBytes; - evictFiles = liveFiles; - LOG("[NS] CompleteBatch app=%u: eviction uses live KV cap " - "maxnumfiles=%u quota=%llu (what native's exit-walk sees)", - appId, evictFiles, (unsigned long long)evictBytes); - } else { - LOG("[NS] CompleteBatch app=%u: live KV cap unreadable; eviction " - "falls back to cloud-state PICS maxnumfiles=%u", - appId, evictFiles); - } - } - auto evicted = ApplyNativeOverQuotaEviction(accountId, appId, state.files, - evictBytes, evictFiles); - if (!evicted.empty()) { - LOG("[NS] CompleteBatch app=%u: evicted %zu over-quota file(s) from cloud set", - appId, evicted.size()); - // Remove their local manifest entries too (their blobs become GC-eligible). - for (const auto& name : evicted) - CloudStorage::DeleteBlobStaged(accountId, appId, name); - } + for (const auto& filename : *deletesCopy) + publishState.files.erase(filename); + + for (const auto& filename : *uploadsCopy) { + if (IsReservedBlobFilename(filename)) continue; + CloudStorage::FileEntry fe; + auto metaIt = uploadMeta->find(filename); + if (metaIt != uploadMeta->end()) { + fe.sha = metaIt->second.sha; + fe.timestamp = metaIt->second.timestamp; + fe.size = metaIt->second.size; + } else { + auto entry = LocalStorage::GetFileEntry(accountId, appId, filename); + if (!entry.has_value()) continue; + fe.sha = entry->sha; + fe.timestamp = entry->timestamp; + fe.size = entry->rawSize; + } + auto ptIt = filePlatforms->find(filename); + fe.platformsToSync = (ptIt != filePlatforms->end()) + ? ptIt->second : 0xFFFFFFFFu; + publishState.files[filename] = std::move(fe); + } - state.cn = newCN; - state.appBuildId = batch.appBuildId; - - LocalStorage::SetChangeNumber(accountId, appId, newCN); - CloudStorage::Manifest updatedManifest; - for (const auto& [name, fe] : state.files) { - CloudStorage::ManifestEntry me; - me.sha = fe.sha; - me.timestamp = fe.timestamp; - me.size = fe.size; - updatedManifest[name] = std::move(me); - } - CloudStorage::SaveManifestLocal(accountId, appId, updatedManifest); - CloudStorage::SaveManifestSnapshot(accountId, appId, newCN); - - // Synchronous first attempt (must finish before ExitSyncDone). - // Steam ignores CompleteBatch eresult, so retry async on failure. - if (!CloudStorage::PublishCloudState(accountId, appId, state)) { - LOG("[NS] CompleteBatch: state publish failed for app %u; scheduling async retry", appId); - // Capture file data only; retry re-fetches live state. - auto filesToMerge = std::make_shared<std::unordered_map<std::string, CloudStorage::FileEntry>>(state.files); - uint64_t retryCN = state.cn; - uint64_t retryBuildId = state.appBuildId; - std::thread([filesToMerge, retryCN, retryBuildId, accountId, appId] { - CloudStorage::InflightSyncScope guard; - if (!guard.entered) return; - constexpr int kMaxRetries = 3; - constexpr int kBaseDelayMs = 2000; - for (int attempt = 1; attempt <= kMaxRetries; ++attempt) { - std::this_thread::sleep_for( - std::chrono::milliseconds(kBaseDelayMs * attempt)); - // Re-fetch live state under sync mutex to preserve session changes. - auto syncMtx = CloudStorage::AcquireAppSyncMutex(accountId, appId); - std::lock_guard<std::mutex> lock(*syncMtx); - auto result = CloudStorage::FetchCloudState(accountId, appId); - if (result.status != CloudStorage::StateFetchStatus::Ok) { - // Cloud fetch failed; skip to avoid erasing session lock. - LOG("[NS] CompleteBatch: retry %d/%d skipped for app %u: cloud fetch failed", - attempt, kMaxRetries, appId); - continue; + // Capture the DEVELOPER's PICS quota into cloud state so it propagates + // to machines that can't read PICS (Linux KvInjector cache-null). + { + uint64_t q = 0; uint32_t f = 0; + if (publishState.quota.maxNumFiles == 0 && + SteamKvInjector::ReadAppQuota(appId, q, f) && f > 0 && q > 0 && + f != kFallbackMaxFiles) { + publishState.quota.quotaBytes = q; + publishState.quota.maxNumFiles = f; + publishState.quota.fetchedAtUnix = static_cast<uint64_t>(time(nullptr)); + publishState.quota.lastSeenBuildId = publishState.appBuildId; + LOG("[NS] CompleteBatch(async) app=%u: captured PICS quota=%llu files=%u into cloud state", + appId, (unsigned long long)q, f); } - CloudStorage::CloudAppState retryState = std::move(result.state); - // Abort if a newer CN already committed. - if (retryState.cn > retryCN) { - LOG("[NS] CompleteBatch: retry aborted for app %u: cloud CN %llu > batch CN %llu", - appId, retryState.cn, retryCN); - return; + } + + // Mirror native over-quota eviction before publishing (live KV cap, + // fall back to cloud-state PICS). Must run before PublishCloudState. + uint64_t evictBytes = publishState.quota.quotaBytes; + uint32_t evictFiles = publishState.quota.maxNumFiles; + { + uint64_t liveBytes = 0; uint32_t liveFiles = 0; + if (SteamKvInjector::ReadAppQuota(appId, liveBytes, liveFiles) && + liveFiles > 0) { + evictBytes = liveBytes; + evictFiles = liveFiles; + LOG("[NS] CompleteBatch(async) app=%u: eviction uses live KV cap " + "maxnumfiles=%u quota=%llu (what native's exit-walk sees)", + appId, evictFiles, (unsigned long long)evictBytes); + } else { + LOG("[NS] CompleteBatch(async) app=%u: live KV cap unreadable; eviction " + "falls back to cloud-state PICS maxnumfiles=%u", + appId, evictFiles); } - retryState.files = *filesToMerge; - retryState.cn = retryCN; - retryState.appBuildId = retryBuildId; - if (CloudStorage::PublishCloudState(accountId, appId, retryState)) { - LOG("[NS] CompleteBatch: async retry %d/%d succeeded for app %u", - attempt, kMaxRetries, appId); - return; + } + // Drop over-quota entries, but defer the local-blob deletes until the + // publish is durable (else a failed publish strands the manifest). + auto evicted = ApplyNativeOverQuotaEviction(accountId, appId, + publishState.files, + evictBytes, evictFiles); + if (!evicted.empty()) + LOG("[NS] CompleteBatch(async) app=%u: evicting %zu over-quota file(s) " + "from cloud set (local blobs dropped only after publish)", + appId, evicted.size()); + + publishState.cn = publishCN; + publishState.appBuildId = publishBuildId; + if (CloudStorage::PublishCloudState(accountId, appId, publishState)) { + LOG("[NS] CompleteBatch(async): publish %d/%d succeeded for app %u", + attempt, kMaxAttempts, appId); + // Now safe: drop the evicted local blobs and prune them from the + // manifest so the fallback-serve manifest stays consistent. + if (!evicted.empty()) { + auto m = CloudStorage::LoadLocalManifest(accountId, appId); + for (const auto& name : evicted) { + m.erase(name); + CloudStorage::DeleteBlobStaged(accountId, appId, name); + } + CloudStorage::SaveManifestLocal(accountId, appId, m); } - LOG("[NS] CompleteBatch: async retry %d/%d failed for app %u", - attempt, kMaxRetries, appId); + PendingOpsJournal::RecordUploadBatchEnd(accountId, appId); + // GC only after a durable publish, when the keep-set (cloud state) + // references the new SHAs -- earlier it could reclaim a live blob. + CloudStorage::GarbageCollectBlobs(accountId, appId); + return; } - LOG("[NS] CompleteBatch: all retries exhausted for app %u; " - "remote state stale until next sync", appId); - }).detach(); - } + LOG("[NS] CompleteBatch(async): publish %d/%d failed for app %u", + attempt, kMaxAttempts, appId); + } + // Local CN/manifest committed and blobs durable -- record End (the next + // sync republishes from the local CN). + PendingOpsJournal::RecordUploadBatchEnd(accountId, appId); + LOG("[NS] CompleteBatch(async): all publish attempts exhausted for app %u; " + "remote state stale until next sync", appId); + }).detach(); } + // The worker copied everything by value, so clearing the batch is safe now. BatchTracker_Clear(accountId, appId, batch.batchId); - PendingOpsJournal::RecordUploadBatchEnd(accountId, appId); - LOG("[NS] CompleteBatch app=%u CN=%llu (state published atomically)", appId, newCN); + // Only validation + token drain ran on Steam's thread; the upload/publish are + // off-thread, so this total should be a few ms. + LOG("[NS] CompleteBatch app=%u CN=%llu DONE: returned to Steam in %lldms total " + "(blob upload + publish + GC are async)", appId, newCN, elapsedMs()); ClearBatchCanonicalTokens(accountId, appId); - // Fire-and-forget GC after successful commit. - std::thread([accountId, appId]() { - CloudStorage::InflightSyncScope guard; - if (!guard.entered) return; - CloudStorage::GarbageCollectBlobs(accountId, appId); - }).detach(); - PB::Writer body; // empty response return body; } diff --git a/src/common/stats_handlers.cpp b/src/common/stats_handlers.cpp index 2eab5744..dce035fa 100644 --- a/src/common/stats_handlers.cpp +++ b/src/common/stats_handlers.cpp @@ -205,6 +205,18 @@ std::optional<std::vector<uint8_t>> HandleLegacyGetUserStats( StatsStore::AppStats stats = StatsStore::Snapshot(appId); + // Count unlocked bits for diagnostics: schema present but zero unlocks is the + // "served 0 achievements despite cloud data" signature. + size_t unlockedBits = 0; + for (const auto& a : stats.achievements) + for (int i = 0; i < 32; i++) + if (a.unlockTimes[i]) unlockedBits++; + LOG("[Stats] Serve snapshot app=%u: schema=%zuB ach_blocks=%zu unlocked_bits=%zu " + "stats=%zu crc=%u(client=%u)%s", appId, stats.schema.size(), + stats.achievements.size(), unlockedBits, stats.stats.size(), + stats.crcStats, clientCrc, + stats.achievements.empty() ? " [WARN: no achievement data in store]" : ""); + // If client has no schema (version=-1) and we don't have one either, // pass through to let the real server provide the schema. if (schemaVersion == -1 && stats.schema.empty()) { @@ -218,8 +230,10 @@ std::optional<std::vector<uint8_t>> HandleLegacyGetUserStats( resp.WriteVarint(3, stats.crcStats); // crc_stats // schema (field 4) - send if client CRC differs + bool sentSchema = false; if (clientCrc != stats.crcStats && !stats.schema.empty()) { resp.WriteBytes(4, stats.schema.data(), stats.schema.size()); + sentSchema = true; } // stats (field 5, repeated submessage): stat_id(1), stat_value(2) @@ -240,7 +254,10 @@ std::optional<std::vector<uint8_t>> HandleLegacyGetUserStats( resp.WriteSubmessage(6, achMsg); } - return resp.Data(); + auto out = resp.Data(); + LOG("[Stats] 819 built app=%u: %zuB (schema_sent=%d ach_blocks=%zu stats=%zu)", + appId, out.size(), sentSchema ? 1 : 0, stats.achievements.size(), stats.stats.size()); + return out; } // Legacy EMsg 820: CMsgClientStoreUserStats2 diff --git a/src/common/stats_store.cpp b/src/common/stats_store.cpp index 982f6d56..611bbaa1 100644 --- a/src/common/stats_store.cpp +++ b/src/common/stats_store.cpp @@ -12,17 +12,25 @@ #include <algorithm> #include <cstring> #include <thread> +#include <unordered_set> namespace fs = std::filesystem; namespace StatsStore { static std::string g_storageRoot; +static std::string g_cloudRoot; // CR root; legacy Playtime/*.bin live under <root>/storage/<acct>/0/Playtime static std::string g_steamPath; // e.g. "C:\Games\Steam\" (used to locate native blobs) static std::mutex g_mutex; // Forward decl: deterministic CRC over an AppStats (caller holds g_mutex). uint32_t ComputeCrcLocked(const AppStats& stats); +// Forward decl: parse an app's stats JSON (used by cloud-blob diagnostics above +// its definition). +static bool ParseAppStatsJson(const std::string& content, AppStats& out); +// Forward decl: count unlocked achievement bits (used by import diagnostics above +// its definition). +static size_t CountUnlockedAchievements(const std::vector<AchievementBlock>& a); static std::unordered_map<uint32_t, AppStats> g_cache; static std::unordered_map<uint32_t, bool> g_dirty; @@ -30,10 +38,19 @@ static std::unordered_map<uint32_t, bool> g_dirty; // Account-wide blob: one network read for every app, not one per app. static CloudPullAllFn g_cloudPullAll; static CloudPushAllFn g_cloudPushAll; +// Reads a single legacy per-app stats blob, for one-time migration into the +// consolidated account blob. May be null on platforms that never wrote per-app. +static CloudPullLegacyFn g_cloudPullLegacy; +// Reads a single first-format playtime blob (Playtime/<appId>.bin) from the cloud. +static CloudPullLegacyPlaytimeFn g_cloudPullLegacyPlaytime; // Last account blob pulled from cloud (appId -> stats JSON). Populated by one // network read; per-app load/merge reads from here. Guarded by g_mutex. static std::unordered_map<uint32_t, std::string> g_cloudBlobByApp; +// Apps whose cached entry has already absorbed the account-blob snapshot. Cleared +// when the blob is (re)fetched so the late-merge re-runs against fresh cloud data. +// Guarded by g_mutex. +static std::unordered_set<uint32_t> g_cloudBlobMerged; // Set when an app's entry in g_cloudBlobByApp changed and the account blob needs // to be re-uploaded; cleared by PushAccountBlobIfDirty. Guarded by g_mutex. static bool g_accountBlobDirty = false; @@ -46,15 +63,20 @@ static SchemaMissingCallback g_schemaMissingCb; static NamespacePredicate g_isNamespaceApp; // Persist to disk; pushCloud=false writes locally only (used by startup reconcile). -static void WriteAppStats(uint32_t appId, const AppStats& stats, bool pushCloud); +static void WriteAppStats(uint32_t appId, const AppStats& stats, bool pushCloud, + bool bypassDiskMerge = false); // Playtime helpers (defined below; forward-declared for use in (de)serialization). static void RecomputePlaytimeTotals(PlaytimeData& pt); -void SetCloudProvider(CloudPullAllFn pullAll, CloudPushAllFn pushAll) { +void SetCloudProvider(CloudPullAllFn pullAll, CloudPushAllFn pushAll, + CloudPullLegacyFn pullLegacy, + CloudPullLegacyPlaytimeFn pullLegacyPlaytime) { std::lock_guard<std::mutex> lock(g_mutex); g_cloudPullAll = std::move(pullAll); g_cloudPushAll = std::move(pushAll); + g_cloudPullLegacy = std::move(pullLegacy); + g_cloudPullLegacyPlaytime = std::move(pullLegacyPlaytime); } // Pull the account-wide blob once into g_cloudBlobByApp. Network I/O; caller must @@ -72,6 +94,26 @@ static bool RefreshCloudBlobCache() { std::lock_guard<std::mutex> lock(g_mutex); g_cloudBlobByApp = std::move(fetched); + // Fresh blob -> allow the per-app late-merge (GetOrCreateLocked) to re-run so + // entries cached before this fetch absorb the newly-arrived cloud achievements. + g_cloudBlobMerged.clear(); + // Per-app summary so a log shows exactly what the cloud delivered (the missing + // diagnostic when chasing "cloud has achievements but client gets none"). + LOG("[Stats] Cloud blob refreshed: %zu app(s)", g_cloudBlobByApp.size()); + for (const auto& [appId, json] : g_cloudBlobByApp) { + AppStats cs; + if (!ParseAppStatsJson(json, cs)) { + LOG("[Stats] cloud[%u]: PARSE FAILED (%zu bytes)", appId, json.size()); + continue; + } + size_t unlocked = 0; + for (const auto& a : cs.achievements) + for (int i = 0; i < 32; i++) if (a.unlockTimes[i]) unlocked++; + if (!cs.achievements.empty() || !cs.stats.empty() || cs.playtime.minutesForever) + LOG("[Stats] cloud[%u]: ach_blocks=%zu unlocked=%zu stats=%zu schema=%zuB forever=%umin", + appId, cs.achievements.size(), unlocked, cs.stats.size(), + cs.schema.size(), cs.playtime.minutesForever); + } return true; } @@ -367,10 +409,10 @@ static void ReconcileLocalConfig(const std::string& cloudRoot, const std::string if (!isNs) return true; LOG("[Stats] Reconcile: considering app %u (ns=1)", appId); - // CR normally writes localconfig Playtime itself, so reading it back is - // circular -- except on first run from a pre-playtime CR version, where - // it's the only record of past minutes. Seed it once so we don't serve - // zeros and wipe the displayed playtime. + // Best-effort seed from Steam's localconfig Playtime. Steam only + // records this for apps IT tracked server-side, so namespace/unowned + // apps usually have no entry here -- their playtime lives only in CR's + // own stats store and must be recovered from the cloud blob, not here. std::string appIdStr = std::to_string(appId); const char* appPath[] = {"UserLocalConfigStore", "Software", "Valve", "Steam", appsKey, appIdStr.c_str()}; uint32_t vdfLastPlayed = 0; @@ -440,6 +482,7 @@ static void ReconcileLocalConfig(const std::string& cloudRoot, const std::string void Init(const std::string& storageRoot, const std::string& steamPath) { std::lock_guard<std::mutex> lock(g_mutex); g_storageRoot = storageRoot + "/stats"; + g_cloudRoot = storageRoot; g_steamPath = steamPath; fs::create_directories(g_storageRoot); fs::create_directories(g_storageRoot + "/schemas"); @@ -477,11 +520,23 @@ static bool ImportNativeStats(uint32_t appId, AppStats& out) { fs::path statsPath = FileUtil::Utf8ToPath(g_steamPath) / "appcache" / "stats" / ("UserGameStats_" + std::to_string(accountId) + "_" + std::to_string(appId) + ".bin"); std::ifstream f(statsPath, std::ios::binary); - if (!f.good()) return false; + // No native stats blob (e.g. a device that never played this app) but we DID + // load a schema above. Report success so the caller adopts the schema: it's + // required to serve achievements that arrived from another device via the + // cloud blob. Without it the serve path withholds the unlocks (Steam shows 0). + if (!f.good()) { + LOG("[Stats] ImportNativeStats app=%u: no native blob (UserGameStats_%u_%u.bin " + "absent) -- schema=%zuB so %s", appId, accountId, appId, out.schema.size(), + out.schema.empty() ? "FAIL (no schema either)" : "adopt schema only"); + return !out.schema.empty(); + } std::vector<uint8_t> blob((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>()); f.close(); - if (blob.empty()) return false; + if (blob.empty()) { + LOG("[Stats] ImportNativeStats app=%u: native blob is empty (0 bytes)", appId); + return false; + } size_t pos = 0, nodeCount = 0; std::vector<BkvNode> root; @@ -491,7 +546,11 @@ static bool ImportNativeStats(uint32_t appId, AppStats& out) { } const BkvNode* cache = BkvFind(root, "cache"); - if (!cache) return false; + if (!cache) { + LOG("[Stats] ImportNativeStats app=%u: no 'cache' node in native blob (%zu bytes)", + appId, blob.size()); + return false; + } // Parse the schema (if present) for human-readable achievement names. std::unordered_map<uint64_t, std::string> achNames; @@ -544,8 +603,9 @@ static bool ImportNativeStats(uint32_t appId, AppStats& out) { } } - LOG("[Stats] ImportNativeStats app=%u: imported %zu stat(s), %zu achievement block(s), schema=%zu bytes", - appId, importedStats, importedAch, out.schema.size()); + LOG("[Stats] ImportNativeStats app=%u: imported %zu stat(s), %zu achievement block(s) " + "(%zu unlocked), schema=%zu bytes", appId, importedStats, importedAch, + CountUnlockedAchievements(out.achievements), out.schema.size()); return importedStats > 0; } @@ -823,8 +883,20 @@ static bool MergeAchievements(std::vector<AchievementBlock>& dst, return changed; } -// Merge the latest stat values from src into dst (this device's native stats are -// authoritative for their own values). Returns true if dst changed. +// Count individual unlocked achievement bits across all blocks. For diagnostics: +// "9 vs 8" cloud/native mismatches are about unlocked bits, not block count. +static size_t CountUnlockedAchievements(const std::vector<AchievementBlock>& a) { + size_t n = 0; + for (const auto& blk : a) + for (int bit = 0; bit < 32; ++bit) + if (blk.unlockTimes[bit] != 0) ++n; + return n; +} + +// Merge stat values from src into dst, last-writer-wins per statId: src (native, +// authoritative for its own values) overwrites dst on any difference -- not max(), +// since stats are non-monotonic and a reset must win. dst-only statIds are kept; +// src-only are appended. Returns true if dst changed. static bool MergeStatValues(std::vector<StatEntry>& dst, const std::vector<StatEntry>& src) { bool changed = false; @@ -867,6 +939,39 @@ static bool LoadAppStatsLocalOnly(uint32_t appId, AppStats& out) { return haveLocal; } +// Fold the cached account blob for one app into `out` (union achievements/stats, +// max-merge playtime, adopt schema if absent). Returns true if a non-empty cloud +// entry was merged. Caller holds g_mutex. `haveLocal` says whether `out` already +// holds real data (so a missing-local app can adopt cloud wholesale). +static bool MergeCloudBlobLocked(uint32_t appId, AppStats& out, bool haveLocal) { + std::string cloud = CloudJsonForAppLocked(appId); + if (cloud.empty()) return false; + AppStats cloudStats; + if (!ParseAppStatsJson(cloud, cloudStats)) return false; + size_t localHad = CountUnlockedAchievements(out.achievements); + size_t cloudHad = CountUnlockedAchievements(cloudStats.achievements); + if (!haveLocal) { + out = std::move(cloudStats); + } else { + MergePlaytime(out.playtime, cloudStats.playtime); + // Union-merge achievements (unlocks are monotonic -- never let a local copy + // hide another device's unlock) and stat values, so cloud progress is + // preserved instead of clobbered on next push. + MergeAchievements(out.achievements, cloudStats.achievements); + MergeStatValues(out.stats, cloudStats.stats); + // Schema is descriptive; adopt cloud's only when we hold none. + if (out.schema.empty() && !cloudStats.schema.empty()) + out.schema = std::move(cloudStats.schema); + } + g_cloudBlobMerged.insert(appId); + // Catches the DOOM-class case (cloud holds more unlocks than local) -- the merged + // store must be the union (>= both). + LOG("[Stats] CloudBlob merge app=%u: local=%zu cloud=%zu -> %zu unlocked%s", + appId, localHad, cloudHad, CountUnlockedAchievements(out.achievements), + cloudHad > localHad ? " (cloud had more)" : ""); + return true; +} + bool LoadAppStats(uint32_t appId, AppStats& out) { std::string path = StatsPath(appId); bool haveLocal = false; @@ -880,34 +985,15 @@ bool LoadAppStats(uint32_t appId, AppStats& out) { haveLocal = true; } - // Consult the cached account blob (pulled once by SeedApps/RefreshFromCloud) - // and merge per-platform: a local copy from a prior session must not hide - // another device's playtime/unlocks in the cloud. No per-app network read -- - // the whole account blob was fetched in one shot. - std::string cloud = CloudJsonForAppLocked(appId); - if (!cloud.empty()) { - AppStats cloudStats; - if (ParseAppStatsJson(cloud, cloudStats)) { - if (!haveLocal) { - out = std::move(cloudStats); - haveLocal = true; - } else { - MergePlaytime(out.playtime, cloudStats.playtime); - // Union-merge achievements (unlocks are monotonic -- never let a - // local copy hide another device's unlock) and stat values, so - // cloud progress is preserved instead of clobbered on next push. - MergeAchievements(out.achievements, cloudStats.achievements); - MergeStatValues(out.stats, cloudStats.stats); - // Schema is descriptive; adopt cloud's only when we hold none. - if (out.schema.empty() && !cloudStats.schema.empty()) - out.schema = std::move(cloudStats.schema); - } - // Materialize the merged result locally for fast subsequent reads. - WriteAppStats(appId, out, false); - LOG("[Stats] Merged app %u with cloud blob (forever=%u win=%u mac=%u linux=%u)", - appId, out.playtime.minutesForever, out.playtime.playtimeWindows, - out.playtime.playtimeMac, out.playtime.playtimeLinux); - } + // Consult the cached account blob (pulled once by SeedApps/RefreshFromCloud). + // No per-app network read -- the whole account blob was fetched in one shot. + if (MergeCloudBlobLocked(appId, out, haveLocal)) { + haveLocal = true; + // Materialize the merged result locally for fast subsequent reads. + WriteAppStats(appId, out, false); + LOG("[Stats] Merged app %u with cloud blob (forever=%u win=%u mac=%u linux=%u)", + appId, out.playtime.minutesForever, out.playtime.playtimeWindows, + out.playtime.playtimeMac, out.playtime.playtimeLinux); } if (!haveLocal) return false; @@ -924,18 +1010,39 @@ bool LoadAppStats(uint32_t appId, AppStats& out) { // Persist locally and, when pushCloud, queue a cloud upload. Reconcile writes // locally only; the cloud is written on session end, when playtime accrues. -static void WriteAppStats(uint32_t appId, const AppStats& stats, bool pushCloud) { +static void WriteAppStats(uint32_t appId, const AppStats& stats, bool pushCloud, + bool bypassDiskMerge) { std::string path = StatsPath(appId); - std::string json = BuildAppStatsJson(stats); + + // Playtime is monotonic: never let a write regress it. A buggy or partial + // caller (or a fresh record built before a load completed) must not truncate + // the file's accumulated minutes. Fold in the on-disk per-device buckets via + // max-merge so the result is always >= what's already persisted. + // bypassDiskMerge: the migration-repair caller must be able to shrink a bucket, + // so skip the re-merge that would undo its recomputed shortfall. + AppStats merged = stats; + if (!bypassDiskMerge) { + std::ifstream rf(path); + if (rf.good()) { + std::string existing((std::istreambuf_iterator<char>(rf)), + std::istreambuf_iterator<char>()); + rf.close(); + AppStats prior; + if (!existing.empty() && ParseAppStatsJson(existing, prior)) + MergePlaytime(merged.playtime, prior.playtime); + } + } + + std::string json = BuildAppStatsJson(merged); std::ofstream f(path, std::ios::trunc); f << json; f.close(); - if (!stats.schema.empty()) { + if (!merged.schema.empty()) { std::string schemaPath = SchemaPath(appId); std::ofstream sf(schemaPath, std::ios::binary | std::ios::trunc); - sf.write(reinterpret_cast<const char*>(stats.schema.data()), stats.schema.size()); + sf.write(reinterpret_cast<const char*>(merged.schema.data()), merged.schema.size()); } if (pushCloud) { @@ -947,6 +1054,13 @@ static void WriteAppStats(uint32_t appId, const AppStats& stats, bool pushCloud) } } +// Serializes the detached push workers: the push does a download->merge->upload +// RMW outside g_mutex, so two concurrent pushes could each merge onto a stale +// base and the slower upload would clobber the faster one's entries. One at a time. +// Leaked, never-destructed: a detached push worker can still lock this during static +// destruction at exit, so heap-back it to avoid locking a destroyed mutex. +static std::mutex& g_pushInFlightMutex = *new std::mutex(); + // Push the account-wide stats blob if any app changed since the last push (one // write for all apps). The push does blocking curl I/O, so it must run off the // caller's thread -- EndSession runs on Steam's GamesPlayed net thread at exit, @@ -963,6 +1077,7 @@ static void PushAccountBlobIfDirty() { g_accountBlobDirty = false; // clear before releasing (re-set on later change) } std::thread([push = std::move(push), snapshot = std::move(snapshot)]() { + std::lock_guard<std::mutex> pushLock(g_pushInFlightMutex); push(snapshot); }).detach(); } @@ -981,8 +1096,15 @@ static std::unordered_map<uint32_t, bool> g_importAttempted; // yet. Retries across calls while accountId is unavailable (returns 0); only // marks "attempted" once we had a real accountId to look with. Caller holds mutex. static void EnsureNativeImportLocked(uint32_t appId, AppStats& stats) { - if (!stats.stats.empty()) return; // already have data - if (g_importAttempted.count(appId)) return; // already tried with a valid acct + // Run when we have no stat data yet, OR when we have data (e.g. achievements + // adopted from the cloud blob) but no schema -- the schema is required to serve + // those achievements, and a device that never played the app only gets it from + // Steam's appcache (SteamTools writes it for namespace apps). + if (!stats.stats.empty() && !stats.schema.empty()) return; + // Honor the once-attempted guard only when we already hold a schema; if the + // schema is still missing (e.g. SteamTools wrote it after our first try), keep + // retrying so cloud-adopted achievements eventually become serveable. + if (g_importAttempted.count(appId) && !stats.schema.empty()) return; if (!g_accountIdProvider || g_accountIdProvider() == 0) { // accountId not ready yet (not logged in) -- don't mark attempted; retry later. return; @@ -993,12 +1115,31 @@ static void EnsureNativeImportLocked(uint32_t appId, AppStats& stats) { bool imported = ImportNativeStats(appId, native); g_importAttempted[appId] = true; // accountId was valid; this is a definitive attempt if (imported) { - stats.stats = std::move(native.stats); - stats.achievements = std::move(native.achievements); + // Adopt the schema even on a schema-only import (no native stats blob). if (!native.schema.empty()) stats.schema = std::move(native.schema); - stats.crcStats = ComputeCrcLocked(stats); - g_dirty[appId] = true; - SaveAppStats(appId, stats); + // Merge, don't overwrite: cloud-adopted state may hold more unlocks than this + // device's native blob (e.g. DOOM 3 BFG -- cloud 9, local native 8). A wholesale + // assign dropped the cloud-only unlock. Union achievements (monotonic); stat + // values are last-writer-wins (native authoritative, so a reset still wins). + size_t haveBefore = CountUnlockedAchievements(stats.achievements); + size_t nativeHas = CountUnlockedAchievements(native.achievements); + bool merged = false; + if (!native.achievements.empty()) + merged |= MergeAchievements(stats.achievements, native.achievements); + if (!native.stats.empty()) + merged |= MergeStatValues(stats.stats, native.stats); + size_t haveAfter = CountUnlockedAchievements(stats.achievements); + // If cloud (haveBefore) held more than native, the merge must keep the + // superset -- haveAfter dropping below haveBefore means an unlock was lost. + LOG("[Stats] NativeImport merge app=%u: store_had=%zu native_had=%zu -> store_now=%zu " + "(stats=%zu schema=%zuB)%s", appId, haveBefore, nativeHas, haveAfter, + stats.stats.size(), stats.schema.size(), + haveAfter < haveBefore ? " [WARN: unlock regressed]" : ""); + if (merged || !native.schema.empty()) { + stats.crcStats = ComputeCrcLocked(stats); + g_dirty[appId] = true; + SaveAppStats(appId, stats); + } } } @@ -1046,9 +1187,18 @@ void CaptureNativeUnlocks(uint32_t appId) { static AppStats& GetOrCreateLocked(uint32_t appId) { auto it = g_cache.find(appId); if (it != g_cache.end()) { - // Cache hit, but a reconcile/session path may have created an empty - // entry before import ran. Ensure native stats are imported on first - // actual stats access (and on later retries once accountId is ready). + // Cache hit. LoadAppStats only merges the cloud blob on a miss, so an entry + // created by reconcile before the blob arrived never absorbs it -- re-merge + // here (once per fetch, guarded by g_cloudBlobMerged). + if (!g_cloudBlobMerged.count(appId) && + MergeCloudBlobLocked(appId, it->second, /*haveLocal=*/true)) { + it->second.crcStats = ComputeCrcLocked(it->second); + WriteAppStats(appId, it->second, false); + LOG("[Stats] Late-merged app %u from cloud blob (%zu ach block(s), schema=%zu)", + appId, it->second.achievements.size(), it->second.schema.size()); + } + // Ensure native stats are imported on first actual stats access (and on + // later retries once accountId is ready). EnsureNativeImportLocked(appId, it->second); return it->second; } @@ -1088,10 +1238,210 @@ void ResetStats(uint32_t appId) { g_dirty[appId] = true; } +// One-time migration off the legacy per-app cloud layout: for each managed app +// with no entry in the account blob, read its old <accountId>/<appId>/stats.json +// and fold it into the cache so the next push consolidates it into /0/stats.json. +// Self-healing -- once migrated, the app is in the account blob and is skipped. +static void MigrateLegacyBlobs(const std::vector<uint32_t>& appIds) { + CloudPullLegacyFn pullLegacy; + std::vector<uint32_t> missing; + { + std::lock_guard<std::mutex> lock(g_mutex); + if (!g_cloudPullLegacy) return; + pullLegacy = g_cloudPullLegacy; + for (uint32_t appId : appIds) { + if (appId == 0) continue; + if (g_cloudBlobByApp.find(appId) == g_cloudBlobByApp.end()) + missing.push_back(appId); + } + } + for (uint32_t appId : missing) { + std::string legacy = pullLegacy(appId); // network, off-lock + if (legacy.empty()) continue; + AppStats parsed; + if (!ParseAppStatsJson(legacy, parsed)) continue; + std::lock_guard<std::mutex> lock(g_mutex); + // Re-check: another path may have populated it while we were off-lock. + if (g_cloudBlobByApp.find(appId) != g_cloudBlobByApp.end()) continue; + g_cloudBlobByApp[appId] = legacy; + g_accountBlobDirty = true; + LOG("[Stats] Migrated legacy per-app blob for app %u (forever=%u)", + appId, parsed.playtime.minutesForever); + } +} + +// Parse the first-format playtime JSON ({"LastPlayed","Playtime","Playtime2wks"}). +static bool ParseLegacyPlaytimeBin(const std::string& content, uint32_t& mins, + uint32_t& lastPlayed, uint32_t& twoWks) { + Json::Value root = Json::Parse(content); + if (root.type != Json::Type::Object) return false; + mins = lastPlayed = twoWks = 0; + try { mins = (uint32_t)std::stoul(root["Playtime"].str()); } catch (...) {} + try { lastPlayed = (uint32_t)std::stoul(root["LastPlayed"].str()); } catch (...) {} + try { twoWks = (uint32_t)std::stoul(root["Playtime2wks"].str()); } catch (...) {} + return true; +} + +// Fold one app's legacy playtime into its store. `mins` is a grand total (sum of +// all platforms), so it must not stack on playtime the store already accounts for +// in other buckets -- that double-counts (e.g. a .bin total of 402 added to an +// existing 402 across __legacy_* buckets -> 804). Seed the migrated bucket with only +// the shortfall above every other bucket's total. Caller must not hold g_mutex. +static void ApplyLegacyPlaytime(uint32_t appId, uint32_t mins, + uint32_t lastPlayed, uint32_t twoWks) { + static const std::string kMigratedBucket = "__migrated_localconfig"; + if (mins == 0) return; + std::lock_guard<std::mutex> lock(g_mutex); + AppStats& stats = GetOrCreateLocked(appId); + + // Total already represented by every bucket OTHER than the migrated one. + uint64_t otherTotal = 0; + for (const auto& [dev, dp] : stats.playtime.perDevice) { + if (dev == kMigratedBucket) continue; + otherTotal += (uint64_t)dp.windows + dp.mac + dp.lin; + } + // Only the part of the .bin total not already covered elsewhere. + uint32_t shortfall = (mins > otherTotal) ? (uint32_t)(mins - otherTotal) : 0u; + + DevicePlaytime& mig = stats.playtime.perDevice[kMigratedBucket]; + // Set the bucket to the shortfall (recomputed each run, not a running max) so the + // v2 repair can shrink a previously double-counted bucket (402 -> 0). +#ifdef _WIN32 + mig.windows = shortfall; +#elif defined(__APPLE__) + mig.mac = shortfall; +#else + mig.lin = shortfall; +#endif + stats.playtime.minutesLastTwoWeeks = + (std::max)(stats.playtime.minutesLastTwoWeeks, twoWks); + if (lastPlayed > stats.playtime.lastPlayedTime) + stats.playtime.lastPlayedTime = lastPlayed; + RecomputePlaytimeTotals(stats.playtime); + g_dirty[appId] = true; + // bypassDiskMerge: this write must be allowed to shrink __migrated_localconfig. + WriteAppStats(appId, stats, true, /*bypassDiskMerge=*/true); + LOG("[Stats] Migrated legacy playtime app %u: .bin=%u, other=%llu -> +%u (forever now %u)", + appId, mins, (unsigned long long)otherTotal, shortfall, stats.playtime.minutesForever); +} + +// One-time migration off the first 2.2.x playtime format (per-app Playtime/<appId>.bin, +// local and cloud), max-merging into the migrated bucket so the higher copy wins. A +// done-marker makes later startups early-return. +static void MigrateLegacyPlaytimeBins(const std::vector<uint32_t>& appIds) { + std::error_code ec; + + // Pass 1 (local disk scan) uses a global one-shot marker. Pass 2 (cloud) is + // per-app: a single global marker stranded apps whose .bin was cloud-only (e.g. + // 1875580) if the provider wasn't authenticated yet at SeedApps time. Per-app + // markers, set only on a non-empty pull, let those retry next launch. + fs::path donePath = FileUtil::Utf8ToPath(g_storageRoot) / ".legacy_playtime_migrated_v2"; + fs::path cloudMarkerDir = FileUtil::Utf8ToPath(g_storageRoot) / ".legacy_pt_cloud_v2"; + bool pass1Done = fs::exists(donePath, ec); + + // Pass 1: local .bin files (fast, no network). Scan every account dir. + if (!pass1Done && !g_cloudRoot.empty()) { + fs::path storageDir = FileUtil::Utf8ToPath(g_cloudRoot) / "storage"; + if (fs::exists(storageDir, ec)) { + for (auto& acct : fs::directory_iterator(storageDir, ec)) { + if (!acct.is_directory()) continue; + fs::path ptDir = acct.path() / "0" / "Playtime"; + if (!fs::exists(ptDir, ec)) continue; + for (auto& entry : fs::directory_iterator(ptDir, ec)) { + if (!entry.is_regular_file()) continue; + // Process both pristine ".bin" and v1 backups ".bin.migrated" + // (v2 repair re-reads the source the v1 pass already renamed). + // Skip our own v2 backups so the pass can't loop. + const std::string fn = entry.path().filename().string(); + bool isBin = entry.path().extension() == ".bin"; + bool isV1 = fn.size() > 13 && + fn.compare(fn.size() - 13, 13, ".bin.migrated") == 0; + if (!isBin && !isV1) continue; + // Derive appId from the leading numeric stem (e.g. "1229490"). + std::string num = fn.substr(0, fn.find('.')); + uint32_t appId = 0; + try { appId = (uint32_t)std::stoul(num); } catch (...) {} + if (appId == 0) continue; + std::ifstream f(entry.path()); + if (!f.good()) continue; + std::string content((std::istreambuf_iterator<char>(f)), + std::istreambuf_iterator<char>()); + f.close(); + uint32_t mins, lastPlayed, twoWks; + if (ParseLegacyPlaytimeBin(content, mins, lastPlayed, twoWks)) + ApplyLegacyPlaytime(appId, mins, lastPlayed, twoWks); + // Settle on a single v2 backup name so neither this pass nor any + // future v2 run re-scans it. + std::error_code renEc; + fs::path v2 = ptDir / (num + ".bin.migrated.v2"); + fs::rename(entry.path(), v2, renEc); + } + } + } + } + + // Mark pass 1 done so the whole-disk scan never repeats. + if (!pass1Done) + std::ofstream(donePath.string(), std::ios::trunc) << "1"; + + // Pass 2: cloud copies (the .bin may exist only in the cloud). Per-app guarded: + // skip apps already recovered, retry the rest. Max-merge prefers higher local/cloud. + CloudPullLegacyPlaytimeFn pullPt; + { + std::lock_guard<std::mutex> lock(g_mutex); + pullPt = g_cloudPullLegacyPlaytime; + } + if (!pullPt) return; + ec.clear(); // ec was threaded through pass-1 fs calls; only trust it fresh + fs::create_directories(cloudMarkerDir, ec); + if (ec) { + // Can't persist per-app counters -> a sync network pull would run every + // launch with no way to give up. Bail rather than spin. + LOG("[Stats] legacy pt cloud marker dir unavailable (%s); skipping cloud pass", + ec.message().c_str()); + return; + } + // Marker contents: "done" once recovered/given up; a number = empty-pull attempts + // so far (retry next launch). After kMaxTries empties we stop -- the format is + // frozen, so a missing bin won't appear. + static const int kMaxTries = 8; + for (uint32_t appId : appIds) { + if (appId == 0) continue; + fs::path mk = cloudMarkerDir / std::to_string(appId); + int tries = 0; + { + std::ifstream mf(mk); + if (mf.good()) { + std::string s((std::istreambuf_iterator<char>(mf)), + std::istreambuf_iterator<char>()); + if (s == "done") continue; // recovered or exhausted + try { tries = std::stoi(s); } catch (...) {} + } + } + std::string json = pullPt(appId); // network, off-lock + if (json.empty()) { + if (++tries >= kMaxTries) + std::ofstream(mk.string(), std::ios::trunc) << "done"; // give up + else + std::ofstream(mk.string(), std::ios::trunc) << tries; // retry later + continue; + } + uint32_t mins, lastPlayed, twoWks; + if (ParseLegacyPlaytimeBin(json, mins, lastPlayed, twoWks)) + ApplyLegacyPlaytime(appId, mins, lastPlayed, twoWks); + std::ofstream(mk.string(), std::ios::trunc) << "done"; // recovered + LOG("[Stats] Cloud legacy playtime recovered app %u (%u min)", appId, mins); + } +} + void SeedApps(const std::vector<uint32_t>& appIds) { // One network read for the whole account, not one per app. GetOrCreate then // reads each app's entry from the cached blob (no further network). RefreshCloudBlobCache(); + // Recover stats stranded under the old per-app cloud layout. + MigrateLegacyBlobs(appIds); + // Recover playtime from the very first 2.2.x per-app .bin format (local+cloud). + MigrateLegacyPlaytimeBins(appIds); for (uint32_t appId : appIds) { if (appId == 0) continue; GetOrCreate(appId); // merges cached cloud blob + imports native + loads local @@ -1138,6 +1488,10 @@ std::vector<uint32_t> RefreshFromCloud(const std::vector<uint32_t>& appIds) { cur.playtime.lastPlayedTime != before.lastPlayedTime); // Another device advanced this app -> persist locally and report. if (playtimeChanged || achChanged || statChanged) { + // The crc is the sync token Steam echoes; recompute it when the data + // changed or it stops matching what we serve. + if (achChanged || statChanged) + cur.crcStats = ComputeCrcLocked(cur); WriteAppStats(appId, cur, false); changed.push_back(appId); LOG("[Stats] Cloud advanced app %u: forever %u -> %u (win=%u mac=%u linux=%u) ach=%d stat=%d", @@ -1190,13 +1544,11 @@ uint32_t ComputeCrcLocked(const AppStats& stats) { return buf.empty() ? 0 : Crc32(buf.data(), buf.size()); } -uint32_t ComputeCrc(uint32_t appId) { - return ComputeCrcLocked(g_cache[appId]); // caller holds g_mutex -} - uint32_t SetStat(uint32_t appId, uint32_t statId, uint32_t value) { std::lock_guard<std::mutex> lock(g_mutex); - auto& stats = g_cache[appId]; + // Seed before mutating, else a first-touch Set builds a near-empty record the + // push would publish over cross-device data. + AppStats& stats = GetOrCreateLocked(appId); bool found = false; for (auto& s : stats.stats) { @@ -1211,13 +1563,13 @@ uint32_t SetStat(uint32_t appId, uint32_t statId, uint32_t value) { } g_dirty[appId] = true; - stats.crcStats = ComputeCrc(appId); + stats.crcStats = ComputeCrcLocked(stats); return stats.crcStats; } uint32_t SetStats(uint32_t appId, const std::vector<StatEntry>& entries) { std::lock_guard<std::mutex> lock(g_mutex); - auto& stats = g_cache[appId]; + AppStats& stats = GetOrCreateLocked(appId); // seed before mutate+push for (auto& e : entries) { bool found = false; @@ -1234,13 +1586,13 @@ uint32_t SetStats(uint32_t appId, const std::vector<StatEntry>& entries) { } g_dirty[appId] = true; - stats.crcStats = ComputeCrc(appId); + stats.crcStats = ComputeCrcLocked(stats); return stats.crcStats; } uint32_t SetAchievement(uint32_t appId, uint32_t statId, uint32_t bit, uint32_t unlockTime) { std::lock_guard<std::mutex> lock(g_mutex); - auto& stats = g_cache[appId]; + AppStats& stats = GetOrCreateLocked(appId); // seed before mutate+push AchievementBlock* blk = nullptr; for (auto& a : stats.achievements) { @@ -1260,7 +1612,7 @@ uint32_t SetAchievement(uint32_t appId, uint32_t statId, uint32_t bit, uint32_t } g_dirty[appId] = true; - stats.crcStats = ComputeCrc(appId); + stats.crcStats = ComputeCrcLocked(stats); return stats.crcStats; } @@ -1279,14 +1631,10 @@ const std::vector<uint8_t>& GetSchema(uint32_t appId) { void StartSession(uint32_t appId) { std::lock_guard<std::mutex> lock(g_mutex); g_activeSessions[appId] = NowUnix(); - auto& stats = g_cache[appId]; - if (stats.playtime.lastPlayedTime == 0) { - LoadAppStats(appId, stats); - } - // Seed achievements/stats from Steam's native blob too -- not every app gets - // a GetUserStats RPC, so launching the game is our reliable trigger to import - // (and then cloud-sync) the real stat/achievement data, not just playtime. - EnsureNativeImportLocked(appId, stats); + // Seed via GetOrCreateLocked (local + cloud blob + native). Hand-rolling g_cache[] + // skipped the cloud merge on reconcile-touched entries, so EndSession then pushed + // a blob missing other devices' achievements. + AppStats& stats = GetOrCreateLocked(appId); stats.playtime.lastPlayedTime = NowUnix(); g_dirty[appId] = true; LOG("[Stats] Session started for app %u", appId); @@ -1303,7 +1651,9 @@ void EndSession(uint32_t appId) { uint32_t minutes = elapsed / 60; g_activeSessions.erase(it); - auto& stats = g_cache[appId]; + // Seed first so the cloud blob's cross-device unlocks are in the record we + // build+push (else EndSession drops another device's achievements). + AppStats& stats = GetOrCreateLocked(appId); // Accrue onto THIS device's own per-device sub-total (keyed by device id), so // a session here can never overwrite another device's contribution -- even a // same-platform device's -- under the last-writer-wins cloud blob. diff --git a/src/common/stats_store.h b/src/common/stats_store.h index 52446686..64c01a35 100644 --- a/src/common/stats_store.h +++ b/src/common/stats_store.h @@ -20,7 +20,17 @@ using CloudPullAllFn = std::function<bool(std::unordered_map<uint32_t, std::string>& out)>; using CloudPushAllFn = std::function<void(const std::unordered_map<uint32_t, std::string>& all)>; -void SetCloudProvider(CloudPullAllFn pullAll, CloudPushAllFn pushAll); +// Read one app's LEGACY per-app stats blob (<accountId>/<appId>/stats.json) from +// the older layout that predates the consolidated account blob. Returns the JSON, +// or empty if absent. Used once to migrate stranded playtime into the account blob. +using CloudPullLegacyFn = std::function<std::string(uint32_t appId)>; +// Read one app's first-format playtime blob (account-scope Playtime/<appId>.bin, +// {"LastPlayed","Playtime","Playtime2wks"}). Returns the JSON or empty. Lets the +// playtime migration recover from the cloud when the .bin isn't in the local cache. +using CloudPullLegacyPlaytimeFn = std::function<std::string(uint32_t appId)>; +void SetCloudProvider(CloudPullAllFn pullAll, CloudPushAllFn pushAll, + CloudPullLegacyFn pullLegacy = nullptr, + CloudPullLegacyPlaytimeFn pullLegacyPlaytime = nullptr); struct StatEntry { uint32_t statId; @@ -136,9 +146,6 @@ uint32_t SetAchievement(uint32_t appId, uint32_t statId, uint32_t bit, uint32_t void SetSchema(uint32_t appId, const uint8_t* data, size_t len); const std::vector<uint8_t>& GetSchema(uint32_t appId); -// Compute CRC32 over current stat values for an app. -uint32_t ComputeCrc(uint32_t appId); - // Re-read Steam's native blob for an app and merge any newly unlocked // achievements / updated stat values into the store, then push to the cloud if // anything changed. Called when an achievement-store message is observed on the diff --git a/src/platform/linux/cloud_hooks.cpp b/src/platform/linux/cloud_hooks.cpp index 643ed5ec..c6869640 100644 --- a/src/platform/linux/cloud_hooks.cpp +++ b/src/platform/linux/cloud_hooks.cpp @@ -376,6 +376,12 @@ static void EnsureInitialized() { // pushAll: RMW-merge our snapshot onto the live blob (don't clobber // another device) and upload once; skip if nothing changed. [](const std::unordered_map<uint32_t, std::string>& all) { + // Detached worker: register with the in-flight drain so + // CloudStorage::Shutdown waits for the provider read below before + // tearing g_provider down (UAF guard). + CloudStorage::InflightSyncScope guard; + if (!guard.entered) return; + uint32_t accountId = CloudIntercept::GetAccountId(); if (accountId == 0) return; @@ -394,10 +400,7 @@ static void EnsureInitialized() { if (appId == 0) continue; std::string key = std::to_string(appId); Json::Value appVal = Json::Parse(json); - std::string existing = root.has(key) - ? Json::Stringify(root.objVal[key]) : std::string(); - std::string updated = Json::Stringify(appVal); - if (existing != updated) { + if (!root.has(key) || !Json::DeepEqual(root.objVal[key], appVal)) { root.objVal[key] = std::move(appVal); changed = true; } @@ -407,6 +410,29 @@ static void EnsureInitialized() { std::string merged = Json::Stringify(root); CloudStorage::UploadCloudMetadataTextAsync( accountId, CloudIntercept::kAccountScopeAppId, "stats.json", merged); + }, + // pullLegacy: read one app's old per-app blob for migration into the account blob. + [](uint32_t appId) -> std::string { + CloudStorage::InflightSyncScope guard; + if (!guard.entered) return std::string(); + uint32_t accountId = CloudIntercept::GetAccountId(); + if (accountId == 0) return std::string(); + std::vector<uint8_t> data; + if (CloudStorage::DownloadCloudMetadataWithLegacyFallback( + accountId, appId, "stats.json", nullptr, data) && !data.empty()) + return std::string(reinterpret_cast<const char*>(data.data()), data.size()); + return std::string(); + }, + // pullLegacyPlaytime: read one app's first-format Playtime/<appId>.bin from cloud. + [](uint32_t appId) -> std::string { + CloudStorage::InflightSyncScope guard; + if (!guard.entered) return std::string(); + uint32_t accountId = CloudIntercept::GetAccountId(); + if (accountId == 0) return std::string(); + std::vector<uint8_t> data; + if (CloudStorage::DownloadLegacyPlaytimeBlob(accountId, appId, data)) + return std::string(reinterpret_cast<const char*>(data.data()), data.size()); + return std::string(); }); // Track playtime/stats for namespace (lua) apps only -- real owned games // must never have their playtime recorded or synced. diff --git a/src/platform/win/cloud_intercept.cpp b/src/platform/win/cloud_intercept.cpp index 24070409..3b37c0a5 100644 --- a/src/platform/win/cloud_intercept.cpp +++ b/src/platform/win/cloud_intercept.cpp @@ -4385,6 +4385,12 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa // pushAll: RMW-merge our snapshot onto the live blob (don't clobber // another device) and upload once; skip if nothing changed. [](const std::unordered_map<uint32_t, std::string>& all) { + // This runs on a detached worker (off Steam's net thread). Register + // with the in-flight drain so CloudStorage::Shutdown waits for the + // provider read below before tearing g_provider down (UAF guard). + CloudStorage::InflightSyncScope guard; + if (!guard.entered) return; + uint32_t accountId = GetAccountId(); if (accountId == 0) return; @@ -4405,10 +4411,7 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa if (appId == 0) continue; std::string key = std::to_string(appId); Json::Value appVal = Json::Parse(json); - std::string existing = root.has(key) - ? Json::Stringify(root.objVal[key]) : std::string(); - std::string updated = Json::Stringify(appVal); - if (existing != updated) { + if (!root.has(key) || !Json::DeepEqual(root.objVal[key], appVal)) { root.objVal[key] = std::move(appVal); changed = true; } @@ -4418,6 +4421,29 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa std::string merged = Json::Stringify(root); CloudStorage::UploadCloudMetadataTextAsync( accountId, CloudIntercept::kAccountScopeAppId, "stats.json", merged); + }, + // pullLegacy: read one app's old per-app blob for migration into the account blob. + [](uint32_t appId) -> std::string { + CloudStorage::InflightSyncScope guard; + if (!guard.entered) return std::string(); + uint32_t accountId = GetAccountId(); + if (accountId == 0) return std::string(); + std::vector<uint8_t> data; + if (CloudStorage::DownloadCloudMetadataWithLegacyFallback( + accountId, appId, "stats.json", nullptr, data) && !data.empty()) + return std::string(reinterpret_cast<const char*>(data.data()), data.size()); + return std::string(); + }, + // pullLegacyPlaytime: read one app's first-format Playtime/<appId>.bin from cloud. + [](uint32_t appId) -> std::string { + CloudStorage::InflightSyncScope guard; + if (!guard.entered) return std::string(); + uint32_t accountId = GetAccountId(); + if (accountId == 0) return std::string(); + std::vector<uint8_t> data; + if (CloudStorage::DownloadLegacyPlaytimeBlob(accountId, appId, data)) + return std::string(reinterpret_cast<const char*>(data.data()), data.size()); + return std::string(); }); // Restrict all playtime/stats tracking to namespace/lua apps only -- real // owned games must never have their playtime recorded or synced. diff --git a/src/providers/google_drive.cpp b/src/providers/google_drive.cpp index c64843ce..f403f7a2 100644 --- a/src/providers/google_drive.cpp +++ b/src/providers/google_drive.cpp @@ -7,6 +7,9 @@ #include <chrono> #include <random> #include <fstream> +#include <atomic> +#include <mutex> +#include <vector> #ifdef _WIN32 #include <bcrypt.h> @@ -916,19 +919,79 @@ bool GoogleDriveProvider::Upload(const std::string& path, } bool GoogleDriveProvider::UploadBatch(const std::vector<UploadItem>& items) { - // Google Drive batch API does not support file upload (multipart/related) - // requests -- only metadata-only operations. Use individual Upload() calls. - std::vector<std::string> uploadedPaths; - for (const auto& item : items) { - if (!Upload(item.path, item.data.data(), item.data.size())) { - LOG("[GDriveProvider] UploadBatch: failed to upload '%s', rolling back %zu prior upload(s)", - item.path.c_str(), uploadedPaths.size()); - for (const auto& path : uploadedPaths) Remove(path); - return false; + // Drive's batch API doesn't cover uploads, so each blob is an individual Upload(). + // Native uploads in parallel (convars @nClientCloudMaxNumParallelUploads=10, + // @nClientCloudMaxMBParallelUploads=64MB); mirror that. Concurrent uploads are + // safe: per-call request handles, distinct CAS paths, m_folderMtx-guarded folders. + if (items.empty()) return true; + + static constexpr size_t kMaxParallel = 10; // native @nClientCloudMaxNumParallelUploads + static constexpr uint64_t kMaxBytesInFlight = 64ull << 20; // native @nClientCloudMaxMBParallelUploads (64 MB) + + std::atomic<size_t> next{0}; + std::atomic<bool> failed{false}; + std::mutex doneMtx; + std::vector<std::string> uploadedPaths; // for rollback, guarded by doneMtx + + // Worker: claim items by index until exhausted or a failure is seen. + auto worker = [&]() { + for (;;) { + if (failed.load(std::memory_order_relaxed)) return; + size_t i = next.fetch_add(1, std::memory_order_relaxed); + if (i >= items.size()) return; + const UploadItem& item = items[i]; + // An exception escaping a std::thread entry calls std::terminate, so catch + // here (bad_alloc is likelier with up to 10 buffers in flight). + bool ok = false; + try { + ok = Upload(item.path, item.data.data(), item.data.size()); + } catch (const std::exception& e) { + LOG("[GDriveProvider] UploadBatch: worker threw on '%s': %s", + item.path.c_str(), e.what()); + } catch (...) { + LOG("[GDriveProvider] UploadBatch: worker threw (unknown) on '%s'", + item.path.c_str()); + } + if (!ok) { + failed.store(true, std::memory_order_relaxed); + LOG("[GDriveProvider] UploadBatch: failed to upload '%s'", item.path.c_str()); + return; + } + std::lock_guard<std::mutex> lk(doneMtx); + uploadedPaths.push_back(item.path); + } + }; + + // Worker count = min(kMaxParallel, items), capped further by avg size so total + // bytes in flight stay roughly under kMaxBytesInFlight. + uint64_t totalBytes = 0; + for (const auto& it : items) totalBytes += it.data.size(); + size_t byCount = (std::min)(kMaxParallel, items.size()); + size_t byBytes = byCount; + if (totalBytes > kMaxBytesInFlight && items.size() > 1) { + // Keep concurrent bytes roughly under the cap (avg item size based). + uint64_t avg = totalBytes / items.size(); + if (avg > 0) { + size_t cap = (size_t)(kMaxBytesInFlight / avg); + byBytes = (cap < 1) ? 1 : cap; } - uploadedPaths.push_back(item.path); } - LOG("[GDriveProvider] UploadBatch: uploaded %zu files individually", items.size()); + size_t workerCount = (std::min)(byCount, byBytes); + if (workerCount < 1) workerCount = 1; + + std::vector<std::thread> pool; + pool.reserve(workerCount); + for (size_t t = 0; t < workerCount; ++t) pool.emplace_back(worker); + for (auto& th : pool) th.join(); + + if (failed.load(std::memory_order_relaxed)) { + LOG("[GDriveProvider] UploadBatch: a parallel upload failed; rolling back %zu uploaded blob(s)", + uploadedPaths.size()); + for (const auto& path : uploadedPaths) Remove(path); + return false; + } + LOG("[GDriveProvider] UploadBatch: uploaded %zu file(s) with %zu parallel worker(s)", + items.size(), workerCount); return true; } diff --git a/ui/Pages/DashboardPage.xaml.cs b/ui/Pages/DashboardPage.xaml.cs index 1420a17b..007c8f3b 100644 --- a/ui/Pages/DashboardPage.xaml.cs +++ b/ui/Pages/DashboardPage.xaml.cs @@ -153,9 +153,12 @@ private async void OpenLog_Click(object sender, RoutedEventArgs e) var logPath = Services.SteamDetector.GetLogPath(); if (logPath != null && File.Exists(logPath)) { + // Open the containing folder with the log file highlighted, rather than + // opening the (large) log in a text editor. /select takes the file path. Process.Start(new ProcessStartInfo { - FileName = logPath, + FileName = "explorer.exe", + Arguments = $"/select,\"{logPath}\"", UseShellExecute = true })?.Dispose(); } diff --git a/ui/Resources/Strings.resx b/ui/Resources/Strings.resx index 2fdd2b2b..6e98a862 100644 --- a/ui/Resources/Strings.resx +++ b/ui/Resources/Strings.resx @@ -579,7 +579,7 @@ If you skip this, saves will be stored locally in your Steam folder.</value> <value>Quick Actions</value> </data> <data name="Dashboard_OpenLogFile" xml:space="preserve"> - <value>Open Log File</value> + <value>Show Log in Folder</value> </data> <data name="Dashboard_RestartSteam" xml:space="preserve"> <value>Restart Steam</value> From a837f855f1db4b2f1101900c34a066d372fdd016 Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Sun, 14 Jun 2026 02:35:28 -0400 Subject: [PATCH 21/24] 2.2.0 Test-3 --- CMakeLists.txt | 1 + src/common/app_state.cpp | 52 ++- src/common/app_state.h | 15 +- src/common/cloud_provider.h | 7 + src/common/cloud_provider_base.cpp | 12 +- src/common/cloud_provider_base.h | 4 + src/common/cloud_storage.cpp | 204 +++++------ src/common/cloud_storage.h | 22 +- src/common/cloud_work_queue.cpp | 2 +- src/common/coop_yield.cpp | 73 ++++ src/common/coop_yield.h | 60 ++++ src/common/local_storage.cpp | 7 +- src/common/rpc_handlers.cpp | 504 ++++++++++++++++----------- src/platform/linux/cloud_hooks.cpp | 8 +- src/platform/win/cloud_intercept.cpp | 403 +++++++++++++++------ src/platform/win/cr_api.cpp | 8 +- src/providers/google_drive.cpp | 39 ++- src/providers/onedrive.cpp | 12 +- 18 files changed, 965 insertions(+), 468 deletions(-) create mode 100644 src/common/coop_yield.cpp create mode 100644 src/common/coop_yield.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 53fa9c1c..56dad663 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,6 +51,7 @@ set(CR_SO_VERSION "${CR_RELEASE_VERSION}+${GIT_SHA}") set(COMMON_SOURCES src/common/protobuf.cpp src/common/json.cpp + src/common/coop_yield.cpp src/common/vdf.cpp src/common/remotecache_repair.cpp src/common/manifest_store.cpp diff --git a/src/common/app_state.cpp b/src/common/app_state.cpp index 7dfd6f31..232b1928 100644 --- a/src/common/app_state.cpp +++ b/src/common/app_state.cpp @@ -1,6 +1,7 @@ #include "app_state.h" #include "cloud_storage.h" #include "cloud_metadata_paths.h" +#include "coop_yield.h" #include "file_util.h" #include "json.h" #include "local_storage.h" @@ -63,6 +64,46 @@ inline int64_t NowMs() { } // namespace +// Pending publish barrier: CompleteBatch defers the cloud publish to a background +// thread and stores its future here. ReleaseCloudSession (ExitSyncDone) waits on it +// before releasing the session lock so cloud state is durable before another machine +// can acquire. BeginBatch's FetchCloudStateForServe also drains it so the next batch +// sees the fresh cloud CN. +namespace { +std::mutex& g_pendingPublishMtx = *new std::mutex(); +std::unordered_map<uint64_t, std::shared_future<void>>& g_pendingPublish = + *new std::unordered_map<uint64_t, std::shared_future<void>>(); +} // namespace + +void SetPendingPublish(uint32_t accountId, uint32_t appId, + std::shared_future<void> fut) { + std::lock_guard<std::mutex> lk(g_pendingPublishMtx); + g_pendingPublish[ServeCacheKey(accountId, appId)] = std::move(fut); +} + +void WaitForPendingPublish(uint32_t accountId, uint32_t appId) { + std::shared_future<void> fut; + { + std::lock_guard<std::mutex> lk(g_pendingPublishMtx); + auto it = g_pendingPublish.find(ServeCacheKey(accountId, appId)); + if (it == g_pendingPublish.end()) return; + fut = it->second; + } + if (fut.valid()) { + // Runs on BMainLoop (BeginBatch handler); a hard fut.wait() here starved the + // frame watchdog while a prior batch's publish held its barrier. Pump the job + // coroutine instead, polling with wait_for(0). Degrades to a plain spin off Steam. + CoopYield::PumpUntil([&fut]() { + return fut.wait_for(std::chrono::seconds(0)) == + std::future_status::ready; + }); + } + { + std::lock_guard<std::mutex> lk(g_pendingPublishMtx); + g_pendingPublish.erase(ServeCacheKey(accountId, appId)); + } +} + // See g_ownClientId. void NoteOwnClientId(uint64_t clientId) { if (clientId != 0) @@ -489,7 +530,8 @@ StateFetchResult FetchCloudStateForServe(uint32_t accountId, uint32_t appId) { } bool PublishCloudState(uint32_t accountId, uint32_t appId, - const CloudAppState& state, bool lockOnly) { + const CloudAppState& state, bool lockOnly, + const std::unordered_set<std::string>* confirmedDurable) { InflightSyncScope guard; if (!guard) return false; if (!g_stateProvider || !g_stateProvider->IsAuthenticated()) { @@ -501,7 +543,8 @@ bool PublishCloudState(uint32_t accountId, uint32_t appId, // we never publish a state pointing at blobs that 404 elsewhere. lockOnly skips // it: a session-release publish reuses the manifest CompleteBatch just verified. CloudAppState verified = state; - if (!lockOnly && !VerifyAndHealManifestForPublish(accountId, appId, verified)) { + if (!lockOnly && + !VerifyAndHealManifestForPublish(accountId, appId, verified, confirmedDurable)) { LOG("[AppState] PublishCloudState app %u: cannot verify blobs, deferring publish", appId); return false; } @@ -548,6 +591,11 @@ bool PublishCloudState(uint32_t accountId, uint32_t appId, } void ReleaseCloudSession(uint32_t accountId, uint32_t appId, uint64_t clientId) { + // Wait for any deferred CompleteBatch publish to land before releasing the + // session. This ensures the cloud state is durable (at the batch CN) before + // another machine can acquire and fetch it. + WaitForPendingPublish(accountId, appId); + // Sync mutex: serialize state RMW to prevent interleaved publishes. auto syncMtx = AcquireAppSyncMutex(accountId, appId); std::lock_guard<std::mutex> syncLock(*syncMtx); diff --git a/src/common/app_state.h b/src/common/app_state.h index 3724da6c..a194fddd 100644 --- a/src/common/app_state.h +++ b/src/common/app_state.h @@ -3,9 +3,11 @@ #include "cloud_provider.h" #include <cstdint> +#include <future> #include <mutex> #include <string> #include <unordered_map> +#include <unordered_set> #include <vector> namespace CloudStorage { @@ -102,15 +104,26 @@ void NoteOwnClientId(uint64_t clientId); // a stale RMW on providers with no conditional-write primitive. // lockOnly skips the blob verify/heal pass; use it only on the session-release // publish, where the manifest and CN were just committed by the upload batch. +// `confirmedDurable` (optional) forwards to VerifyAndHealManifestForPublish: filenames +// uploaded+provider-confirmed in this batch, so their durability needn't be re-listed. bool PublishCloudState(uint32_t accountId, uint32_t appId, - const CloudAppState& state, bool lockOnly = false); + const CloudAppState& state, bool lockOnly = false, + const std::unordered_set<std::string>* confirmedDurable = nullptr); std::string SerializeState(const CloudAppState& state); bool DeserializeState(const std::string& json, CloudAppState& outState); // Release the session lock in the cloud state (called on ExitSyncDone). +// Blocks until any pending async publish completes before releasing. void ReleaseCloudSession(uint32_t accountId, uint32_t appId, uint64_t clientId); +// Pending publish barrier: CompleteBatch defers cloud publish to a background +// thread and registers a future here. ReleaseCloudSession and BeginBatch's +// FetchCloudStateForServe wait on it to ensure cross-machine consistency. +void SetPendingPublish(uint32_t accountId, uint32_t appId, + std::shared_future<void> fut); +void WaitForPendingPublish(uint32_t accountId, uint32_t appId); + CloudAppState MigrateFromLegacy(uint64_t cn, const std::unordered_map<std::string, FileEntry>& legacyFiles); diff --git a/src/common/cloud_provider.h b/src/common/cloud_provider.h index 003e4117..bcf7dbf8 100644 --- a/src/common/cloud_provider.h +++ b/src/common/cloud_provider.h @@ -30,8 +30,15 @@ class ICloudProvider { std::vector<uint8_t> data; }; + // Contract: implementations MUST CAS-skip items already present on the + // provider (CheckExists == Exists), because callers such as + // PromoteStagedBatchForCommit no longer pre-filter existing blobs -- dedup + // happens here (native-faithful: per-file, ideally inside parallel workers, + // mirroring ClientBeginFileUpload's server-side short-circuit). Skip only on + // a definite Exists; on Missing/Error, upload (the CAS path is idempotent). virtual bool UploadBatch(const std::vector<UploadItem>& items) { for (const auto& item : items) { + if (CheckExists(item.path) == ExistsStatus::Exists) continue; if (!Upload(item.path, item.data.data(), item.data.size())) return false; } diff --git a/src/common/cloud_provider_base.cpp b/src/common/cloud_provider_base.cpp index 14e6d641..f01ee97e 100644 --- a/src/common/cloud_provider_base.cpp +++ b/src/common/cloud_provider_base.cpp @@ -8,9 +8,15 @@ #include <ctime> #include <thread> #include <chrono> +#include <atomic> using HttpUtil::HttpResp; +// Global counter of HTTP 429/403 rate-limit hits (read/reset per batch in +// UploadBatch for throughput telemetry). Confirms the API throttle has +// headroom before lowering it further. +std::atomic<uint64_t> g_rateLimitHits{0}; + // ── ParsePath ────────────────────────────────────────────────────────────── bool CloudProviderBase::ParsePath(const std::string& path, @@ -49,7 +55,10 @@ void CloudProviderBase::ThrottleApiCall() { last = m_lastApiCallTick.load(std::memory_order_acquire); uint64_t now = (uint64_t)duration_cast<milliseconds>( steady_clock::now().time_since_epoch()).count(); - desired = (last != 0 && now < last + 150) ? last + 150 : now; + // 60ms (~16 req/s) min spacing between API calls. Measured 0 rate-limit + // hits across full multi-batch uploads, so there is headroom over + // Google's ~10 req/s soft limit; lower further only with telemetry. + desired = (last != 0 && now < last + 60) ? last + 60 : now; } while (!m_lastApiCallTick.compare_exchange_weak(last, desired, std::memory_order_acq_rel, std::memory_order_acquire)); uint64_t now = (uint64_t)duration_cast<milliseconds>( @@ -236,6 +245,7 @@ HttpResp CloudProviderBase::ApiRequest(const char* method, const std::string& pa hdrs.push_back("Content-Type: " + contentType); lastResp = Request(method, ApiHost(), path, body, hdrs); if (!IsRateLimited(lastResp.status, lastResp.body)) return lastResp; + g_rateLimitHits.fetch_add(1, std::memory_order_relaxed); LOG("%s Rate limited (%s attempt %d, HTTP %d), retrying", LogTag(), method, attempt + 1, lastResp.status); } diff --git a/src/common/cloud_provider_base.h b/src/common/cloud_provider_base.h index 6dfa83d2..14459c58 100644 --- a/src/common/cloud_provider_base.h +++ b/src/common/cloud_provider_base.h @@ -14,6 +14,10 @@ #include <vector> #include <cstdint> +// Global counter of HTTP 429/403 rate-limit hits, incremented in ApiRequest. +// Read/reset per batch in UploadBatch for throughput telemetry. +extern std::atomic<uint64_t> g_rateLimitHits; + // ── IHttpTransport ──────────────────────────────────────────────────────── // Platform adapter for raw HTTP request execution. // Windows: WinHTTP session/connection/request handles. diff --git a/src/common/cloud_storage.cpp b/src/common/cloud_storage.cpp index e07e615b..905b980f 100644 --- a/src/common/cloud_storage.cpp +++ b/src/common/cloud_storage.cpp @@ -69,6 +69,33 @@ struct BlobIndex { }; static std::unordered_map<uint64_t, BlobIndex> g_blobIndex; // key = (accountId<<32)|appId +// Session-scoped set of CAS hashes (sha1hex) provider-confirmed durable this process +// lifetime. The session lock (LaunchIntent EResult=108) blocks other machines from +// GC-ing them, so a hash durable earlier this session is still durable now. Lets +// VerifyAndHealManifestForPublish skip its slow blob listing. Keyed by sha (CAS blobs +// are immutable), in-memory only; a fresh launch re-verifies. +static std::mutex g_durableBlobsMutex; +static std::unordered_map<uint64_t, std::unordered_set<std::string>> g_durableBlobs; // (acct<<32|app) -> {sha1hex} + +static void RecordDurableBlobShas(uint32_t accountId, uint32_t appId, + const std::vector<std::string>& shaHexes) { + if (shaHexes.empty()) return; + uint64_t key = (static_cast<uint64_t>(accountId) << 32) | appId; + std::lock_guard<std::mutex> lk(g_durableBlobsMutex); + auto& set = g_durableBlobs[key]; + for (const auto& s : shaHexes) + if (s.size() == 40) set.insert(s); +} + +static bool IsBlobShaDurableThisSession(uint32_t accountId, uint32_t appId, + const std::string& shaHex) { + if (shaHex.size() != 40) return false; + uint64_t key = (static_cast<uint64_t>(accountId) << 32) | appId; + std::lock_guard<std::mutex> lk(g_durableBlobsMutex); + auto it = g_durableBlobs.find(key); + return it != g_durableBlobs.end() && it->second.count(shaHex) > 0; +} + // Serializes token persistence (root_token.dat, file_tokens.dat) across // concurrent callers (rpc_handlers batch operations). // Per-(account,app) sync mutex registry (Steam-parity). Non-reentrant: SyncFromCloudInner-reachable callers go direct. @@ -79,7 +106,6 @@ static std::unordered_map<uint64_t, std::shared_ptr<std::mutex>> g_syncMutexRegi // long-running Download/Upload doesn't return into freed memory. static std::atomic<int> g_inflightSyncCount{0}; static std::atomic<bool> g_shuttingDown{false}; -static std::atomic<int> g_inflightCommitDrainCount{0}; InflightSyncScope::InflightSyncScope() { g_inflightSyncCount.fetch_add(1, std::memory_order_seq_cst); @@ -741,105 +767,11 @@ static std::string LocalBlobPath(uint32_t accountId, uint32_t appId, // Enqueue a cloud upload of the current CN value for this app. // Dedup in EnqueueWork will coalesce multiple calls during a batch. -void PushCNToCloud(uint32_t accountId, uint32_t appId, uint64_t cn) { - ClearMissingMetadataPath(CloudMetadataPath(accountId, appId, kCNFilename)); - std::string cnStr = std::to_string(cn); - CloudWorkQueue::WorkItem wi; - wi.type = CloudWorkQueue::WorkItem::Upload; - wi.cloudPath = CloudMetadataPath(accountId, appId, kCNFilename); - wi.data.assign(cnStr.begin(), cnStr.end()); - CloudWorkQueue::EnqueueWork(std::move(wi)); -} - -bool PushCNToCloudSync(uint32_t accountId, uint32_t appId, uint64_t cn) { - InflightSyncScope guard; - if (!guard) return false; - if (!g_provider) return false; - std::string cnStr = std::to_string(cn); - if (!UploadCloudMetadataText(accountId, appId, kCNFilename, cnStr)) { - return false; - } - RemoveCloudMetadataIfPresent(accountId, appId, kLegacyCNFilename); - return true; -} - -uint64_t FetchCloudCN(uint32_t accountId, uint32_t appId) { - InflightSyncScope guard; - if (!guard) return 0; - if (!g_provider || !g_provider->IsAuthenticated()) return 0; - - std::vector<uint8_t> data; - bool usedLegacy = false; - if (!DownloadCloudMetadataWithLegacyFallback(accountId, appId, - kCNFilename, kLegacyCNFilename, data, &usedLegacy)) { - return 0; - } - - std::string s(data.begin(), data.end()); - try { - uint64_t cn = std::stoull(s); - if (usedLegacy) { - if (UploadCloudMetadataText(accountId, appId, kCNFilename, std::to_string(cn))) { - RemoveCloudMetadataIfPresent(accountId, appId, kLegacyCNFilename); - } else { - LOG("[CloudStorage] FetchCloudCN app %u: failed to migrate legacy cloud CN", appId); - } - } else { - RemoveCloudMetadataIfPresent(accountId, appId, kLegacyCNFilename); - } - return cn; - } catch (...) { - return 0; - } -} - - -bool CommitCNWithRetry(uint32_t accountId, uint32_t appId, uint64_t cn) { - bool drained = CloudWorkQueue::DrainQueueForApp(accountId, appId); - if (g_shuttingDown.load(std::memory_order_seq_cst)) return false; - bool cnPublished = drained && PushCNToCloudSync(accountId, appId, cn); - if (cnPublished) return true; - if (g_shuttingDown.load(std::memory_order_seq_cst)) return false; - LOG("[CloudStorage] CommitCNWithRetry app %u CN=%llu drained=%d: deferring to async retry", - appId, (unsigned long long)cn, drained ? 1 : 0); - PushCNToCloud(accountId, appId, cn); - if (g_shuttingDown.load(std::memory_order_seq_cst)) return false; - CloudWorkQueue::DrainQueueForApp(accountId, appId); - return false; -} - // --- manifest utilities needed by SyncFromCloudInner --- // ManifestToJson lives in manifest_store.cpp; SyncFromCloudWithFlag uses // SaveManifestLocal / SaveManifest which call through to ManifestStore. -// Detached: don't block Steam's RPC dispatch. Per-app sync mutex orders against SyncFromCloud and prevents older CNs landing after newer. -void CommitCNAsync(uint32_t accountId, uint32_t appId, uint64_t cn) { - g_inflightCommitDrainCount.fetch_add(1, std::memory_order_seq_cst); - if (g_shuttingDown.load(std::memory_order_seq_cst)) { - g_inflightCommitDrainCount.fetch_sub(1, std::memory_order_seq_cst); - return; - } - try { - std::thread([accountId, appId, cn]() { - struct Guard { - ~Guard() { g_inflightCommitDrainCount.fetch_sub(1, std::memory_order_seq_cst); } - } guard; - if (g_shuttingDown.load(std::memory_order_seq_cst)) return; - auto m = AcquireAppSyncMutex(accountId, appId); - std::lock_guard<std::mutex> lk(*m); - if (g_shuttingDown.load(std::memory_order_seq_cst)) return; - // No WaitForForegroundSyncIdle: per-app mutex covers ordering; a cross-app park here would deadlock or invert FIFO. - (void)CommitCNWithRetry(accountId, appId, cn); - }).detach(); - } catch (...) { - g_inflightCommitDrainCount.fetch_sub(1, std::memory_order_seq_cst); - LOG("[CloudStorage] CommitCNAsync: std::thread construction failed for app %u CN=%llu", - appId, (unsigned long long)cn); - } -} - - // Drop stale conflict-copy files (>30 days) from cloud_redirect\conflicts\. // Best-effort startup cleanup; must not escape exceptions into Init(). static void PruneStaleConflictCopies(const std::string& localRoot) { @@ -963,17 +895,14 @@ void Shutdown() { // Drain in-flight ops before g_provider teardown (no internal cancel). 5s cap so session switches don't lag. { const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); - while ((g_inflightSyncCount.load(std::memory_order_seq_cst) > 0 - || g_inflightCommitDrainCount.load(std::memory_order_seq_cst) > 0) + while (g_inflightSyncCount.load(std::memory_order_seq_cst) > 0 && std::chrono::steady_clock::now() < deadline) { std::this_thread::sleep_for(std::chrono::milliseconds(25)); } - int residualSync = g_inflightSyncCount.load(std::memory_order_seq_cst); - int residualCommit = g_inflightCommitDrainCount.load(std::memory_order_seq_cst); - if (residualSync > 0 || residualCommit > 0) { - LOG("[CloudStorage] Shutdown: %d in-flight SyncFromCloud and %d CommitCNAsync " - "call(s) did not drain within 5s; leaking provider to avoid UAF", - residualSync, residualCommit); + int residualSync = g_inflightSyncCount.load(std::memory_order_seq_cst); + if (residualSync > 0) { + LOG("[CloudStorage] Shutdown: %d in-flight SyncFromCloud call(s) did not " + "drain within 5s; leaking provider to avoid UAF", residualSync); (void)g_provider.release(); g_provider = nullptr; return; @@ -1610,7 +1539,8 @@ bool DeleteBlobStaged(uint32_t accountId, uint32_t appId, // (forget) rather than advertise a blob that 404s elsewhere. Returns false only // when the cloud listing is unavailable (can't tell durable from phantom). bool VerifyAndHealManifestForPublish(uint32_t accountId, uint32_t appId, - CloudAppState& state) { + CloudAppState& state, + const std::unordered_set<std::string>* confirmedDurable) { if (state.files.empty()) return true; InflightSyncScope guard; @@ -1621,6 +1551,33 @@ bool VerifyAndHealManifestForPublish(uint32_t accountId, uint32_t appId, // Account-scope metadata is filename-addressed, not CAS; skip. if (appId == CloudIntercept::kAccountScopeAppId) return true; + // Skip blob re-listing for a file whose content hash is in the session durable + // cache. Sha-keyed only: the cache holds the shas the promote actually uploaded + // (RecordDurableBlobShas, populated before this runs). Filename is not trusted -- + // fe.sha may differ from the uploaded sha, which could skip a stale CAS path. If + // every file qualifies the ~20s GDrive walk is skipped; a mixed state still lists + // the unconfirmed remainder. confirmedDurable is kept for ABI but no longer read. + auto durableWithoutListing = [&](const std::string& filename, + const FileEntry& fe) -> bool { + (void)filename; + (void)confirmedDurable; + std::string shaHex = fe.sha.empty() ? std::string() : ShaToHex(fe.sha); + return !shaHex.empty() && IsBlobShaDurableThisSession(accountId, appId, shaHex); + }; + + { + bool allConfirmed = true; + for (const auto& [filename, fe] : state.files) { + if (!durableWithoutListing(filename, fe)) { allConfirmed = false; break; } + } + if (allConfirmed) { + LOG("[CloudStorage] VerifyManifest app %u: all %zu file(s) confirmed durable " + "this session; skipping blob listing", + appId, state.files.size()); + return true; + } + } + std::string blobPrefix = std::to_string(accountId) + "/" + std::to_string(appId) + "/blobs/"; std::vector<ICloudProvider::FileInfo> remoteBlobs; @@ -1656,6 +1613,11 @@ bool VerifyAndHealManifestForPublish(uint32_t accountId, uint32_t appId, const std::string& filename = it->first; const FileEntry& fe = it->second; + // Sha confirmed durable this session: durable by definition, no need to consult + // the listing (it may even race a just-completed upload's eventual-consistency + // window). Trust the upload 2xx, exactly as native trusts EResult. + if (durableWithoutListing(filename, fe)) { ++it; continue; } + std::string shaHex = fe.sha.empty() ? std::string() : ShaToHex(fe.sha); bool present = (!shaHex.empty() && cloudShas.count(shaHex) > 0) || cloudFilenames.count(filename) > 0; @@ -1721,6 +1683,8 @@ bool PromoteStagedBatchForCommit(uint32_t accountId, uint32_t appId, std::vector<ICloudProvider::UploadItem> batchItems; batchItems.reserve(uploads.size()); + std::vector<std::string> batchShaHexes; // content hashes promoted this batch + batchShaHexes.reserve(uploads.size()); for (const auto& filename : uploads) { if (CloudIntercept::IsReservedBlobFilename(filename)) { @@ -1744,28 +1708,24 @@ bool PromoteStagedBatchForCommit(uint32_t accountId, uint32_t appId, item.path = CloudBlobPathByNameAndSHA(accountId, appId, filename, shaHex); item.data = std::move(data); batchItems.push_back(std::move(item)); + batchShaHexes.push_back(std::move(shaHex)); } if (!batchItems.empty()) { - // Filter out blobs that already exist on cloud (CAS dedup). - std::vector<ICloudProvider::UploadItem> newItems; - newItems.reserve(batchItems.size()); - for (auto& item : batchItems) { - if (g_provider->CheckExists(item.path) == ICloudProvider::ExistsStatus::Exists) { - LOG("[CloudStorage] PromoteStagedBatch app %u batch %llu: CAS dedup skipped %s", - appId, (unsigned long long)batchId, item.path.c_str()); - continue; - } - newItems.push_back(std::move(item)); - } - - if (!newItems.empty()) { - if (!g_provider->UploadBatch(newItems)) { - LOG("[CloudStorage] PromoteStagedBatch app %u batch %llu: batch upload failed", - appId, (unsigned long long)batchId); - return false; - } + // CAS dedup is now done per-file INSIDE UploadBatch's parallel workers + // (native-faithful: mirrors ClientBeginFileUpload's server-side EResult-29 + // short-circuit), so the prior serial CheckExists pre-pass is gone -- it + // was ~100 sequential round-trips on Steam's thread before the parallel + // upload even started. + if (!g_provider->UploadBatch(batchItems)) { + LOG("[CloudStorage] PromoteStagedBatch app %u batch %llu: batch upload failed", + appId, (unsigned long long)batchId); + return false; } + // UploadBatch==true => every blob is provider-confirmed durable (2xx or + // CAS-Exists). Record their hashes so a later batch's publish can skip + // re-listing them (the session lock guarantees no other machine removes them). + RecordDurableBlobShas(accountId, appId, batchShaHexes); } // CAS: deletes don't remove cloud blobs; GC reclaims orphans. diff --git a/src/common/cloud_storage.h b/src/common/cloud_storage.h index 29660b08..664992b1 100644 --- a/src/common/cloud_storage.h +++ b/src/common/cloud_storage.h @@ -51,8 +51,16 @@ bool PromoteStagedBatchForCommit(uint32_t accountId, uint32_t appId, // Verifies every file in `state` has its CAS blob durably on the provider before // its manifest is published; heals from the local cache or drops phantom entries. // Returns false only when the cloud blob listing is unavailable (don't publish). +// +// `confirmedDurable`, when non-null, lists filenames whose blobs were just uploaded +// (and provider-confirmed with a 2xx) in this batch. These are durable by definition +// -- exactly the signal native trusts (the upload EResult; see YldUploadFiles) -- so +// they need no re-listing. If every file in `state` is confirmed, the (slow, ~20s for +// GDrive) blob listing is skipped entirely; otherwise it lists only to verify the +// carried-forward remainder (files from a prior CN that this batch did not re-upload). bool VerifyAndHealManifestForPublish(uint32_t accountId, uint32_t appId, - CloudAppState& state); + CloudAppState& state, + const std::unordered_set<std::string>* confirmedDurable = nullptr); std::vector<uint64_t> ListStagedBatchIds(uint32_t accountId, uint32_t appId); bool RemoveStagedBatch(uint32_t accountId, uint32_t appId, uint64_t batchId); @@ -70,21 +78,9 @@ bool HasLocalBlob(uint32_t accountId, uint32_t appId, // GC: delete unreferenced blobs. Returns count deleted, or -1 on error. int GarbageCollectBlobs(uint32_t accountId, uint32_t appId); -// Returns 0 if in sync or error; >0 = cloud CN when cloud is newer. -uint64_t FetchCloudCN(uint32_t accountId, uint32_t appId); - bool SyncFromCloud(uint32_t accountId, uint32_t appId); std::vector<uint32_t> SyncAllFromCloud(uint32_t accountId); -void PushCNToCloud(uint32_t accountId, uint32_t appId, uint64_t cn); -bool PushCNToCloudSync(uint32_t accountId, uint32_t appId, uint64_t cn); - -// Drain + sync push CN; on failure enqueues async retry + drains again. -bool CommitCNWithRetry(uint32_t accountId, uint32_t appId, uint64_t cn); - -// Fire-and-forget CommitCNWithRetry on a detached thread. -void CommitCNAsync(uint32_t accountId, uint32_t appId, uint64_t cn); - // Pauses background uploads so foreground SyncFromCloud doesn't queue behind sweeps. struct ForegroundSyncScope { ForegroundSyncScope(); diff --git a/src/common/cloud_work_queue.cpp b/src/common/cloud_work_queue.cpp index ccb2f842..e30442e0 100644 --- a/src/common/cloud_work_queue.cpp +++ b/src/common/cloud_work_queue.cpp @@ -35,7 +35,7 @@ static std::unordered_map<std::string, std::pair<uint64_t, std::chrono::steady_c static std::unordered_set<std::string> g_failedPaths; static std::unordered_map<std::string, WorkItem> g_failedWorkItems; static std::condition_variable g_drainCV; -static constexpr int WORKER_THREAD_COUNT = 4; +static constexpr int WORKER_THREAD_COUNT = 8; static constexpr int MAX_DRAIN_REQUEUES = 3; static constexpr int FAIL_THRESHOLD = 5; diff --git a/src/common/coop_yield.cpp b/src/common/coop_yield.cpp new file mode 100644 index 00000000..cf6e2425 --- /dev/null +++ b/src/common/coop_yield.cpp @@ -0,0 +1,73 @@ +#include "coop_yield.h" + +#include <atomic> +#include <chrono> +#include <thread> + +namespace CoopYield { + +// g_hook is installed by SetYieldHook at startup. The g_hook object itself is not +// reassigned at runtime; instead g_hookDisabled is flipped to suppress invocation +// (e.g. on a yield-primitive fault), avoiding reassigning/destroying the std::function +// while it may be executing. Acquire/release ordering avoids locking on the hot path. +static YieldHook g_hook; +static std::atomic<bool> g_hookSet{false}; +static std::atomic<bool> g_hookDisabled{false}; + +void SetYieldHook(YieldHook hook) { + g_hook = std::move(hook); + const bool installed = static_cast<bool>(g_hook); + if (installed) { + g_hookDisabled.store(false, std::memory_order_release); // re-register re-enables + } + g_hookSet.store(installed, std::memory_order_release); +} + +void DisableYieldHook() { + // Suppress further invocation without touching g_hook (which may be running). + g_hookDisabled.store(true, std::memory_order_release); +} + +bool HasYieldHook() { + return g_hookSet.load(std::memory_order_acquire); +} + +void YieldNow() { + if (g_hookDisabled.load(std::memory_order_acquire)) return; + if (g_hookSet.load(std::memory_order_acquire) && g_hook) { + g_hook(); + } +} + +void PumpUntil(const std::function<bool()>& done) { + // Poll cadence: short enough that BMainLoop stays well under the >15s frame + // watchdog and feels responsive, long enough not to spin the CPU. The win32 + // yield itself is a no-op unless the job has held its slice >10ms, so this + // self-throttles toward native's yield cadence. + constexpr auto kPollInterval = std::chrono::milliseconds(2); + const bool canYield = g_hookSet.load(std::memory_order_acquire); + while (!done()) { + if (canYield) { + YieldNow(); // guarded inside the hook (skips if coroutine inactive) + } + std::this_thread::sleep_for(kPollInterval); + } +} + +std::unique_lock<std::mutex> LockCooperatively(std::mutex& mtx) { + // Same cadence/rationale as PumpUntil. try_lock() never blocks, so BMainLoop is + // only ever paused for the yield + 2ms sleep per iteration, well under the >15s + // watchdog, no matter how long the holding thread keeps the lock. + constexpr auto kPollInterval = std::chrono::milliseconds(2); + const bool canYield = g_hookSet.load(std::memory_order_acquire); + std::unique_lock<std::mutex> lock(mtx, std::defer_lock); + while (!lock.try_lock()) { + if (canYield) { + YieldNow(); // guarded inside the hook (skips if coroutine inactive) + } + std::this_thread::sleep_for(kPollInterval); + } + return lock; +} + +} // namespace CoopYield diff --git a/src/common/coop_yield.h b/src/common/coop_yield.h new file mode 100644 index 00000000..68593401 --- /dev/null +++ b/src/common/coop_yield.h @@ -0,0 +1,60 @@ +#pragma once +// Cooperative main-thread yield. +// +// CompleteBatch and its blob promote run on Steam's BMainLoop thread and block it for +// the whole upload (~43s for a 100-file batch). Starving BMainLoop that long trips +// Steam's frame watchdog at >15s (steamengine.cpp:2838) and a fatal pipe stall +// (pipes.cpp:900) -- a crash on large saves. Native survives because its upload job +// keeps yielding the job fiber; the win32 layer mirrors that with a hook that yields +// the current Steam job (CJob::BYieldIfTimeSlice on g_pJobCur, a no-op unless the job +// held the slice >10ms). Main-thread wait loops call YieldNow between polls. +// +// Constraints: +// - Re-enters Steam's scheduler; MUST be called holding no CR mutex. +// - Only valid on BMainLoop; a worker-thread call is a guarded no-op. +// - Off Steam (Linux, tests) no hook is set and YieldNow does nothing. + +#include <functional> +#include <mutex> + +namespace CoopYield { + +using YieldHook = std::function<void()>; + +// Register the platform yield implementation (win32). Pass nullptr to clear. +// Installed at startup and read locklessly thereafter. The hook object is not +// reassigned at runtime; it may instead be atomically disabled via DisableYieldHook +// (e.g. on a yield-primitive fault). Re-registering a non-null hook re-enables it. +void SetYieldHook(YieldHook hook); + +// Suppress the hook via an atomic flag rather than clearing it, so it is safe to call +// from inside the hook itself. YieldNow no-ops until a hook is re-registered. +void DisableYieldHook(); + +// True if a yield hook is installed, i.e. running under Steam. Callers use this to +// pick a yield-poll wait over a hard join. +bool HasYieldHook(); + +// One cooperative yield (no-op with no hook). Named YieldNow, not Yield, to dodge the +// empty Yield() macro from <windows.h>. Only does anything where the calling thread's +// job coroutine is active (the slot-5 handler level); the win32 hook guards with +// Coroutine_IsActive() and skips otherwise, so calling it elsewhere is a safe no-op. +// Hold no CR mutex across it -- the yield re-enters Steam's scheduler. +void YieldNow(); + +// Cooperatively wait until done() returns true, keeping BMainLoop responsive. The +// analogue of native CJobFuncs::YieldingWaitForFuncs: yields the job coroutine while +// background work runs instead of hard-blocking in join(). Off Steam it degrades to a +// predicate spin with a short sleep. +// - MUST run at the active-coroutine handler level and hold no CR mutex. +// - done() must be cheap and thread-safe (typically an atomic the worker sets). +void PumpUntil(const std::function<bool()>& done); + +// Acquire `mtx` without hard-blocking BMainLoop: try_lock() inside the same pump as +// PumpUntil, so while another thread holds it (e.g. a publish thread in a ~20s cloud +// List) the coroutine keeps yielding and the frame watchdog never trips. Off Steam it +// degrades to a try_lock + short-sleep spin. Same contract as PumpUntil (active +// coroutine level, no other CR mutex held). Returns the held lock to the caller. +std::unique_lock<std::mutex> LockCooperatively(std::mutex& mtx); + +} // namespace CoopYield diff --git a/src/common/local_storage.cpp b/src/common/local_storage.cpp index 96a15d80..83a58729 100644 --- a/src/common/local_storage.cpp +++ b/src/common/local_storage.cpp @@ -793,8 +793,11 @@ uint64_t GetChangeNumber(uint32_t accountId, uint32_t appId) { break; } - g_changeNumbers[key] = 1; - return 1; + // No CN file exists -- brand-new app. Native Steam (sub_138A16FB0) returns 0 + // for unknown apps. Returning 1 would make Steam think cloud is ahead of the + // client (serverCN=1 > clientCN=0), confusing the initial sync direction. + g_changeNumbers[key] = 0; + return 0; } // Lazy load; ++ on a missing key would silently regress to 1. diff --git a/src/common/rpc_handlers.cpp b/src/common/rpc_handlers.cpp index 10cae677..41bddd77 100644 --- a/src/common/rpc_handlers.cpp +++ b/src/common/rpc_handlers.cpp @@ -11,6 +11,7 @@ #include "cloud_staging.h" #include "app_state.h" #include "cloud_storage.h" +#include "coop_yield.h" #include "pending_ops_journal.h" #include "file_util.h" #include "remotecache_repair.h" @@ -147,6 +148,11 @@ static std::mutex g_fileTokensDirtyMutex; // Per-batch AutoCloud canonical root map: resolve once, reuse across begin/commit. static std::unordered_map<uint64_t, std::unordered_map<std::string, std::string>> g_batchCanonicalTokens; +// Keys already loaded this batch -- tracked separately so an EMPTY token map still +// counts as "prepared" and avoids re-hitting the provider (a Drive 404 for +// file_tokens.dat + root_token.dat) on every per-file commit. Otherwise a fresh app +// with no tokens re-downloads both files per commit on Steam's thread = UI freeze. +static std::unordered_set<uint64_t> g_batchTokensPrepared; static std::mutex g_batchCanonicalTokensMutex; // Serializes token load-merge-save cycles. @@ -350,7 +356,8 @@ static void PrepareBatchCanonicalTokens(uint32_t accountId, uint32_t appId) { uint64_t key = MakeAppAccountKey(accountId, appId); { std::lock_guard<std::mutex> lock(g_batchCanonicalTokensMutex); - if (g_batchCanonicalTokens.find(key) != g_batchCanonicalTokens.end()) return; + // Already prepared this batch (even if empty) -- skip the provider load. + if (g_batchTokensPrepared.count(key)) return; } // File->root-token mappings are persisted to disk by the upload path; load @@ -358,15 +365,19 @@ static void PrepareBatchCanonicalTokens(uint32_t accountId, uint32_t appId) { // cache, but that component is gone -- disk is the single source of truth.) std::unordered_map<std::string, std::string> tokens = CloudStorage::LoadFileTokens(accountId, appId); - if (tokens.empty()) return; std::lock_guard<std::mutex> lock(g_batchCanonicalTokensMutex); - g_batchCanonicalTokens.emplace(key, std::move(tokens)); + // Mark prepared unconditionally so an empty result is cached too. + g_batchTokensPrepared.insert(key); + if (!tokens.empty()) + g_batchCanonicalTokens.emplace(key, std::move(tokens)); } static void ClearBatchCanonicalTokens(uint32_t accountId, uint32_t appId) { std::lock_guard<std::mutex> lock(g_batchCanonicalTokensMutex); - g_batchCanonicalTokens.erase(MakeAppAccountKey(accountId, appId)); + uint64_t key = MakeAppAccountKey(accountId, appId); + g_batchCanonicalTokens.erase(key); + g_batchTokensPrepared.erase(key); } static std::string CanonicalizeUploadRootToken(uint32_t accountId, uint32_t appId, @@ -600,6 +611,11 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB LOG("[NS-CL] GetAppFileChangelist app=%u: cloud state CN=%llu (%zu files)", appId, cloudCN, cloudManifest.size()); } else if (stateResult.status == CloudStorage::StateFetchStatus::NotFound) { + // Definitively no cloud data (not a fetch failure). Treat as an empty + // cloud manifest so the authoritative path returns is_only_delta=0, + // which tells Steam "cloud is empty" and triggers its disk scan + upload. + haveCloudManifest = true; + cloudCN = 0; LOG("[NS-CL] GetAppFileChangelist app=%u: no cloud state (new app), using local", appId); } else if (stateResult.status == CloudStorage::StateFetchStatus::Timeout) { @@ -1078,8 +1094,12 @@ RpcResult HandleLaunchIntent(uint32_t appId, const std::vector<PB::Field>& reqBo bool sessionConflict = false; if (stateResult.status == CloudStorage::StateFetchStatus::Ok) { // Sync mutex: serialize state RMW to prevent interleaved publishes. + // Cooperative acquire (same rationale as CompleteBatch): a background publish + // thread may hold this mutex through a slow cloud round-trip; a hard lock here + // would stall BMainLoop past the >15s watchdog. This handler also runs on the + // active job coroutine, so LockCooperatively can yield while it waits. auto syncMtx = CloudStorage::AcquireAppSyncMutex(accountId, appId); - std::lock_guard<std::mutex> syncLock(*syncMtx); + auto syncLock = CoopYield::LockCooperatively(*syncMtx); auto& state = stateResult.state; uint64_t now = static_cast<uint64_t>(time(nullptr)); @@ -1107,7 +1127,8 @@ RpcResult HandleLaunchIntent(uint32_t appId, const std::vector<PB::Field>& reqBo state.session.machineName = currentSession.machineName; state.session.timeLastUpdated = now; state.session.operation = "active"; - if (!CloudStorage::PublishCloudState(accountId, appId, state)) { + // Session-only: skip blob verification (lockOnly=true). + if (!CloudStorage::PublishCloudState(accountId, appId, state, /*lockOnly=*/true)) { LOG("[NS] LaunchIntent app=%u: session override publish failed", appId); } LOG("[NS] LaunchIntent app=%u: forced session override (machine=%s, client=%llu)", @@ -1118,7 +1139,8 @@ RpcResult HandleLaunchIntent(uint32_t appId, const std::vector<PB::Field>& reqBo state.session.machineName = currentSession.machineName; state.session.timeLastUpdated = now; state.session.operation = "active"; - if (!CloudStorage::PublishCloudState(accountId, appId, state)) { + // Session-only: skip blob verification (lockOnly=true). + if (!CloudStorage::PublishCloudState(accountId, appId, state, /*lockOnly=*/true)) { // Publish refused/failed -- typically the CN-monotonic guard saw a // newer cloud state (another machine published in the window). // Re-fetch the fresh state and re-apply our session onto it. @@ -1133,7 +1155,7 @@ RpcResult HandleLaunchIntent(uint32_t appId, const std::vector<PB::Field>& reqBo sessionConflict = !ignorePendingOperations; } else { freshState.session = state.session; - if (!CloudStorage::PublishCloudState(accountId, appId, freshState)) { + if (!CloudStorage::PublishCloudState(accountId, appId, freshState, /*lockOnly=*/true)) { LOG("[NS] LaunchIntent app=%u: session acquire retry also failed", appId); } } @@ -1265,6 +1287,11 @@ RpcResult HandleBeginBatch(uint32_t appId, const std::vector<PB::Field>& reqBody return RpcResult(PB::Writer(), kEResultFail); } + // If a previous batch's cloud publish is still in-flight, wait for it so + // FetchCloudStateForServe sees the fresh CN. Typically a no-op (single batch) + // or instant (publish already landed between batches). + CloudStorage::WaitForPendingPublish(accountId, appId); + // The CN returned here is what Steam records as synced and what we must publish // at CompleteBatch -- they have to match, or Steam re-downloads what it just // uploaded. Assign max(local, cloud)+1, strictly above whatever the cloud holds. @@ -1753,232 +1780,283 @@ RpcResult HandleCompleteBatch(uint32_t appId, const std::vector<PB::Field>& reqB // blocked BMainLoop past the 15s watchdog on large saves. Detach it instead. uint64_t newCN = batch.assignedCN; - // Worker order: promote -> advance local CN/manifest -> publish. Advancing the - // manifest only after promote avoids advertising a not-yet-uploaded SHA. + // Native CompleteAppUploadBatchBlocking yields while BMainLoop keeps pumping. + // Return quickly after the critical sync work (promote + local CN advance) and + // defer the slow cloud publish to a background thread. Correct because: + // 1. The next BeginBatch reads CN from local state (already advanced). + // 2. ExitSyncDone is fire-and-forget (IDA-validated: notification, no response). + // 3. ReleaseCloudSession waits on the publish barrier before releasing the + // session lock, so another machine can't see stale state. + uint64_t publishCN = newCN; + uint64_t publishBuildId = batch.appBuildId; + uint64_t workerBatchId = batch.batchId; + auto& uploadMeta = batch.uploadMeta; + auto& filePlatforms = batch.filePlatforms; + + // --- Synchronous on Steam's thread: promote + local CN advance --- + + // Inflight-sync scope: drains before provider teardown (UAF guard). + CloudStorage::InflightSyncScope guard; + if (!guard.entered) { + LOG("[NS] CompleteBatch app=%u: inflight-sync scope refused entry (shutting down?)", appId); + BatchTracker_Clear(accountId, appId, batch.batchId); + ClearBatchCanonicalTokens(accountId, appId); + return PB::Writer(); + } + + // Promote (blob upload, ~43s for a 100-file batch). The handler runs on the active + // job coroutine, so mirror native YldUploadFiles: run the promote on a background + // thread and PumpUntil it finishes rather than hard-blocking BMainLoop. Returns + // only after the promote completes, keeping the CN advance below strictly after + // durability; no CR mutex is held across the pump, so a re-entry can't deadlock. + auto tPromote = std::chrono::steady_clock::now(); + std::atomic<bool> promoteDone{false}; + bool promoteOk = false; + std::thread promoteThread( + [&accountId, &appId, &workerBatchId, &uploads, &deletes, + &promoteOk, &promoteDone]() { + promoteOk = CloudStorage::PromoteStagedBatchForCommit( + accountId, appId, workerBatchId, uploads, deletes); + promoteDone.store(true, std::memory_order_release); + }); + // Cooperative wait: pumps BMainLoop via the guarded job-coroutine yield until the + // promote thread signals completion. Degrades to a plain spin off Steam. + CoopYield::PumpUntil([&promoteDone]() { + return promoteDone.load(std::memory_order_acquire); + }); + promoteThread.join(); + auto promoteMs = std::chrono::duration_cast<std::chrono::milliseconds>( + std::chrono::steady_clock::now() - tPromote).count(); + LOG("[NS] CompleteBatch app=%u: promote done in %lldms (ok=%d)", + appId, (long long)promoteMs, promoteOk ? 1 : 0); + if (!promoteOk) { + LOG("[NS] CompleteBatch app=%u: staged promotion failed; " + "leaving CN unchanged (Steam re-uploads next sync)", appId); + PendingOpsJournal::RecordUploadBatchInterrupted(accountId, appId); + BatchTracker_Clear(accountId, appId, batch.batchId); + ClearBatchCanonicalTokens(accountId, appId); + return PB::Writer(); + } + + // Advance the local CN/manifest now that the blobs are durable. { - uint64_t publishCN = newCN; - uint64_t publishBuildId = batch.appBuildId; - uint64_t workerBatchId = batch.batchId; - // Copies of the per-file metadata the publish stage rebuilds cloud state from. - auto uploadMeta = std::make_shared< - std::unordered_map<std::string, CloudIntercept::UploadFileMeta>>(batch.uploadMeta); - auto filePlatforms = std::make_shared< - std::unordered_map<std::string, uint32_t>>(batch.filePlatforms); - auto uploadsCopy = std::make_shared<std::vector<std::string>>(uploads); - auto deletesCopy = std::make_shared<std::vector<std::string>>(deletes); - - std::thread([accountId, appId, publishCN, publishBuildId, workerBatchId, - uploadMeta, filePlatforms, uploadsCopy, deletesCopy] { - // Inflight-sync scope first: drains before provider teardown (UAF guard). - CloudStorage::InflightSyncScope guard; - if (!guard.entered) return; - - // Promote: the blob upload (the slow part). - auto tPromote = std::chrono::steady_clock::now(); - bool promoteOk = CloudStorage::PromoteStagedBatchForCommit( - accountId, appId, workerBatchId, *uploadsCopy, *deletesCopy); - LOG("[NS] CompleteBatch(async) app=%u: promote done in %lldms (ok=%d)", - appId, - (long long)std::chrono::duration_cast<std::chrono::milliseconds>( - std::chrono::steady_clock::now() - tPromote).count(), - promoteOk ? 1 : 0); - if (!promoteOk) { - // Upload failed: leave the CN so the next BeginBatch reassigns it and - // Steam re-uploads. - LOG("[NS] CompleteBatch(async) app=%u: staged promotion failed; " - "leaving CN unchanged (Steam re-uploads next sync)", appId); - PendingOpsJournal::RecordUploadBatchInterrupted(accountId, appId); - return; + // Cooperative acquire: a prior batch's deferred publish thread can still hold + // the app-sync mutex through a ~20s cloud List. A hard lock_guard here would + // hard-block BMainLoop for that whole window (tripping the >15s watchdog, as + // observed). LockCooperatively keeps the job coroutine yielding while it waits. + auto syncMtx = CloudStorage::AcquireAppSyncMutex(accountId, appId); + auto lock = CoopYield::LockCooperatively(*syncMtx); + auto localManifest = CloudStorage::LoadLocalManifest(accountId, appId); + for (const auto& filename : deletes) + localManifest.erase(filename); + for (const auto& filename : uploads) { + if (IsReservedBlobFilename(filename)) continue; + CloudStorage::ManifestEntry me; + auto metaIt = uploadMeta.find(filename); + if (metaIt != uploadMeta.end()) { + me.sha = metaIt->second.sha; + me.timestamp = metaIt->second.timestamp; + me.size = metaIt->second.size; + } else { + auto entry = LocalStorage::GetFileEntry(accountId, appId, filename); + if (!entry.has_value()) continue; + me.sha = entry->sha; + me.timestamp = entry->timestamp; + me.size = entry->rawSize; } + localManifest[filename] = std::move(me); + } + LocalStorage::SetChangeNumber(accountId, appId, publishCN); + CloudStorage::SaveManifestLocal(accountId, appId, localManifest); + CloudStorage::SaveManifestSnapshot(accountId, appId, publishCN); + LOG("[NS] CompleteBatch app=%u: local CN advanced to %llu + manifest saved", + appId, (unsigned long long)publishCN); + } + + // --- Deferred to background thread: cloud state publish --- + // Captures batch metadata by value; the background thread owns it. + auto uploadMetaCopy = std::make_shared< + std::unordered_map<std::string, CloudIntercept::UploadFileMeta>>(uploadMeta); + auto filePlatformsCopy = std::make_shared< + std::unordered_map<std::string, uint32_t>>(filePlatforms); + auto uploadsCopy = std::make_shared<std::vector<std::string>>(uploads); + auto deletesCopy = std::make_shared<std::vector<std::string>>(deletes); + + std::promise<void> publishPromise; + CloudStorage::SetPendingPublish(accountId, appId, publishPromise.get_future().share()); + + std::thread([accountId, appId, publishCN, publishBuildId, + uploadMetaCopy, filePlatformsCopy, uploadsCopy, deletesCopy, + publishPromise = std::move(publishPromise)]() mutable { + CloudStorage::InflightSyncScope pubGuard; + if (!pubGuard.entered) { + publishPromise.set_value(); + return; + } - // Advance the local CN/manifest only now that the blobs are durable, so - // the fallback-serve manifest never references a not-yet-uploaded SHA. - { - auto syncMtx = CloudStorage::AcquireAppSyncMutex(accountId, appId); - std::lock_guard<std::mutex> lock(*syncMtx); - auto localManifest = CloudStorage::LoadLocalManifest(accountId, appId); - for (const auto& filename : *deletesCopy) - localManifest.erase(filename); - for (const auto& filename : *uploadsCopy) { - if (IsReservedBlobFilename(filename)) continue; - CloudStorage::ManifestEntry me; - auto metaIt = uploadMeta->find(filename); - if (metaIt != uploadMeta->end()) { - me.sha = metaIt->second.sha; - me.timestamp = metaIt->second.timestamp; - me.size = metaIt->second.size; - } else { - auto entry = LocalStorage::GetFileEntry(accountId, appId, filename); - if (!entry.has_value()) continue; - me.sha = entry->sha; - me.timestamp = entry->timestamp; - me.size = entry->rawSize; - } - localManifest[filename] = std::move(me); - } - LocalStorage::SetChangeNumber(accountId, appId, publishCN); - CloudStorage::SaveManifestLocal(accountId, appId, localManifest); - CloudStorage::SaveManifestSnapshot(accountId, appId, publishCN); - LOG("[NS] CompleteBatch(async) app=%u: local CN advanced to %llu + manifest saved " - "(blobs durable)", appId, (unsigned long long)publishCN); + // Files this batch uploaded are provider-confirmed durable (the promote's + // UploadBatch returned true, i.e. a 2xx per file). Pass them to the publish so + // it can skip the slow blob re-listing for them -- native trusts the same + // upload result and never re-lists. Deletes/reserved names are excluded; only + // carried-forward files (not in this set) still need a durability check. + std::unordered_set<std::string> confirmedDurable; + confirmedDurable.reserve(uploadsCopy->size()); + for (const auto& filename : *uploadsCopy) { + if (IsReservedBlobFilename(filename)) continue; + confirmedDurable.insert(filename); + } + for (const auto& filename : *deletesCopy) confirmedDurable.erase(filename); + + bool publishSucceeded = false; + constexpr int kMaxAttempts = 4; + constexpr int kBaseDelayMs = 2000; + for (int attempt = 1; attempt <= kMaxAttempts; ++attempt) { + if (attempt > 1) + std::this_thread::sleep_for( + std::chrono::milliseconds(kBaseDelayMs * (attempt - 1))); + auto syncMtx = CloudStorage::AcquireAppSyncMutex(accountId, appId); + std::lock_guard<std::mutex> lock(*syncMtx); + auto result = CloudStorage::FetchCloudState(accountId, appId); + // NotFound is the expected state for a brand-new app (no cloud state + // file yet) -- proceed with an empty base state so the first batch can + // create it. Only genuine transient failures (FetchFailed/ParseFailed/ + // Timeout) are retry-worthy; otherwise the first-ever batch could never + // publish (chicken-and-egg deadlock). + if (result.status != CloudStorage::StateFetchStatus::Ok && + result.status != CloudStorage::StateFetchStatus::NotFound) { + LOG("[NS] CompleteBatch(pub): publish %d/%d skipped for app %u: cloud fetch failed", + attempt, kMaxAttempts, appId); + continue; + } + CloudStorage::CloudAppState publishState = std::move(result.state); + if (result.status == CloudStorage::StateFetchStatus::NotFound) { + // Fresh app: start from an empty, CN-0 base; uploads layer on below. + publishState = CloudStorage::CloudAppState{}; + LOG("[NS] CompleteBatch(pub) app %u: no cloud state yet (NotFound); " + "publishing initial state for batch CN %llu", + appId, (unsigned long long)publishCN); + } + if (publishState.cn >= publishCN) { + LOG("[NS] CompleteBatch(pub): publish aborted for app %u: cloud CN %llu >= batch CN %llu", + appId, publishState.cn, publishCN); + publishSucceeded = true; + break; } - // Publish: re-fetch live state, CN-monotonic guard, then write. - constexpr int kMaxAttempts = 4; // 1 immediate + 3 backoff retries - constexpr int kBaseDelayMs = 2000; - for (int attempt = 1; attempt <= kMaxAttempts; ++attempt) { - if (attempt > 1) - std::this_thread::sleep_for( - std::chrono::milliseconds(kBaseDelayMs * (attempt - 1))); - // Re-fetch live state under sync mutex to preserve session changes. - auto syncMtx = CloudStorage::AcquireAppSyncMutex(accountId, appId); - std::lock_guard<std::mutex> lock(*syncMtx); - auto result = CloudStorage::FetchCloudState(accountId, appId); - if (result.status != CloudStorage::StateFetchStatus::Ok) { - // Cloud fetch failed; skip to avoid erasing session lock. - LOG("[NS] CompleteBatch(async): publish %d/%d skipped for app %u: cloud fetch failed", - attempt, kMaxAttempts, appId); - continue; - } - CloudStorage::CloudAppState publishState = std::move(result.state); - // Refuse if another device already committed at >= our CN. This is - // always a fresh commit (never a same-CN republish), so equality aborts. - if (publishState.cn >= publishCN) { - LOG("[NS] CompleteBatch(async): publish aborted for app %u: cloud CN %llu >= batch CN %llu", - appId, publishState.cn, publishCN); - return; - } - - // Rebuild the file list from the cloud base (or local manifest if - // behind) and apply this batch's deletes/uploads from captured meta. - uint64_t localCN = LocalStorage::GetChangeNumber(accountId, appId); - if (publishState.cn < localCN) { - LOG("[NS] CompleteBatch(async) app %u: cloud CN %llu < local CN %llu, " - "rebuilding file list from local manifest", - appId, (unsigned long long)publishState.cn, - (unsigned long long)localCN); - publishState.files.clear(); - auto localManifest = CloudStorage::LoadLocalManifest(accountId, appId); - for (const auto& [name, me] : localManifest) { - CloudStorage::FileEntry fe; - fe.sha = me.sha; - fe.timestamp = me.timestamp; - fe.size = me.size; - publishState.files[name] = std::move(fe); - } + uint64_t localCN = LocalStorage::GetChangeNumber(accountId, appId); + if (publishState.cn < localCN) { + LOG("[NS] CompleteBatch(pub) app %u: cloud CN %llu < local CN %llu, " + "rebuilding file list from local manifest", + appId, (unsigned long long)publishState.cn, (unsigned long long)localCN); + publishState.files.clear(); + auto localManifest = CloudStorage::LoadLocalManifest(accountId, appId); + for (const auto& [name, me] : localManifest) { + CloudStorage::FileEntry fe; + fe.sha = me.sha; + fe.timestamp = me.timestamp; + fe.size = me.size; + publishState.files[name] = std::move(fe); } + } - for (const auto& filename : *deletesCopy) - publishState.files.erase(filename); + for (const auto& filename : *deletesCopy) + publishState.files.erase(filename); - for (const auto& filename : *uploadsCopy) { - if (IsReservedBlobFilename(filename)) continue; - CloudStorage::FileEntry fe; - auto metaIt = uploadMeta->find(filename); - if (metaIt != uploadMeta->end()) { - fe.sha = metaIt->second.sha; - fe.timestamp = metaIt->second.timestamp; - fe.size = metaIt->second.size; - } else { - auto entry = LocalStorage::GetFileEntry(accountId, appId, filename); - if (!entry.has_value()) continue; - fe.sha = entry->sha; - fe.timestamp = entry->timestamp; - fe.size = entry->rawSize; - } - auto ptIt = filePlatforms->find(filename); - fe.platformsToSync = (ptIt != filePlatforms->end()) - ? ptIt->second : 0xFFFFFFFFu; - publishState.files[filename] = std::move(fe); + for (const auto& filename : *uploadsCopy) { + if (IsReservedBlobFilename(filename)) continue; + CloudStorage::FileEntry fe; + auto metaIt = uploadMetaCopy->find(filename); + if (metaIt != uploadMetaCopy->end()) { + fe.sha = metaIt->second.sha; + fe.timestamp = metaIt->second.timestamp; + fe.size = metaIt->second.size; + } else { + auto entry = LocalStorage::GetFileEntry(accountId, appId, filename); + if (!entry.has_value()) continue; + fe.sha = entry->sha; + fe.timestamp = entry->timestamp; + fe.size = entry->rawSize; } + auto ptIt = filePlatformsCopy->find(filename); + fe.platformsToSync = (ptIt != filePlatformsCopy->end()) + ? ptIt->second : 0xFFFFFFFFu; + publishState.files[filename] = std::move(fe); + } - // Capture the DEVELOPER's PICS quota into cloud state so it propagates - // to machines that can't read PICS (Linux KvInjector cache-null). - { - uint64_t q = 0; uint32_t f = 0; - if (publishState.quota.maxNumFiles == 0 && - SteamKvInjector::ReadAppQuota(appId, q, f) && f > 0 && q > 0 && - f != kFallbackMaxFiles) { - publishState.quota.quotaBytes = q; - publishState.quota.maxNumFiles = f; - publishState.quota.fetchedAtUnix = static_cast<uint64_t>(time(nullptr)); - publishState.quota.lastSeenBuildId = publishState.appBuildId; - LOG("[NS] CompleteBatch(async) app=%u: captured PICS quota=%llu files=%u into cloud state", - appId, (unsigned long long)q, f); - } + // Capture PICS quota into cloud state for cross-machine propagation. + { + uint64_t q = 0; uint32_t f = 0; + if (publishState.quota.maxNumFiles == 0 && + SteamKvInjector::ReadAppQuota(appId, q, f) && f > 0 && q > 0 && + f != kFallbackMaxFiles) { + publishState.quota.quotaBytes = q; + publishState.quota.maxNumFiles = f; + publishState.quota.fetchedAtUnix = static_cast<uint64_t>(time(nullptr)); + publishState.quota.lastSeenBuildId = publishState.appBuildId; + LOG("[NS] CompleteBatch(pub) app=%u: captured PICS quota=%llu files=%u", + appId, (unsigned long long)q, f); } + } - // Mirror native over-quota eviction before publishing (live KV cap, - // fall back to cloud-state PICS). Must run before PublishCloudState. - uint64_t evictBytes = publishState.quota.quotaBytes; - uint32_t evictFiles = publishState.quota.maxNumFiles; - { - uint64_t liveBytes = 0; uint32_t liveFiles = 0; - if (SteamKvInjector::ReadAppQuota(appId, liveBytes, liveFiles) && - liveFiles > 0) { - evictBytes = liveBytes; - evictFiles = liveFiles; - LOG("[NS] CompleteBatch(async) app=%u: eviction uses live KV cap " - "maxnumfiles=%u quota=%llu (what native's exit-walk sees)", - appId, evictFiles, (unsigned long long)evictBytes); - } else { - LOG("[NS] CompleteBatch(async) app=%u: live KV cap unreadable; eviction " - "falls back to cloud-state PICS maxnumfiles=%u", - appId, evictFiles); - } + // Native over-quota eviction before publishing. + uint64_t evictBytes = publishState.quota.quotaBytes; + uint32_t evictFiles = publishState.quota.maxNumFiles; + { + uint64_t liveBytes = 0; uint32_t liveFiles = 0; + if (SteamKvInjector::ReadAppQuota(appId, liveBytes, liveFiles) && + liveFiles > 0) { + evictBytes = liveBytes; + evictFiles = liveFiles; } - // Drop over-quota entries, but defer the local-blob deletes until the - // publish is durable (else a failed publish strands the manifest). - auto evicted = ApplyNativeOverQuotaEviction(accountId, appId, - publishState.files, - evictBytes, evictFiles); - if (!evicted.empty()) - LOG("[NS] CompleteBatch(async) app=%u: evicting %zu over-quota file(s) " - "from cloud set (local blobs dropped only after publish)", - appId, evicted.size()); - - publishState.cn = publishCN; - publishState.appBuildId = publishBuildId; - if (CloudStorage::PublishCloudState(accountId, appId, publishState)) { - LOG("[NS] CompleteBatch(async): publish %d/%d succeeded for app %u", - attempt, kMaxAttempts, appId); - // Now safe: drop the evicted local blobs and prune them from the - // manifest so the fallback-serve manifest stays consistent. - if (!evicted.empty()) { - auto m = CloudStorage::LoadLocalManifest(accountId, appId); - for (const auto& name : evicted) { - m.erase(name); - CloudStorage::DeleteBlobStaged(accountId, appId, name); - } - CloudStorage::SaveManifestLocal(accountId, appId, m); + } + auto evicted = ApplyNativeOverQuotaEviction(accountId, appId, + publishState.files, + evictBytes, evictFiles); + if (!evicted.empty()) + LOG("[NS] CompleteBatch(pub) app=%u: evicting %zu over-quota file(s)", + appId, evicted.size()); + + publishState.cn = publishCN; + publishState.appBuildId = publishBuildId; + if (CloudStorage::PublishCloudState(accountId, appId, publishState, + /*lockOnly=*/false, &confirmedDurable)) { + LOG("[NS] CompleteBatch(pub): publish %d/%d succeeded for app %u at CN=%llu", + attempt, kMaxAttempts, appId, (unsigned long long)publishCN); + if (!evicted.empty()) { + auto m = CloudStorage::LoadLocalManifest(accountId, appId); + for (const auto& name : evicted) { + m.erase(name); + CloudStorage::DeleteBlobStaged(accountId, appId, name); } - PendingOpsJournal::RecordUploadBatchEnd(accountId, appId); - // GC only after a durable publish, when the keep-set (cloud state) - // references the new SHAs -- earlier it could reclaim a live blob. - CloudStorage::GarbageCollectBlobs(accountId, appId); - return; + CloudStorage::SaveManifestLocal(accountId, appId, m); } - LOG("[NS] CompleteBatch(async): publish %d/%d failed for app %u", - attempt, kMaxAttempts, appId); + publishSucceeded = true; + PendingOpsJournal::RecordUploadBatchEnd(accountId, appId); + break; } - // Local CN/manifest committed and blobs durable -- record End (the next - // sync republishes from the local CN). + LOG("[NS] CompleteBatch(pub): publish %d/%d failed for app %u", + attempt, kMaxAttempts, appId); + } + + if (!publishSucceeded) { PendingOpsJournal::RecordUploadBatchEnd(accountId, appId); - LOG("[NS] CompleteBatch(async): all publish attempts exhausted for app %u; " - "remote state stale until next sync", appId); - }).detach(); - } + LOG("[NS] CompleteBatch(pub): all publish attempts exhausted for app %u", + appId); + } + // Resolve the barrier BEFORE GC — session release must not wait on housekeeping. + publishPromise.set_value(); + // GC is best-effort housekeeping; runs after the barrier is released. + if (publishSucceeded) + CloudStorage::GarbageCollectBlobs(accountId, appId); + }).detach(); - // The worker copied everything by value, so clearing the batch is safe now. BatchTracker_Clear(accountId, appId, batch.batchId); - // Only validation + token drain ran on Steam's thread; the upload/publish are - // off-thread, so this total should be a few ms. - LOG("[NS] CompleteBatch app=%u CN=%llu DONE: returned to Steam in %lldms total " - "(blob upload + publish + GC are async)", appId, newCN, elapsedMs()); - + LOG("[NS] CompleteBatch app=%u CN=%llu returned to Steam in %lldms " + "(publish deferred, barrier at session release)", + appId, (unsigned long long)newCN, elapsedMs()); ClearBatchCanonicalTokens(accountId, appId); - PB::Writer body; // empty response + PB::Writer body; return body; } diff --git a/src/platform/linux/cloud_hooks.cpp b/src/platform/linux/cloud_hooks.cpp index c6869640..a9c4c110 100644 --- a/src/platform/linux/cloud_hooks.cpp +++ b/src/platform/linux/cloud_hooks.cpp @@ -700,7 +700,13 @@ extern "C" int hook_NotificationDirect(void* pThis, const char* methodName, void if (accountId != 0) { PendingOpsJournal::RecordExitSyncState(accountId, appId, uploadsCompleted, uploadsRequired, clientId); - CloudStorage::ReleaseCloudSession(accountId, appId, clientId); + // Native-faithful: ExitSyncDone is fire-and-forget (notification, no + // response). Dispatch session release off Steam's thread. + std::thread([accountId, appId, clientId] { + CloudStorage::InflightSyncScope guard; + if (!guard.entered) return; // shutting down, skip session release + CloudStorage::ReleaseCloudSession(accountId, appId, clientId); + }).detach(); } LOG("[Hook-Notif] %s app=%u: letting Steam process internally", methodName, appId); return origFn(pThis, methodName, body, flags); diff --git a/src/platform/win/cloud_intercept.cpp b/src/platform/win/cloud_intercept.cpp index 3b37c0a5..7cb1df4a 100644 --- a/src/platform/win/cloud_intercept.cpp +++ b/src/platform/win/cloud_intercept.cpp @@ -13,6 +13,7 @@ #include "http_util.h" #include "local_storage.h" #include "cloud_storage.h" +#include "coop_yield.h" #include "cloud_provider.h" #include "pending_ops_journal.h" #include "json.h" @@ -108,11 +109,21 @@ static constexpr uint32_t CPROTOBUFMSG_OFF_CONN = 0x1C; // uint32_t connectio static constexpr uint32_t CPROTOBUFMSG_OFF_EMSG = 0x20; // uint32_t EMsg | PROTO_FLAG static constexpr uint32_t CPROTOBUFMSG_OFF_BODY = 0x30; // protobuf body object* -// g_pJobCur (qword_1397DC0C0): the CJob coroutine currently running; its CJobID -// is at CJob+32 (GetJobID, asserted at userremotestorage.cpp:3920). +// g_pJobCur (qword_1397E02C0): the CJob coroutine currently running; its CJobID +// is at CJob+32 (GetJobID, asserted at userremotestorage.cpp:3920). The yield +// primitives (CJobFuncs::YieldingWaitForFuncs, CJob::BYieldIfTimeSlice) hard-assert +// g_pJobCur != NULL, so it is only valid on the BMainLoop/job thread. static constexpr uintptr_t SC_RVA_JOBCUR_GLOBAL = 0x17E02C0; static constexpr uint32_t JOB_OFF_JOBID = 32; +// CJob::BYieldIfTimeSlice (sub_138CE8130): cooperative yield of the current job. +// bool fn(CJob* this /*== g_pJobCur*/, void* ctx /*0=no debug label*/, bool* outYielded); +// Sources the JobMgr from this->m_pJobMgr (CJob+8) itself, asserts this==g_pJobCur, +// and only actually yields when the job has held the cooperative slice >10ms (else a +// cheap no-op). Lets a long main-thread upload wait pump BMainLoop. Dependency is one +// function address + g_pJobCur -- a wrong RVA crashes clean (no struct-layout ABI). +static constexpr uintptr_t SC_RVA_YIELD_IF_TIMESLICE = 0xCE8130; + // Schema-fetch injection: build a CMsgClientGetUserStats (EMsg 818) and send it // via BAsyncSend, asking the server for the latest achievement schema of any app // (schema_local_version=-1) on behalf of an owning SteamID (steam_id_for_user). @@ -453,6 +464,29 @@ struct HookGuard { HookGuard& operator=(const HookGuard&) = delete; }; +// Re-entrancy probe (diagnostic, read-only): detects a slot-5 RPC firing on the +// same OS thread while CompleteBatch is mid-dispatch (the yield fix is only +// deadlock-safe if that never happens while g_queueMutex is held). +// g_reentCompleteInFlight : CompleteBatch calls currently dispatching +// g_reentCompleteThreadId : OS thread id of the most recent in-flight CompleteBatch +static std::atomic<int> g_reentCompleteInFlight{0}; +static std::atomic<uint32_t> g_reentCompleteThreadId{0}; + +struct CompleteBatchInFlightMark { + bool active = false; + explicit CompleteBatchInFlightMark(bool on) : active(on) { + if (active) { + g_reentCompleteThreadId.store((uint32_t)GetCurrentThreadId(), std::memory_order_release); + g_reentCompleteInFlight.fetch_add(1, std::memory_order_acq_rel); + } + } + ~CompleteBatchInFlightMark() { + if (active) g_reentCompleteInFlight.fetch_sub(1, std::memory_order_acq_rel); + } + CompleteBatchInFlightMark(const CompleteBatchInFlightMark&) = delete; + CompleteBatchInFlightMark& operator=(const CompleteBatchInFlightMark&) = delete; +}; + // namespace state (auto-detected from stplug-in directory) static std::unordered_set<uint32_t> g_namespaceApps; static std::mutex g_namespaceAppsMutex; @@ -737,6 +771,49 @@ static uint64_t GetJobIdSource(const std::vector<PB::Field>& header) { return f ? f->varintVal : JOBID_NONE; } +// Coroutine_IsActive(): the single predicate that decides whether yielding the +// current job coroutine is legal. Resolved by NAME from vstdlib_s64.dll (the module +// that actually implements the coroutine engine -- steamclient only imports it), so +// it survives address-layout changes across Steam updates. It reads the CALLING +// THREAD's coroutine context (active depth > 1); a yield is only safe when this is +// true. Independent of g_pJobCur (a process global): "on-fiber=1" was necessary but +// NOT sufficient, and the earlier yield corrupted the coroutine stack. Returns false +// (fail-closed: never yield) if unresolved. +typedef bool(__cdecl* CoroutineIsActiveFn)(); +static std::atomic<CoroutineIsActiveFn> g_coroutineIsActive{nullptr}; +static std::atomic<bool> g_coroutineIsActiveResolved{false}; + +static CoroutineIsActiveFn ResolveCoroutineIsActive() { + if (g_coroutineIsActiveResolved.load(std::memory_order_acquire)) + return g_coroutineIsActive.load(std::memory_order_acquire); + CoroutineIsActiveFn fn = nullptr; + HMODULE vstd = GetModuleHandleW(L"vstdlib_s64.dll"); + if (vstd) { + fn = reinterpret_cast<CoroutineIsActiveFn>( + GetProcAddress(vstd, "Coroutine_IsActive")); + } + g_coroutineIsActive.store(fn, std::memory_order_release); + g_coroutineIsActiveResolved.store(true, std::memory_order_release); + LOG("[CoopYield] Coroutine_IsActive resolved: %p (vstdlib=%p)", + (void*)fn, (void*)vstd); + return fn; +} + +// Safe wrapper: true only if the coroutine engine is resolved AND reports the +// current thread's coroutine is active (yieldable). SEH-isolated so a bad call +// can never escalate. Fails closed (returns false) on any uncertainty. +static bool CoroutineActiveNow() { + CoroutineIsActiveFn fn = ResolveCoroutineIsActive(); + if (!fn) return false; + bool active = false; + __try { + active = fn(); + } __except(EXCEPTION_EXECUTE_HANDLER) { + active = false; + } + return active; +} + // Jobid of the running coroutine. Correlates an outbound 818 with the 819 we // inject back: the framework stamps the header's jobid_source only after our send // hook, but the sending job already holds the id. SEH-isolated against a faulting @@ -750,6 +827,83 @@ static uint64_t ReadCurrentJobId() { return jobId; } +// Read-only probe: the raw g_pJobCur pointer (NOT the jobid). Native's fiber-yield +// primitives (CJobFuncs::YieldingWaitForFuncs) hard-require g_pJobCur != NULL, so a +// non-NULL value means a hook runs on the cooperative job fiber and could legally +// yield. Pure observation; no behavior change. +static uintptr_t ReadCurrentJobPtr() { + uintptr_t jobCur = 0; + __try { + jobCur = *(uintptr_t*)(g_steamClientBase + SC_RVA_JOBCUR_GLOBAL); + } __except(EXCEPTION_EXECUTE_HANDLER) { jobCur = 0; } + return jobCur; +} + +// CJob::BYieldIfTimeSlice signature (fastcall): (CJob* this, void* ctx, bool* out). +typedef bool(__fastcall* YieldIfTimeSliceFn)(void* job, void* ctx, bool* outYielded); + +// Leaf SEH helper: invoke the yield primitive, returning false if it faulted. +// SEH cannot live in a function that also needs C++ object unwinding, so this is +// kept separate from CooperativeYieldCurrentJob (which logs / clears the hook). +static bool InvokeYieldPrimitive(uintptr_t job) { + __try { + auto fn = (YieldIfTimeSliceFn)(g_steamClientBase + SC_RVA_YIELD_IF_TIMESLICE); + fn((void*)job, nullptr, nullptr); + return true; + } __except(EXCEPTION_EXECUTE_HANDLER) { + return false; + } +} + +// Cooperative yield of the current Steam job, registered as CoopYield's hook so the +// cross-platform UploadBatch wait can pump BMainLoop without freezing/crashing Steam. +// +// Safety: only yields when running on the BMainLoop/job thread (g_pJobCur != NULL). +// On a worker thread g_pJobCur is NULL -> skip (the native primitive would assert). +// A stale RVA after a Steam update fails closed (no yield, hook disabled) rather than +// corrupting anything. The primitive sources the JobMgr from the job itself and only +// suspends if the slice has exceeded 10ms, mirroring native cadence. +static void CooperativeYieldCurrentJob() { + if (!g_steamClientBase) return; + uintptr_t job = ReadCurrentJobPtr(); + if (!job) return; // not on the job thread (e.g. a worker) -> nothing to yield + // LOAD-BEARING GUARD: only yield when the calling thread's coroutine is genuinely + // active. g_pJobCur != NULL is NOT sufficient -- yielding an inactive coroutine + // corrupts its stack accounting (coroutine.cpp:434/1064) and crashes Steam. This + // check is the difference between a faithful cooperative yield and the prior crash. + if (!CoroutineActiveNow()) return; // not at a legal yield point -> skip safely + if (!InvokeYieldPrimitive(job)) { + LOG("[CoopYield] yield primitive faulted; disabling cooperative yield"); + // DisableYieldHook (not SetYieldHook(nullptr)): this runs from inside the hook + // itself, so reassigning g_hook here would destroy the executing std::function. + CoopYield::DisableYieldHook(); + } +} + +static bool LooksLikeFunctionPrologue(const uint8_t* p); // fwd decl (defined below) + +// Register CooperativeYieldCurrentJob as the CoopYield hook used by the cross-platform +// UploadBatch wait to keep BMainLoop pumping during a large blob promote (prevents the +// >15s frame watchdog / cross-thread pipe-stall crash on big saves). Prologue-gated so +// a stale RVA after a Steam update simply disables the yield (falling back to the old +// blocking wait) rather than jumping into garbage. Separate from the installer because +// it constructs a std::function (needs C++ unwinding) and so cannot share a function +// body with the installer's __try blocks (MSVC C2712). +static void RegisterCooperativeYieldHook() { + if (!g_steamClientBase) return; + auto yieldFn = reinterpret_cast<const uint8_t*>(g_steamClientBase + SC_RVA_YIELD_IF_TIMESLICE); + if (LooksLikeFunctionPrologue(yieldFn)) { + CoopYield::SetYieldHook(&CooperativeYieldCurrentJob); + LOG("[CoopYield] cooperative job-yield registered (sc+0x%llX)", + (unsigned long long)SC_RVA_YIELD_IF_TIMESLICE); + } else { + CoopYield::SetYieldHook(nullptr); + LOG("[CoopYield] WARNING: yield primitive prologue check failed at sc+0x%llX; " + "cooperative yield DISABLED (uploads will use the blocking wait)", + (unsigned long long)SC_RVA_YIELD_IF_TIMESLICE); + } +} + static std::vector<uint8_t> BuildPacket(uint32_t emsg, const PB::Writer& header, const PB::Writer& body) { uint32_t emsgRaw = emsg | PROTO_FLAG; uint32_t headerLen = (uint32_t)header.Size(); @@ -1962,6 +2116,42 @@ static bool __fastcall ServiceMethodHook(void* thisptr, const char* methodName, } } + // FIBER-YIELD FEASIBILITY PROBE (read-only): is this slot-5 hook running on + // the cooperative job fiber? If g_pJobCur is reliably non-null here, native's + // YieldingWaitForFuncs could legally be called to yield while a bg thread does + // the promote (the native-faithful CompleteBatch fix). Logged for the slow RPCs. + bool isCompleteBatch = (strcmp(methodName, RPC_COMPLETE_BATCH) == 0); + if (isCompleteBatch || strcmp(methodName, RPC_BEGIN_BATCH) == 0) { + uintptr_t jobPtr = ReadCurrentJobPtr(); + // coroutine-active is the DECISIVE fact: if true at handler entry, CR can + // faithfully yield the job coroutine (mirroring native YieldingWaitForFuncs) + // while its workers upload. If false, yield is illegal here regardless of + // g_pJobCur and a native-faithful cooperative upload is not reachable from + // this hook point. Logged here to settle it empirically against real saves. + bool coroActive = CoroutineActiveNow(); + LOG("[FiberProbe] %s app=%u: g_pJobCur=%p (on-fiber=%d) jobid=%llu coro-active=%d", + methodName, appId, (void*)jobPtr, jobPtr != 0 ? 1 : 0, ReadCurrentJobId(), + coroActive ? 1 : 0); + } + + // RE-ENTRANCY PROBE (read-only): if any slot-5 cloud RPC begins dispatching + // while a CompleteBatch is still in-flight, record whether the re-entry is on + // the same OS thread (deadlock-relevant for the yield fix) or a different one. + { + int inFlight = g_reentCompleteInFlight.load(std::memory_order_acquire); + if (inFlight > 0) { + uint32_t curTid = (uint32_t)GetCurrentThreadId(); + uint32_t cbTid = g_reentCompleteThreadId.load(std::memory_order_acquire); + LOG("[ReentProbe] %s app=%u began while %d CompleteBatch in-flight " + "(this-tid=%u completebatch-tid=%u same-thread=%d)", + methodName, appId, inFlight, curTid, cbTid, (curTid == cbTid) ? 1 : 0); + } + } + + // Mark CompleteBatch as in-flight for the duration of its dispatch (the slow + // promote/drain wait), so concurrent slot-5 entries can detect overlap above. + CompleteBatchInFlightMark cbMark(isCompleteBatch); + // Call the appropriate handler to build a response body auto dispatched = DispatchCloudRpc(methodName, realAppId, innerFields); if (!dispatched.has_value()) { @@ -2128,8 +2318,15 @@ static bool __fastcall NotificationWrapperHook(void* thisptr, const char* method if (accountId != 0) { PendingOpsJournal::RecordExitSyncState(accountId, realAppId, uploadsCompleted, uploadsRequired, clientId); - // Release cloud session lock -- server-faithful: sync done, release ownership. - CloudStorage::ReleaseCloudSession(accountId, realAppId, clientId); + // Native-faithful: ExitSyncDone is fire-and-forget (IDA slot 64, + // notification, no response). The client sends it and moves on; the + // server processes it asynchronously. Dispatch session release off + // Steam's thread so the UI doesn't freeze on Drive API calls. + std::thread([accountId, appId = realAppId, clientId] { + CloudStorage::InflightSyncScope guard; + if (!guard.entered) return; // shutting down, skip session release + CloudStorage::ReleaseCloudSession(accountId, appId, clientId); + }).detach(); } LOG("[VtHook-Notif] %s app=%u: letting Steam process internally", methodName, realAppId); return g_originalSlot8(thisptr, methodName, request); @@ -2633,6 +2830,10 @@ static void InstallServiceMethodHookLocked() { } else { LOG("[VtHook] WARNING: Some hooks failed! slot4=%d slot5=%d slot7=%d slot8=%d", slot4Ok, slot5Ok, slot7Ok, slot8Ok); } + + // Register the cooperative yield (kept in its own function: this one uses __try + // and so cannot also host std::function object unwinding -- MSVC C2712). + RegisterCooperativeYieldHook(); } // SEH helper: read vector header (can't mix SEH with C++ object unwinding) @@ -2858,8 +3059,7 @@ static int64_t __fastcall RecvPktMonitorHook(void* thisptr, CNetPacket* pkt) { } // Lua file sync: stplug-in/*.lua via account-scope LuaArchive.zip + LuaManifest.json (appId=0). -// Manifest entry: { "file.lua": { "mod": ts, "del": ts } }; del > mod = deleted (timestamps prevent ping-pong). -// .sync_state: line 1 = lastSyncTime, remaining lines = files this machine knows about. +// Union/grow-only: luas are only added or extracted, never deleted across machines. static constexpr uint32_t LUA_SYNC_APPID = 0; @@ -2872,7 +3072,7 @@ static std::string GetLuaSyncStatePath() { return g_steamPath + "config\\stplug-in\\.sync_state"; } -static SyncState ReadSyncState() { +[[maybe_unused]] static SyncState ReadSyncState() { SyncState state; std::ifstream f(FileUtil::Utf8ToPath(GetLuaSyncStatePath())); if (!f.is_open()) return state; @@ -3166,73 +3366,46 @@ static void SyncLuaFiles() { } } - auto syncState = ReadSyncState(); - uint64_t lastSync = syncState.lastSyncTime; - auto localFiles = ReadLocalLuaFiles(); std::unordered_map<std::string, uint64_t> localByName; // filename -> modTime for (auto& lf : localFiles) localByName[lf.filename] = lf.modTime; - int extracted = 0, deletedLocally = 0, addedToCloud = 0, markedDeleted = 0; + int extracted = 0, addedToCloud = 0; bool manifestChanged = false; + // Extract cloud luas missing locally; never delete or tombstone. for (auto& [filename, entry] : cloudManifest) { if (!IsValidLuaFilename(filename)) { LOG("[LuaSync] Skipping invalid manifest entry: %s", filename.c_str()); continue; } bool onDisk = localByName.count(filename) > 0; - bool inSyncState = syncState.files.count(filename) > 0; - - if (entry.isDeleted()) { - // Cloud says deleted - if (onDisk && lastSync > 0 && entry.del > lastSync) { - // Deleted on another machine after our last sync - delete locally - std::string path = luaDir + filename; - // Wide-API: DeleteFileA's ACP narrowing fails with ERROR_FILE_NOT_FOUND on non-ASCII installs, stranding the stale lua. - auto pathWide = FileUtil::Utf8ToPath(path).wstring(); - if (DeleteFileW(pathWide.c_str())) { - deletedLocally++; - localByName.erase(filename); - LOG("[LuaSync] Deleted locally (remote deletion): %s", filename.c_str()); - } - } - // If not on disk or deletion is old, no action - } else { - // Cloud says alive - if (!onDisk && !inSyncState) { - // New file for this machine - auto it = cloudFiles.find(filename); - if (it != cloudFiles.end()) { - std::error_code ec; - // Route via Utf8ToPath: create_directories on std::string narrows via ACP internally. - std::filesystem::create_directories(FileUtil::Utf8ToPath(luaDir), ec); - std::string destPath = luaDir + filename; - // Atomic-write so a crash never leaves a partial lua. - if (FileUtil::AtomicWriteBinary(destPath, it->second.data(), it->second.size())) { - localByName[filename] = entry.mod; - extracted++; - LOG("[LuaSync] Extracted new lua: %s (%zu bytes)", filename.c_str(), it->second.size()); - } else { - LOG("[LuaSync] Failed to extract lua %s", filename.c_str()); - } + + if (!onDisk) { + auto it = cloudFiles.find(filename); + if (it != cloudFiles.end()) { + std::error_code ec; + // Route via Utf8ToPath: create_directories on std::string narrows via ACP internally. + std::filesystem::create_directories(FileUtil::Utf8ToPath(luaDir), ec); + std::string destPath = luaDir + filename; + // Atomic-write so a crash never leaves a partial lua. + if (FileUtil::AtomicWriteBinary(destPath, it->second.data(), it->second.size())) { + localByName[filename] = entry.mod; + extracted++; + LOG("[LuaSync] Extracted new lua: %s (%zu bytes)", filename.c_str(), it->second.size()); + } else { + LOG("[LuaSync] Failed to extract lua %s", filename.c_str()); } - } else if (!onDisk && inSyncState) { - // User deleted locally - mark as deleted in cloud - entry.del = now; - markedDeleted++; - manifestChanged = true; - LOG("[LuaSync] User deleted %s, marking deleted in cloud", filename.c_str()); } } } - if (extracted > 0 || deletedLocally > 0) { + if (extracted > 0) { localFiles = ReadLocalLuaFiles(); localByName.clear(); for (auto& lf : localFiles) localByName[lf.filename] = lf.modTime; - if (extracted > 0) { + { for (auto& lf : localFiles) { auto dot = lf.filename.rfind('.'); if (dot != std::string::npos) { @@ -3255,23 +3428,17 @@ static void SyncLuaFiles() { } } + // Propagate local additions up; never remove or tombstone entries. for (auto& [filename, modTime] : localByName) { auto it = cloudManifest.find(filename); if (it == cloudManifest.end()) { cloudManifest[filename] = { modTime, 0 }; addedToCloud++; manifestChanged = true; - } else if (it->second.isDeleted()) { - // File exists locally but cloud says deleted - local wins (re-added) - it->second.mod = modTime; - it->second.del = 0; - addedToCloud++; - manifestChanged = true; - LOG("[LuaSync] Re-added %s (was deleted in cloud)", filename.c_str()); } } - bool needUpload = manifestChanged || extracted > 0 || deletedLocally > 0; + bool needUpload = manifestChanged || extracted > 0; if (!needUpload && cloudManifest.empty() && !localFiles.empty()) { needUpload = true; LOG("[LuaSync] Cloud empty, seeding %zu lua files", localFiles.size()); @@ -3280,24 +3447,30 @@ static void SyncLuaFiles() { } if (needUpload) { - // Build archive from alive files only - std::vector<LuaFile> aliveFiles; - for (auto& lf : localFiles) { - auto it = cloudManifest.find(lf.filename); - if (it != cloudManifest.end() && !it->second.isDeleted()) - aliveFiles.push_back(lf); + // Archive = union of cloud archive and local luas; local bytes win on collision. + std::unordered_map<std::string, std::vector<uint8_t>> unionFiles = cloudFiles; + for (auto& lf : localFiles) + unionFiles[lf.filename] = lf.content; + + std::vector<LuaFile> archiveFiles; + archiveFiles.reserve(unionFiles.size()); + for (auto& [filename, content] : unionFiles) { + if (cloudManifest.count(filename) == 0) continue; + LuaFile lf; + lf.filename = filename; + lf.content = content; + lf.modTime = cloudManifest[filename].mod; + archiveFiles.push_back(std::move(lf)); } - if (!aliveFiles.empty()) { - auto zipData = CreateLuaZip(aliveFiles); + if (!archiveFiles.empty()) { + auto zipData = CreateLuaZip(archiveFiles); if (!zipData.empty()) { CloudStorage::StoreBlob(accountId, LUA_SYNC_APPID, "LuaArchive.zip", zipData.data(), zipData.size()); - LOG("[LuaSync] Uploaded archive: %zu files, %zu bytes zip", - aliveFiles.size(), zipData.size()); + LOG("[LuaSync] Uploaded archive (union): %zu files, %zu bytes zip", + archiveFiles.size(), zipData.size()); } - } else { - CloudStorage::DeleteBlob(accountId, LUA_SYNC_APPID, "LuaArchive.zip"); } std::string manifestStr = SerializeManifest(cloudManifest); @@ -3311,12 +3484,11 @@ static void SyncLuaFiles() { } WriteSyncState(now, newFiles); - LOG("[LuaSync] Sync complete: %d extracted, %d deleted locally, %d added to cloud, %d marked deleted", - extracted, deletedLocally, addedToCloud, markedDeleted); + LOG("[LuaSync] Sync complete: %d extracted, %d added to cloud (union, grow-only)", + extracted, addedToCloud); } -// Shutdown upload: captures local changes (additions + deletions) to cloud. -// Downloads manifest first to detect local deletions and set timestamps. +// Shutdown upload: propagate local lua additions only; never tombstone (grow-only). static void UploadLuaOnShutdown() { if (!CloudStorage::IsCloudActive()) return; uint32_t accountId = GetAccountId(); @@ -3324,75 +3496,96 @@ static void UploadLuaOnShutdown() { uint64_t now = NowUnix(); - // Download current cloud manifest to compare against auto manifestData = CloudStorage::RetrieveBlob(accountId, LUA_SYNC_APPID, "LuaManifest.json"); auto cloudManifest = ParseManifest(manifestData); + bool hasCloudAlive = false; + for (auto& [f, e] : cloudManifest) { if (!e.isDeleted()) { hasCloudAlive = true; break; } } + std::unordered_map<std::string, std::vector<uint8_t>> cloudFiles; + if (hasCloudAlive) { + auto archiveData = CloudStorage::RetrieveBlob(accountId, LUA_SYNC_APPID, "LuaArchive.zip"); + if (!archiveData.empty()) { + mz_zip_archive zip{}; + if (mz_zip_reader_init_mem(&zip, archiveData.data(), archiveData.size(), 0)) { + mz_uint numFiles = mz_zip_reader_get_num_files(&zip); + for (mz_uint i = 0; i < numFiles && numFiles <= 10000; i++) { + mz_uint nameLenPlusNul = mz_zip_reader_get_filename(&zip, i, nullptr, 0); + if (nameLenPlusNul == 0 || nameLenPlusNul > 256) continue; + char fname[256]; + mz_zip_reader_get_filename(&zip, i, fname, sizeof(fname)); + if (!IsValidLuaFilename(fname)) continue; + size_t uncompSize = 0; + void* p = mz_zip_reader_extract_to_heap(&zip, i, &uncompSize, 0); + if (p) { + if (IsValidLuaContent(static_cast<uint8_t*>(p), uncompSize)) + cloudFiles[fname] = std::vector<uint8_t>( + static_cast<uint8_t*>(p), static_cast<uint8_t*>(p) + uncompSize); + mz_free(p); + } + } + mz_zip_reader_end(&zip); + } + } + } + auto localFiles = ReadLocalLuaFiles(); std::unordered_map<std::string, uint64_t> localByName; for (auto& lf : localFiles) localByName[lf.filename] = lf.modTime; bool changed = false; - // Mark cloud-alive files that are no longer on disk as deleted - for (auto& [filename, entry] : cloudManifest) { - if (!entry.isDeleted() && localByName.count(filename) == 0) { - entry.del = now; - changed = true; - LOG("[LuaSync] Shutdown: marking %s as deleted", filename.c_str()); - } - } - - // Add new local files + // Add new local files to the manifest; never tombstone. for (auto& [filename, modTime] : localByName) { auto it = cloudManifest.find(filename); if (it == cloudManifest.end()) { cloudManifest[filename] = { modTime, 0 }; changed = true; - } else if (it->second.isDeleted()) { - it->second.mod = modTime; - it->second.del = 0; - changed = true; } } if (!changed && !cloudManifest.empty()) { LOG("[LuaSync] Shutdown: no changes to upload"); - // Still update sync state time std::unordered_set<std::string> newFiles; for (auto& [f, e] : cloudManifest) { if (!e.isDeleted()) newFiles.insert(f); } WriteSyncState(now, newFiles); return; } - // Upload archive (alive files only) - std::vector<LuaFile> aliveFiles; - for (auto& lf : localFiles) { - auto it = cloudManifest.find(lf.filename); - if (it != cloudManifest.end() && !it->second.isDeleted()) - aliveFiles.push_back(lf); + // Archive = union of cloud archive and local luas. + std::unordered_map<std::string, std::vector<uint8_t>> unionFiles = cloudFiles; + for (auto& lf : localFiles) + unionFiles[lf.filename] = lf.content; + + std::vector<LuaFile> archiveFiles; + archiveFiles.reserve(unionFiles.size()); + for (auto& [filename, content] : unionFiles) { + auto it = cloudManifest.find(filename); + if (it == cloudManifest.end() || it->second.isDeleted()) continue; + LuaFile lf; + lf.filename = filename; + lf.content = content; + lf.modTime = it->second.mod; + archiveFiles.push_back(std::move(lf)); } - if (!aliveFiles.empty()) { - auto zipData = CreateLuaZip(aliveFiles); + if (!archiveFiles.empty()) { + auto zipData = CreateLuaZip(archiveFiles); if (!zipData.empty()) { CloudStorage::StoreBlob(accountId, LUA_SYNC_APPID, "LuaArchive.zip", zipData.data(), zipData.size()); } } - // Upload manifest (includes deletion markers) std::string manifestStr = SerializeManifest(cloudManifest); CloudStorage::StoreBlob(accountId, LUA_SYNC_APPID, "LuaManifest.json", reinterpret_cast<const uint8_t*>(manifestStr.data()), manifestStr.size()); - // Update sync state std::unordered_set<std::string> newFiles; for (auto& [f, e] : cloudManifest) { if (!e.isDeleted()) newFiles.insert(f); } WriteSyncState(now, newFiles); - LOG("[LuaSync] Shutdown upload: %zu alive, %zu total manifest entries", - localFiles.size(), cloudManifest.size()); + LOG("[LuaSync] Shutdown upload (union): %zu archived, %zu total manifest entries", + archiveFiles.size(), cloudManifest.size()); } // Supported Steam client versions - patches and RVAs are only valid for these builds. Index 0 is the newest. diff --git a/src/platform/win/cr_api.cpp b/src/platform/win/cr_api.cpp index 45539748..fdc502bf 100644 --- a/src/platform/win/cr_api.cpp +++ b/src/platform/win/cr_api.cpp @@ -1,6 +1,7 @@ #define CR_API_EXPORTS #include "cr_api.h" #include "cloud_intercept.h" +#include "cloud_storage.h" #include "rpc_handlers.h" #include "protobuf.h" #include "pending_ops_journal.h" @@ -12,6 +13,7 @@ #include <atomic> #include <mutex> #include <string> +#include <thread> static std::mutex g_crInitMutex; static std::atomic<bool> g_crInitDone{false}; @@ -96,7 +98,11 @@ bool CR_HandleCloudRpc(const char* method, uint32_t appId, if (accountId != 0) { PendingOpsJournal::RecordExitSyncState(accountId, appId, uploadsCompleted, uploadsRequired, clientId); - CloudStorage::ReleaseCloudSession(accountId, appId, clientId); + std::thread([accountId, appId, clientId] { + CloudStorage::InflightSyncScope guard; + if (!guard.entered) return; + CloudStorage::ReleaseCloudSession(accountId, appId, clientId); + }).detach(); } LOG("[CR_API] ExitSyncDone app=%u", appId); } diff --git a/src/providers/google_drive.cpp b/src/providers/google_drive.cpp index f403f7a2..e9eed92b 100644 --- a/src/providers/google_drive.cpp +++ b/src/providers/google_drive.cpp @@ -925,11 +925,16 @@ bool GoogleDriveProvider::UploadBatch(const std::vector<UploadItem>& items) { // safe: per-call request handles, distinct CAS paths, m_folderMtx-guarded folders. if (items.empty()) return true; + // Per-batch throughput telemetry: wall time + rate-limit hit delta. + auto batchStart = std::chrono::steady_clock::now(); + uint64_t rlBefore = g_rateLimitHits.load(std::memory_order_relaxed); + static constexpr size_t kMaxParallel = 10; // native @nClientCloudMaxNumParallelUploads static constexpr uint64_t kMaxBytesInFlight = 64ull << 20; // native @nClientCloudMaxMBParallelUploads (64 MB) std::atomic<size_t> next{0}; std::atomic<bool> failed{false}; + std::atomic<size_t> dedupSkips{0}; // CAS-existing blobs skipped in-worker std::mutex doneMtx; std::vector<std::string> uploadedPaths; // for rollback, guarded by doneMtx @@ -944,7 +949,21 @@ bool GoogleDriveProvider::UploadBatch(const std::vector<UploadItem>& items) { // here (bad_alloc is likelier with up to 10 buffers in flight). bool ok = false; try { - ok = Upload(item.path, item.data.data(), item.data.size()); + // CAS dedup IN the parallel worker (native-faithful: mirrors + // ClientBeginFileUpload's per-file EResult-29 server short-circuit, + // IDA EYieldingUploadFile sub_1389119A0). Skip only on a definite + // Exists; on Missing OR Error, upload (idempotent CAS path, so + // treating an errored check as "must upload" never strands a blob). + if (CheckExists(item.path) == ExistsStatus::Exists) { + dedupSkips.fetch_add(1, std::memory_order_relaxed); + ok = true; // already durable; nothing to roll back + } else { + ok = Upload(item.path, item.data.data(), item.data.size()); + if (ok) { + std::lock_guard<std::mutex> lk(doneMtx); + uploadedPaths.push_back(item.path); + } + } } catch (const std::exception& e) { LOG("[GDriveProvider] UploadBatch: worker threw on '%s': %s", item.path.c_str(), e.what()); @@ -957,8 +976,6 @@ bool GoogleDriveProvider::UploadBatch(const std::vector<UploadItem>& items) { LOG("[GDriveProvider] UploadBatch: failed to upload '%s'", item.path.c_str()); return; } - std::lock_guard<std::mutex> lk(doneMtx); - uploadedPaths.push_back(item.path); } }; @@ -981,6 +998,10 @@ bool GoogleDriveProvider::UploadBatch(const std::vector<UploadItem>& items) { std::vector<std::thread> pool; pool.reserve(workerCount); + + // Plain spawn-then-join worker pool. BMainLoop responsiveness is handled by the + // CompleteBatch handler (PumpUntil at the active-coroutine level), not here -- the + // coroutine is inactive this deep, so a yield would corrupt it. for (size_t t = 0; t < workerCount; ++t) pool.emplace_back(worker); for (auto& th : pool) th.join(); @@ -990,8 +1011,16 @@ bool GoogleDriveProvider::UploadBatch(const std::vector<UploadItem>& items) { for (const auto& path : uploadedPaths) Remove(path); return false; } - LOG("[GDriveProvider] UploadBatch: uploaded %zu file(s) with %zu parallel worker(s)", - items.size(), workerCount); + double elapsedSec = std::chrono::duration<double>( + std::chrono::steady_clock::now() - batchStart).count(); + uint64_t rlHits = g_rateLimitHits.load(std::memory_order_relaxed) - rlBefore; + double filesPerSec = elapsedSec > 0 ? items.size() / elapsedSec : 0.0; + double mbPerSec = elapsedSec > 0 ? (totalBytes / (1024.0 * 1024.0)) / elapsedSec : 0.0; + LOG("[GDriveProvider] UploadBatch: %zu file(s) (%zu uploaded, %zu CAS-skipped) " + "with %zu parallel worker(s) in %.1fs (%.2f files/s, %.2f MB/s, %llu rate-limit hits)", + items.size(), items.size() - dedupSkips.load(), dedupSkips.load(), + workerCount, elapsedSec, filesPerSec, mbPerSec, + (unsigned long long)rlHits); return true; } diff --git a/src/providers/onedrive.cpp b/src/providers/onedrive.cpp index f9d99bf0..a973d141 100644 --- a/src/providers/onedrive.cpp +++ b/src/providers/onedrive.cpp @@ -520,6 +520,7 @@ bool OneDriveProvider::Upload(const std::string& path, bool OneDriveProvider::UploadBatch(const std::vector<UploadItem>& items) { if (items.empty()) return true; if (items.size() == 1) { + if (CheckExists(items[0].path) == ExistsStatus::Exists) return true; return Upload(items[0].path, items[0].data.data(), items[0].data.size()); } @@ -531,7 +532,15 @@ bool OneDriveProvider::UploadBatch(const std::vector<UploadItem>& items) { // Upload sequentially with rollback on failure. std::vector<std::string> uploaded; + size_t dedupSkips = 0; for (const auto& item : items) { + // Per-file CAS dedup (PromoteStagedBatchForCommit no longer pre-filters). + // Skip only on a definite Exists; on Missing OR Error, upload -- the CAS path + // is idempotent, so an errored check never strands a blob. + if (CheckExists(item.path) == ExistsStatus::Exists) { + ++dedupSkips; + continue; + } if (!Upload(item.path, item.data.data(), item.data.size())) { LOG("[OneDriveProvider] UploadBatch: failed on '%s', rolling back %zu uploads", item.path.c_str(), uploaded.size()); @@ -540,7 +549,8 @@ bool OneDriveProvider::UploadBatch(const std::vector<UploadItem>& items) { } uploaded.push_back(item.path); } - LOG("[OneDriveProvider] UploadBatch: uploaded %zu files", items.size()); + LOG("[OneDriveProvider] UploadBatch: %zu file(s) (%zu uploaded, %zu CAS-skipped)", + items.size(), uploaded.size(), dedupSkips); return true; } From c23ce35146166ecc1b63f926eac1df3dbb6f9c8a Mon Sep 17 00:00:00 2001 From: Selectively11 <selectively11@users.noreply.github.com> Date: Sun, 21 Jun 2026 01:04:37 -0400 Subject: [PATCH 22/24] 2.2.0 Test 11 --- .gitignore | 1 + CMakeLists.txt | 44 +- Version.props | 2 +- ...g.cloudredirect.CloudRedirect.metainfo.xml | 1 + flatpak/org.cloudredirect.CloudRedirect.yml | 4 +- src/common/app_state.cpp | 127 ++- src/common/app_state.h | 6 +- src/common/autocloud_scan.cpp | 11 +- src/common/autocloud_scan.h | 1 - src/common/cli.cpp | 47 +- src/common/cloud_provider_base.cpp | 36 +- src/common/cloud_provider_base.h | 4 + src/common/cloud_storage.cpp | 275 ++++--- src/common/cloud_storage.h | 12 +- src/common/cloud_work_queue.cpp | 87 +- src/common/cloud_work_queue.h | 8 + src/common/manifest_store.cpp | 7 + src/common/metadata_sync.cpp | 5 +- src/common/metadata_sync.h | 23 +- src/common/rpc_handlers.cpp | 109 ++- src/common/stats_handlers.cpp | 30 +- src/common/stats_store.cpp | 483 +++++++++-- src/common/stats_store.h | 32 +- src/common/steam_kv_injector.cpp | 83 +- src/common/steam_kv_injector.h | 5 + src/platform/linux/achievement_inject.cpp | 66 +- src/platform/linux/achievement_inject.h | 9 +- src/platform/linux/cloud_hooks.cpp | 90 ++- src/platform/linux/gamesplayed_hook.cpp | 62 +- src/platform/linux/gamesplayed_hook.h | 22 +- src/platform/linux/http_server.cpp | 12 +- src/platform/linux/init.cpp | 168 +++- src/platform/linux/live_playtime.cpp | 124 ++- src/platform/linux/recvpkt_hook.cpp | 249 ++++++ src/platform/linux/recvpkt_hook.h | 16 + src/platform/linux/schema_fetch.cpp | 630 +++++++++++++++ src/platform/linux/schema_fetch.h | 30 + src/platform/linux/vtable_hook.cpp | 114 +++ src/platform/linux/vtable_hook.h | 10 + src/platform/win/cloud_intercept.cpp | 760 +++++++++++++++--- src/platform/win/http_server.cpp | 23 +- src/platform/win/http_transport_win.cpp | 11 + src/platform/win/log.cpp | 2 +- src/providers/google_drive.cpp | 54 +- ui-linux/CMakeLists.txt | 8 +- ui-linux/src/backend.cpp | 37 +- ui/MainWindow.xaml | 8 +- ui/MainWindow.xaml.cs | 3 +- ui/Pages/ChoiceModePage.xaml | 2 +- ui/Pages/ChoiceModePage.xaml.cs | 163 +--- ui/Pages/Cloud760Page.xaml | 4 +- ui/Pages/Cloud760Page.xaml.cs | 3 - ui/Pages/CloudProviderPage.xaml | 74 +- ui/Pages/CloudProviderPage.xaml.cs | 133 ++- ui/Pages/SettingsPage.xaml | 210 +++-- ui/Pages/SettingsPage.xaml.cs | 219 +++-- ui/Pages/StatsPage.xaml | 18 +- ui/Resources/Strings.es.resx | 233 ++++-- ui/Resources/Strings.pt-BR.resx | 233 ++++-- ui/Resources/Strings.resx | 139 ++-- ui/Services/ModeService.cs | 165 ++++ ui/Services/Steam760Cloud.cs | 5 + ui/Services/SteamDetector.cs | 51 +- ui/Windows/DisclaimerWindow.xaml | 222 +++-- ui/Windows/DisclaimerWindow.xaml.cs | 62 +- 65 files changed, 4501 insertions(+), 1386 deletions(-) create mode 100644 src/platform/linux/recvpkt_hook.cpp create mode 100644 src/platform/linux/recvpkt_hook.h create mode 100644 src/platform/linux/schema_fetch.cpp create mode 100644 src/platform/linux/schema_fetch.h create mode 100644 ui/Services/ModeService.cs diff --git a/.gitignore b/.gitignore index 2dfbe2ed..718d1c46 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ build/ build-*/ +dist/ ui/bin/ ui/obj/ ui/publish/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 56dad663..b88dc35f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,10 +9,13 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON) file(READ "${CMAKE_SOURCE_DIR}/Version.props" VERSION_PROPS_CONTENT) string(REGEX MATCH "<ReleaseVersion>([^<]+)</ReleaseVersion>" _ "${VERSION_PROPS_CONTENT}") set(CR_RELEASE_VERSION "${CMAKE_MATCH_1}") +string(REGEX MATCH "<ReleasePrerelease>([^<]*)</ReleasePrerelease>" _ "${VERSION_PROPS_CONTENT}") +set(CR_PRERELEASE "${CMAKE_MATCH_1}") if(NOT CR_RELEASE_VERSION) set(CR_RELEASE_VERSION "0.0.0") endif() +set(CR_RELEASE_VERSION "${CR_RELEASE_VERSION}${CR_PRERELEASE}") # ── Generate version string with git SHA ──────────────────────────────── # Release builds may pass -DCR_GIT_SHA to pin the build id (e.g. when the build @@ -110,6 +113,8 @@ else() src/platform/linux/gamesplayed_hook.cpp src/platform/linux/live_playtime.cpp src/platform/linux/achievement_inject.cpp + src/platform/linux/schema_fetch.cpp + src/platform/linux/recvpkt_hook.cpp src/platform/linux/http_transport_linux.cpp src/platform/linux/token_store_linux.cpp src/platform/linux/log.cpp @@ -210,6 +215,7 @@ endif() enable_testing() if(EXISTS ${CMAKE_SOURCE_DIR}/tests/autocloud_native_tests.cpp) + add_compile_definitions(CR_RELEASE_VERSION="${CR_RELEASE_VERSION}") if(WIN32) add_executable(autocloud_native_tests tests/autocloud_native_tests.cpp @@ -273,6 +279,7 @@ if(EXISTS ${CMAKE_SOURCE_DIR}/tests/autocloud_native_tests.cpp) src/common/legacy_metadata_cleanup.cpp src/common/manifest_store.cpp src/common/app_state.cpp + src/common/coop_yield.cpp src/common/token_store.cpp src/common/cloud_staging.cpp src/common/pending_ops_journal.cpp @@ -347,15 +354,50 @@ if(EXISTS ${CMAKE_SOURCE_DIR}/tests/autocloud_native_tests.cpp) ) target_include_directories(vdf_tests PRIVATE src/common) add_test(NAME vdf_tests COMMAND vdf_tests) + + add_executable(stats_store_tests + tests/stats_store_tests.cpp + src/common/stats_store.cpp + src/common/json.cpp + src/common/metadata_sync.cpp + src/common/vdf.cpp + ) + target_include_directories(stats_store_tests PRIVATE src/common) + if(WIN32) + target_include_directories(stats_store_tests PRIVATE src/platform/win) + target_sources(stats_store_tests PRIVATE + src/platform/win/log.cpp + src/platform/win/platform_win.cpp + ) + target_link_libraries(stats_store_tests PRIVATE Shlwapi Advapi32 Crypt32 Shell32 Ole32) + else() + target_include_directories(stats_store_tests PRIVATE src/platform/linux) + target_sources(stats_store_tests PRIVATE + src/platform/linux/log.cpp + src/platform/linux/platform_linux.cpp + src/platform/linux/file_util.cpp + ) + target_link_libraries(stats_store_tests PRIVATE pthread dl) + endif() + add_test(NAME stats_store_tests COMMAND stats_store_tests) endif() # ── UI (Windows only) ─────────────────────────────────────────────────── if(WIN32) + # The csproj expects the DLL/CLI at ../build/Release/; copy from the actual + # build output dir so out-of-tree builds (build-win) work correctly. add_custom_target(ui ALL + COMMAND ${CMAKE_COMMAND} -E make_directory "${CMAKE_SOURCE_DIR}/build/Release" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$<TARGET_FILE:cloud_redirect>" + "${CMAKE_SOURCE_DIR}/build/Release/cloud_redirect.dll" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$<TARGET_FILE:cloud_redirect_cli>" + "${CMAKE_SOURCE_DIR}/build/Release/cloud_redirect_cli.exe" COMMAND dotnet publish -c Release -r win-x64 --self-contained false -o bin/publish WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/ui COMMENT "Publishing CloudRedirect UI" - DEPENDS cloud_redirect + DEPENDS cloud_redirect cloud_redirect_cli ) endif() diff --git a/Version.props b/Version.props index 8955604b..81bf77c2 100644 --- a/Version.props +++ b/Version.props @@ -5,7 +5,7 @@ <!-- Optional pre-release suffix (e.g. -TEST1, -beta). Empty for stable releases. Appended to the user-facing version shown in Settings; AssemblyVersion stays numeric. --> - <ReleasePrerelease>-TEST2</ReleasePrerelease> + <ReleasePrerelease>-TEST11</ReleasePrerelease> <!-- Sync engine generation - increment on breaking protocol changes --> <CoreGeneration>1.0</CoreGeneration> diff --git a/flatpak/org.cloudredirect.CloudRedirect.metainfo.xml b/flatpak/org.cloudredirect.CloudRedirect.metainfo.xml index cb93e176..ab1e721f 100644 --- a/flatpak/org.cloudredirect.CloudRedirect.metainfo.xml +++ b/flatpak/org.cloudredirect.CloudRedirect.metainfo.xml @@ -38,6 +38,7 @@ <content_rating type="oars-1.1" /> <releases> + <release version="2.2.0" date="2026-06-16" /> <release version="2.1.5" date="2026-06-04" /> <release version="2.1.2" date="2026-06-02" /> <release version="2.0.4" date="2026-05-15" /> diff --git a/flatpak/org.cloudredirect.CloudRedirect.yml b/flatpak/org.cloudredirect.CloudRedirect.yml index 1822163d..90684ae6 100644 --- a/flatpak/org.cloudredirect.CloudRedirect.yml +++ b/flatpak/org.cloudredirect.CloudRedirect.yml @@ -44,8 +44,10 @@ modules: buildsystem: cmake-ninja no-debuginfo: true config-opts: + # Version is read from Version.props (copied into the sandbox below) by + # ui-linux/CMakeLists.txt. Do NOT hardcode CR_RELEASE_VERSION here -- it + # overrides Version.props and drifts out of sync. - -DCMAKE_BUILD_TYPE=Release - - -DCR_RELEASE_VERSION=2.2.0-TEST2 sources: - type: dir path: ../ui-linux diff --git a/src/common/app_state.cpp b/src/common/app_state.cpp index 232b1928..737ed2ae 100644 --- a/src/common/app_state.cpp +++ b/src/common/app_state.cpp @@ -70,37 +70,64 @@ inline int64_t NowMs() { // can acquire. BeginBatch's FetchCloudStateForServe also drains it so the next batch // sees the fresh cloud CN. namespace { +struct PendingPublishEntry { + uint64_t generation = 0; + std::shared_future<void> fut; +}; std::mutex& g_pendingPublishMtx = *new std::mutex(); -std::unordered_map<uint64_t, std::shared_future<void>>& g_pendingPublish = - *new std::unordered_map<uint64_t, std::shared_future<void>>(); +std::unordered_map<uint64_t, PendingPublishEntry>& g_pendingPublish = + *new std::unordered_map<uint64_t, PendingPublishEntry>(); +uint64_t g_pendingPublishGen = 0; } // namespace void SetPendingPublish(uint32_t accountId, uint32_t appId, std::shared_future<void> fut) { std::lock_guard<std::mutex> lk(g_pendingPublishMtx); - g_pendingPublish[ServeCacheKey(accountId, appId)] = std::move(fut); + PendingPublishEntry entry; + entry.generation = ++g_pendingPublishGen; + entry.fut = std::move(fut); + g_pendingPublish[ServeCacheKey(accountId, appId)] = std::move(entry); } void WaitForPendingPublish(uint32_t accountId, uint32_t appId) { - std::shared_future<void> fut; - { - std::lock_guard<std::mutex> lk(g_pendingPublishMtx); - auto it = g_pendingPublish.find(ServeCacheKey(accountId, appId)); - if (it == g_pendingPublish.end()) return; - fut = it->second; - } - if (fut.valid()) { - // Runs on BMainLoop (BeginBatch handler); a hard fut.wait() here starved the - // frame watchdog while a prior batch's publish held its barrier. Pump the job - // coroutine instead, polling with wait_for(0). Degrades to a plain spin off Steam. - CoopYield::PumpUntil([&fut]() { - return fut.wait_for(std::chrono::seconds(0)) == - std::future_status::ready; - }); - } - { - std::lock_guard<std::mutex> lk(g_pendingPublishMtx); - g_pendingPublish.erase(ServeCacheKey(accountId, appId)); + const uint64_t key = ServeCacheKey(accountId, appId); + // Loop: PumpUntil yields the job coroutine, so another CompleteBatch for this + // same app can run while we wait and replace the map entry with a newer barrier. + // We must wait on (and only erase) the exact future we observed -- never blindly + // erase, or we'd drop a newer publish barrier and release the session lock while + // that publish is still in flight (stale cloud state for the next machine). + while (true) { + std::shared_future<void> fut; + uint64_t gen = 0; + { + std::lock_guard<std::mutex> lk(g_pendingPublishMtx); + auto it = g_pendingPublish.find(key); + if (it == g_pendingPublish.end()) return; + fut = it->second.fut; + gen = it->second.generation; + } + if (fut.valid()) { + // Runs on BMainLoop (BeginBatch handler); a hard fut.wait() here starved the + // frame watchdog while a prior batch's publish held its barrier. Pump the job + // coroutine instead, polling with wait_for(0). Degrades to a plain spin off Steam. + CoopYield::PumpUntil([&fut]() { + return fut.wait_for(std::chrono::seconds(0)) == + std::future_status::ready; + }); + } + { + std::lock_guard<std::mutex> lk(g_pendingPublishMtx); + auto it = g_pendingPublish.find(key); + // Only erase if the map still holds the same barrier we just waited on. + // A newer CompleteBatch that ran during our yield will have bumped the + // generation; loop again to wait on that one before returning. + if (it == g_pendingPublish.end()) return; + if (it->second.generation != gen) { + continue; // a newer barrier appeared; wait on it too + } + g_pendingPublish.erase(it); + return; + } } } @@ -310,7 +337,10 @@ bool DeserializeState(const std::string& json, CloudAppState& outState) { static constexpr const char* kStateFilename = "state.cloudredirect"; static constexpr size_t MAX_STATE_SIZE = 16 * 1024 * 1024; // 16 MB -static StateFetchResult FetchCloudStateLive(uint32_t accountId, uint32_t appId) { +// allowLegacyMigration=false reads canonical state without migration side effects; +// used by PublishCloudState's CN-regression re-check to avoid recursive migration. +static StateFetchResult FetchCloudStateLive(uint32_t accountId, uint32_t appId, + bool allowLegacyMigration = true) { InflightSyncScope guard; if (!guard) return { StateFetchStatus::FetchFailed, {} }; if (!g_stateProvider || !g_stateProvider->IsAuthenticated()) @@ -339,6 +369,12 @@ static StateFetchResult FetchCloudStateLive(uint32_t accountId, uint32_t appId) auto existsStatus = g_stateProvider->CheckExists(statePath); if (existsStatus == ICloudProvider::ExistsStatus::Missing) { + // No canonical state: nothing to migrate from when the caller forbids it + // (e.g. PublishCloudState's regression re-check). Absent state means there + // is no newer cloud CN to regress against, so report NotFound. + if (!allowLegacyMigration) { + return { StateFetchStatus::NotFound, {} }; + } auto legacyResult = FetchCloudManifest(accountId, appId); uint64_t legacyCN = 0; @@ -444,8 +480,10 @@ static std::unordered_set<uint64_t>& g_boundedInflightKeys = *new std::unordered_set<uint64_t>(); // apps with a live worker static std::atomic<int> g_boundedWorkerCount{0}; static std::atomic<int64_t> g_providerSlowUntilMs{0}; // circuit-breaker deadline +static std::atomic<int> g_consecutiveTimeouts{0}; // reset on any successful fetch static constexpr int kMaxBoundedWorkers = 4; -static constexpr int kCircuitCooldownMs = 30000; // serve-local window after a timeout +static constexpr int kCircuitCooldownMs = 8000; // serve-local window once circuit opens +static constexpr int kCircuitTripThreshold = 2; // consecutive timeouts before opening StateFetchResult FetchCloudStateBounded(uint32_t accountId, uint32_t appId, int deadlineMs) { @@ -496,14 +534,23 @@ StateFetchResult FetchCloudStateBounded(uint32_t accountId, uint32_t appId, if (future.wait_for(std::chrono::milliseconds(deadlineMs)) == std::future_status::ready) { - return future.get(); + StateFetchResult r = future.get(); + if (r.status == StateFetchStatus::Ok || r.status == StateFetchStatus::NotFound) + g_consecutiveTimeouts.store(0, std::memory_order_relaxed); + return r; + } + // Timed out. Open the circuit only after repeated timeouts so a single slow cold + // fetch doesn't blind the whole burst; one timeout is safe (caller serves local + // as a delta) but the background fetch still warms the cache for the next call. + if (g_consecutiveTimeouts.fetch_add(1, std::memory_order_relaxed) + 1 >= kCircuitTripThreshold) { + g_providerSlowUntilMs.store(NowMs() + kCircuitCooldownMs, std::memory_order_relaxed); + LOG("[AppState] FetchCloudStateBounded app %u: provider exceeded %dms (%d consecutive) " + "-- circuit open %dms, background fetch continues", + appId, deadlineMs, kCircuitTripThreshold, kCircuitCooldownMs); + } else { + LOG("[AppState] FetchCloudStateBounded app %u: provider exceeded %dms -- serving local " + "this call, circuit NOT yet open, background fetch continues", appId, deadlineMs); } - // Timed out: open the circuit so the rest of this changelist burst serves local - // immediately instead of each waiting another full deadline. - g_providerSlowUntilMs.store(NowMs() + kCircuitCooldownMs, std::memory_order_relaxed); - LOG("[AppState] FetchCloudStateBounded app %u: provider exceeded %dms -- serving " - "local, circuit open %dms, background fetch continues", - appId, deadlineMs, kCircuitCooldownMs); return { StateFetchStatus::Timeout, {} }; } @@ -530,8 +577,7 @@ StateFetchResult FetchCloudStateForServe(uint32_t accountId, uint32_t appId) { } bool PublishCloudState(uint32_t accountId, uint32_t appId, - const CloudAppState& state, bool lockOnly, - const std::unordered_set<std::string>* confirmedDurable) { + const CloudAppState& state, bool lockOnly) { InflightSyncScope guard; if (!guard) return false; if (!g_stateProvider || !g_stateProvider->IsAuthenticated()) { @@ -544,7 +590,7 @@ bool PublishCloudState(uint32_t accountId, uint32_t appId, // it: a session-release publish reuses the manifest CompleteBatch just verified. CloudAppState verified = state; if (!lockOnly && - !VerifyAndHealManifestForPublish(accountId, appId, verified, confirmedDurable)) { + !VerifyAndHealManifestForPublish(accountId, appId, verified)) { LOG("[AppState] PublishCloudState app %u: cannot verify blobs, deferring publish", appId); return false; } @@ -553,7 +599,8 @@ bool PublishCloudState(uint32_t accountId, uint32_t appId, // cloud CN and reject a stale RMW (e.g. the session lock republish) that would // clobber a newer CN another machine published in the window. Equal CN is fine. { - auto current = FetchCloudStateLive(accountId, appId); + auto current = FetchCloudStateLive(accountId, appId, + /*allowLegacyMigration=*/false); if (current.status == StateFetchStatus::Ok && current.state.cn > verified.cn) { LOG("[AppState] PublishCloudState app %u: REFUSED -- cloud CN=%llu is newer than " "publish CN=%llu (would regress changelist); leaving cloud state intact", @@ -561,6 +608,16 @@ bool PublishCloudState(uint32_t accountId, uint32_t appId, (unsigned long long)verified.cn); return false; } + // Fail-closed: an inconclusive re-fetch can't prove we won't regress a newer + // cloud CN. NotFound is genuinely empty (fresh app) so publishing is safe; + // Timeout/FetchFailed/ParseFailed are not -- refuse so the caller retries. + if (current.status != StateFetchStatus::Ok && + current.status != StateFetchStatus::NotFound) { + LOG("[AppState] PublishCloudState app %u: REFUSED -- cannot verify cloud CN " + "(status=%d); deferring publish to avoid a blind regression", + appId, static_cast<int>(current.status)); + return false; + } } std::string json = SerializeState(verified); diff --git a/src/common/app_state.h b/src/common/app_state.h index a194fddd..46944df7 100644 --- a/src/common/app_state.h +++ b/src/common/app_state.h @@ -7,7 +7,6 @@ #include <mutex> #include <string> #include <unordered_map> -#include <unordered_set> #include <vector> namespace CloudStorage { @@ -104,11 +103,8 @@ void NoteOwnClientId(uint64_t clientId); // a stale RMW on providers with no conditional-write primitive. // lockOnly skips the blob verify/heal pass; use it only on the session-release // publish, where the manifest and CN were just committed by the upload batch. -// `confirmedDurable` (optional) forwards to VerifyAndHealManifestForPublish: filenames -// uploaded+provider-confirmed in this batch, so their durability needn't be re-listed. bool PublishCloudState(uint32_t accountId, uint32_t appId, - const CloudAppState& state, bool lockOnly = false, - const std::unordered_set<std::string>* confirmedDurable = nullptr); + const CloudAppState& state, bool lockOnly = false); std::string SerializeState(const CloudAppState& state); bool DeserializeState(const std::string& json, CloudAppState& outState); diff --git a/src/common/autocloud_scan.cpp b/src/common/autocloud_scan.cpp index edee5f52..a513ca3a 100644 --- a/src/common/autocloud_scan.cpp +++ b/src/common/autocloud_scan.cpp @@ -648,11 +648,6 @@ ScanResult GetFileList(const std::string& steamPath, std::filesystem::path appUserdataDir = FileUtil::Utf8ToPath(steamPath) / "userdata" / std::to_string(accountId) / std::to_string(appId); - // Retain hashed bytes so the bootstrap commit can avoid re-reading; bounded - // by a total budget, beyond which the commit re-reads from disk. - constexpr uint64_t kMaxRetainedContentBytes = 512ULL * 1024 * 1024; - uint64_t retainedContentBytes = 0; - auto addFile = [&](const std::filesystem::directory_entry& fileEntry, const std::string& cloudPath, const std::string& sourcePath, @@ -665,7 +660,7 @@ ScanResult GetFileList(const std::string& steamPath, uint64_t rawSize = (uint64_t)fileEntry.file_size(ec); if (ec) return; - std::vector<uint8_t> bytes; + std::vector<uint8_t> bytes; // read once for SHA; not retained on the entry auto sha = ReadAndHashFile(FileUtil::PathToUtf8(fileEntry.path()), bytes); if (sha.empty()) { LOG("GetAutoCloudFileList: skipping app %u file %s (SHA1 read error)", @@ -684,10 +679,6 @@ ScanResult GetFileList(const std::string& steamPath, fe.rootToken = rootToken; fe.rootId = rootId; fe.sha = std::move(sha); - if (retainedContentBytes + bytes.size() <= kMaxRetainedContentBytes) { - retainedContentBytes += bytes.size(); - fe.content = std::move(bytes); - } outResult.files.push_back(std::move(fe)); }; diff --git a/src/common/autocloud_scan.h b/src/common/autocloud_scan.h index 0a0b1111..c51a20b5 100644 --- a/src/common/autocloud_scan.h +++ b/src/common/autocloud_scan.h @@ -20,7 +20,6 @@ struct FileEntry { std::vector<uint8_t> sha; // SHA1 hash (20 bytes) std::string rootToken; // Cloud root token (e.g., "%WinAppDataLocal%") uint32_t rootId = 0; // Steam ERemoteStorageFileRoot enum value - std::vector<uint8_t> content; // bytes read while hashing; empty if not retained }; struct ScanResult { diff --git a/src/common/cli.cpp b/src/common/cli.cpp index eb848818..6b822350 100644 --- a/src/common/cli.cpp +++ b/src/common/cli.cpp @@ -473,19 +473,18 @@ std::string CmdListAllStats(const std::string& provider) { return JsonError("Search not supported for provider: " + provider); } + // Stats sync as one account-wide blob at <accountId>/0/stats.json; legacy + // installs keep per-app files. Expand the blob per app and de-dupe (account + // blob wins). Account scope lives under synthetic appId 0 (kAccountScopeAppId). + const std::string accountScope = "0"; + std::unordered_set<std::string> seen; // "<account>/<app>" std::ostringstream apps; apps << "["; bool first = true; - for (const auto& h : hits) { - // h.path is "<accountId>/<appId>/stats.json". - size_t s1 = h.path.find('/'); - if (s1 == std::string::npos) continue; - size_t s2 = h.path.find('/', s1 + 1); - if (s2 == std::string::npos) continue; - std::string accountId = h.path.substr(0, s1); - std::string appId = h.path.substr(s1 + 1, s2 - s1 - 1); - std::string content(reinterpret_cast<const char*>(h.content.data()), h.content.size()); + auto emit = [&](const std::string& accountId, const std::string& appId, + const std::string& content) { + if (!seen.insert(accountId + "/" + appId).second) return; if (!first) apps << ","; apps << JsonObject({ {"account_id", JsonString(accountId)}, @@ -493,6 +492,36 @@ std::string CmdListAllStats(const std::string& provider) { {"content", JsonString(content)} }); first = false; + }; + + // Pass 1: account-scope blobs first so they win de-dup over legacy files. + // Pass 2: legacy per-app blobs for un-migrated apps. + for (int pass = 0; pass < 2; ++pass) { + for (const auto& h : hits) { + // h.path is "<accountId>/<appId>/stats.json". + size_t s1 = h.path.find('/'); + if (s1 == std::string::npos) continue; + size_t s2 = h.path.find('/', s1 + 1); + if (s2 == std::string::npos) continue; + std::string accountId = h.path.substr(0, s1); + std::string appId = h.path.substr(s1 + 1, s2 - s1 - 1); + bool isAccountBlob = (appId == accountScope); + if (isAccountBlob != (pass == 0)) continue; + std::string content( + reinterpret_cast<const char*>(h.content.data()), h.content.size()); + + if (isAccountBlob) { + // {"<appId>": {stats...}} -> one entry per app. + Json::Value root = Json::Parse(content); + if (root.type != Json::Type::Object) continue; + for (const auto& [appIdStr, appVal] : root.objVal) { + if (appIdStr == accountScope) continue; + emit(accountId, appIdStr, Json::Stringify(appVal)); + } + } else { + emit(accountId, appId, content); + } + } } apps << "]"; return std::string("{\"success\":true,\"apps\":") + apps.str() + "}"; diff --git a/src/common/cloud_provider_base.cpp b/src/common/cloud_provider_base.cpp index f01ee97e..781eb0ec 100644 --- a/src/common/cloud_provider_base.cpp +++ b/src/common/cloud_provider_base.cpp @@ -9,6 +9,7 @@ #include <thread> #include <chrono> #include <atomic> +#include <random> using HttpUtil::HttpResp; @@ -17,6 +18,10 @@ using HttpUtil::HttpResp; // headroom before lowering it further. std::atomic<uint64_t> g_rateLimitHits{0}; +// In-flight upload byte cap (see header). Default 24 MB; overridden from +// config.json "upload_inflight_mb" at DLL init. +std::atomic<uint64_t> g_uploadInFlightCapBytes{24ull << 20}; + // ── ParsePath ────────────────────────────────────────────────────────────── bool CloudProviderBase::ParsePath(const std::string& path, @@ -230,10 +235,21 @@ HttpResp CloudProviderBase::ApiGet(const std::string& path) { HttpResp CloudProviderBase::ApiRequest(const char* method, const std::string& path, const std::string& body, const std::string& contentType) { + // Exponential backoff with jitter: base 1s, factor 2x, up to 5 attempts. + // Retries on rate-limit (429 / 403+rateLimitExceeded) AND timeout (HTTP 0). + static constexpr int kMaxAttempts = 5; + static thread_local std::mt19937 rng{std::random_device{}()}; + HttpResp lastResp; - for (int attempt = 0; attempt < 4; ++attempt) { - if (attempt > 0) - std::this_thread::sleep_for(std::chrono::seconds(attempt)); + for (int attempt = 0; attempt < kMaxAttempts; ++attempt) { + if (attempt > 0) { + int baseMs = 1000 * (1 << (attempt - 1)); // 1s, 2s, 4s, 8s + int jitter = std::uniform_int_distribution<int>(0, baseMs / 2)(rng); + int delayMs = baseMs + jitter; + LOG("%s Backoff %s %s: attempt %d, waiting %dms", + LogTag(), method, path.c_str(), attempt + 1, delayMs); + std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); + } auto token = GetAccessToken(); if (token.empty()) { LOG("%s ApiRequest: no access token for %s %s", LogTag(), method, path.c_str()); @@ -244,12 +260,16 @@ HttpResp CloudProviderBase::ApiRequest(const char* method, const std::string& pa if (!contentType.empty()) hdrs.push_back("Content-Type: " + contentType); lastResp = Request(method, ApiHost(), path, body, hdrs); - if (!IsRateLimited(lastResp.status, lastResp.body)) return lastResp; - g_rateLimitHits.fetch_add(1, std::memory_order_relaxed); - LOG("%s Rate limited (%s attempt %d, HTTP %d), retrying", - LogTag(), method, attempt + 1, lastResp.status); + bool rateLimited = IsRateLimited(lastResp.status, lastResp.body); + bool timedOut = (lastResp.status == 0); + if (!rateLimited && !timedOut) return lastResp; + if (rateLimited) + g_rateLimitHits.fetch_add(1, std::memory_order_relaxed); + LOG("%s %s (%s attempt %d, HTTP %d), retrying", + LogTag(), rateLimited ? "Rate limited" : "Timeout", + method, attempt + 1, lastResp.status); } - LOG("%s Rate limit retries exhausted for %s %s", LogTag(), method, path.c_str()); + LOG("%s Retries exhausted for %s %s (HTTP %d)", LogTag(), method, path.c_str(), lastResp.status); return lastResp; } diff --git a/src/common/cloud_provider_base.h b/src/common/cloud_provider_base.h index 14459c58..3fc29f4a 100644 --- a/src/common/cloud_provider_base.h +++ b/src/common/cloud_provider_base.h @@ -18,6 +18,10 @@ // Read/reset per batch in UploadBatch for throughput telemetry. extern std::atomic<uint64_t> g_rateLimitHits; +// Max bytes in flight in UploadBatch (config.json "upload_inflight_mb", default +// 24 MB). Lower keeps each large blob above the request receive timeout. +extern std::atomic<uint64_t> g_uploadInFlightCapBytes; + // ── IHttpTransport ──────────────────────────────────────────────────────── // Platform adapter for raw HTTP request execution. // Windows: WinHTTP session/connection/request handles. diff --git a/src/common/cloud_storage.cpp b/src/common/cloud_storage.cpp index 905b980f..5850c33c 100644 --- a/src/common/cloud_storage.cpp +++ b/src/common/cloud_storage.cpp @@ -96,6 +96,47 @@ static bool IsBlobShaDurableThisSession(uint32_t accountId, uint32_t appId, return it != g_durableBlobs.end() && it->second.count(shaHex) > 0; } +// 60s blob-listing cache: VerifyAndHealManifestForPublish and GarbageCollectBlobs +// list the same blobs/ prefix back-to-back (~20s on GDrive). Invalidated on upload/delete. +struct BlobListingCache { + std::vector<ICloudProvider::FileInfo> blobs; + std::chrono::steady_clock::time_point fetchedAt; + bool complete = false; +}; +static std::mutex g_blobListingCacheMtx; +static std::unordered_map<uint64_t, BlobListingCache> g_blobListingCache; +static constexpr int kBlobListingCacheTtlSec = 60; + +static bool GetCachedBlobListing(uint32_t accountId, uint32_t appId, + std::vector<ICloudProvider::FileInfo>& out) { + uint64_t key = (static_cast<uint64_t>(accountId) << 32) | appId; + std::lock_guard<std::mutex> lk(g_blobListingCacheMtx); + auto it = g_blobListingCache.find(key); + if (it == g_blobListingCache.end()) return false; + auto age = std::chrono::steady_clock::now() - it->second.fetchedAt; + if (age > std::chrono::seconds(kBlobListingCacheTtlSec) || !it->second.complete) { + g_blobListingCache.erase(it); + return false; + } + out = it->second.blobs; + return true; +} + +static void SetCachedBlobListing(uint32_t accountId, uint32_t appId, + const std::vector<ICloudProvider::FileInfo>& blobs, + bool complete) { + if (!complete) return; // only cache complete listings + uint64_t key = (static_cast<uint64_t>(accountId) << 32) | appId; + std::lock_guard<std::mutex> lk(g_blobListingCacheMtx); + g_blobListingCache[key] = {blobs, std::chrono::steady_clock::now(), true}; +} + +static void InvalidateBlobListingCache(uint32_t accountId, uint32_t appId) { + uint64_t key = (static_cast<uint64_t>(accountId) << 32) | appId; + std::lock_guard<std::mutex> lk(g_blobListingCacheMtx); + g_blobListingCache.erase(key); +} + // Serializes token persistence (root_token.dat, file_tokens.dat) across // concurrent callers (rpc_handlers batch operations). // Per-(account,app) sync mutex registry (Steam-parity). Non-reentrant: SyncFromCloudInner-reachable callers go direct. @@ -1539,8 +1580,7 @@ bool DeleteBlobStaged(uint32_t accountId, uint32_t appId, // (forget) rather than advertise a blob that 404s elsewhere. Returns false only // when the cloud listing is unavailable (can't tell durable from phantom). bool VerifyAndHealManifestForPublish(uint32_t accountId, uint32_t appId, - CloudAppState& state, - const std::unordered_set<std::string>* confirmedDurable) { + CloudAppState& state) { if (state.files.empty()) return true; InflightSyncScope guard; @@ -1552,15 +1592,10 @@ bool VerifyAndHealManifestForPublish(uint32_t accountId, uint32_t appId, if (appId == CloudIntercept::kAccountScopeAppId) return true; // Skip blob re-listing for a file whose content hash is in the session durable - // cache. Sha-keyed only: the cache holds the shas the promote actually uploaded - // (RecordDurableBlobShas, populated before this runs). Filename is not trusted -- - // fe.sha may differ from the uploaded sha, which could skip a stale CAS path. If - // every file qualifies the ~20s GDrive walk is skipped; a mixed state still lists - // the unconfirmed remainder. confirmedDurable is kept for ABI but no longer read. - auto durableWithoutListing = [&](const std::string& filename, - const FileEntry& fe) -> bool { - (void)filename; - (void)confirmedDurable; + // cache. The cache holds shas the promote actually uploaded (RecordDurableBlobShas, + // populated before this runs). If every file qualifies the ~20s GDrive walk is + // skipped entirely; a mixed state still lists the unconfirmed remainder. + auto durableWithoutListing = [&](const FileEntry& fe) -> bool { std::string shaHex = fe.sha.empty() ? std::string() : ShaToHex(fe.sha); return !shaHex.empty() && IsBlobShaDurableThisSession(accountId, appId, shaHex); }; @@ -1568,7 +1603,7 @@ bool VerifyAndHealManifestForPublish(uint32_t accountId, uint32_t appId, { bool allConfirmed = true; for (const auto& [filename, fe] : state.files) { - if (!durableWithoutListing(filename, fe)) { allConfirmed = false; break; } + if (!durableWithoutListing(fe)) { allConfirmed = false; break; } } if (allConfirmed) { LOG("[CloudStorage] VerifyManifest app %u: all %zu file(s) confirmed durable " @@ -1581,11 +1616,18 @@ bool VerifyAndHealManifestForPublish(uint32_t accountId, uint32_t appId, std::string blobPrefix = std::to_string(accountId) + "/" + std::to_string(appId) + "/blobs/"; std::vector<ICloudProvider::FileInfo> remoteBlobs; - bool complete = false; - if (!g_provider->ListChecked(blobPrefix, remoteBlobs, &complete) || !complete) { - LOG("[CloudStorage] VerifyManifest app %u: blob listing unavailable; not publishing", - appId); - return false; + bool fromCache = GetCachedBlobListing(accountId, appId, remoteBlobs); + if (!fromCache) { + bool complete = false; + if (!g_provider->ListChecked(blobPrefix, remoteBlobs, &complete) || !complete) { + LOG("[CloudStorage] VerifyManifest app %u: blob listing unavailable; not publishing", + appId); + return false; + } + SetCachedBlobListing(accountId, appId, remoteBlobs, complete); + } else { + LOG("[CloudStorage] VerifyManifest app %u: reusing cached blob listing (%zu entries)", + appId, remoteBlobs.size()); } // Build the set of SHAs and legacy paths actually present on the provider. @@ -1613,10 +1655,8 @@ bool VerifyAndHealManifestForPublish(uint32_t accountId, uint32_t appId, const std::string& filename = it->first; const FileEntry& fe = it->second; - // Sha confirmed durable this session: durable by definition, no need to consult - // the listing (it may even race a just-completed upload's eventual-consistency - // window). Trust the upload 2xx, exactly as native trusts EResult. - if (durableWithoutListing(filename, fe)) { ++it; continue; } + // Sha confirmed durable this session: trust the upload 2xx. + if (durableWithoutListing(fe)) { ++it; continue; } std::string shaHex = fe.sha.empty() ? std::string() : ShaToHex(fe.sha); bool present = (!shaHex.empty() && cloudShas.count(shaHex) > 0) || @@ -1651,6 +1691,7 @@ bool VerifyAndHealManifestForPublish(uint32_t accountId, uint32_t appId, if (!healed.empty() || !dropped.empty()) { InvalidateBlobIndex(accountId, appId); + if (!healed.empty()) InvalidateBlobListingCache(accountId, appId); LOG("[CloudStorage] VerifyManifest app %u: healed %zu, dropped %zu phantom file(s)", appId, healed.size(), dropped.size()); } @@ -1737,6 +1778,7 @@ bool PromoteStagedBatchForCommit(uint32_t accountId, uint32_t appId, LOG("[CloudStorage] PromoteStagedBatch app %u batch %llu: promoted %zu upload(s), %zu delete(s)", appId, (unsigned long long)batchId, uploads.size(), deletes.size()); InvalidateBlobIndex(accountId, appId); + InvalidateBlobListingCache(accountId, appId); return true; } @@ -1912,10 +1954,16 @@ int GarbageCollectBlobs(uint32_t accountId, uint32_t appId) { std::string blobPrefix = std::to_string(accountId) + "/" + std::to_string(appId) + "/blobs/"; std::vector<ICloudProvider::FileInfo> remoteBlobs; - bool complete = false; - if (!g_provider->ListChecked(blobPrefix, remoteBlobs, &complete) || !complete) { - LOG("[GC] app=%u: listing incomplete or failed, refusing GC", appId); - return -1; + bool fromCache = GetCachedBlobListing(accountId, appId, remoteBlobs); + if (!fromCache) { + bool complete = false; + if (!g_provider->ListChecked(blobPrefix, remoteBlobs, &complete) || !complete) { + LOG("[GC] app=%u: listing incomplete or failed, refusing GC", appId); + return -1; + } + SetCachedBlobListing(accountId, appId, remoteBlobs, complete); + } else { + LOG("[GC] app=%u: reusing cached blob listing (%zu entries)", appId, remoteBlobs.size()); } if (remoteBlobs.empty()) { @@ -2087,6 +2135,7 @@ int GarbageCollectBlobs(uint32_t accountId, uint32_t appId) { LOG("[GC] app=%u: deleted %d/%zu orphaned blobs", appId, deleted, orphans.size()); InvalidateBlobIndex(accountId, appId); + InvalidateBlobListingCache(accountId, appId); return deleted + promoted; } @@ -2592,105 +2641,143 @@ static bool SyncFromCloudInner(uint32_t accountId, uint32_t appId, bool isSweep) } } + // Parallel download: 8 workers (mirrors RestoreBlobs bootstrap concurrency). + // Download + local-write are both thread-safe (per-request HTTP handles; + // WriteFileNoIncrement holds its own mutex). + static constexpr size_t kMaxDownloadWorkers = 8; + auto blobStart = std::chrono::steady_clock::now(); - for (auto& item : downloadItems) { - // Check timeout - auto elapsed = std::chrono::duration_cast<std::chrono::seconds>( - std::chrono::steady_clock::now() - blobStart).count(); - if (elapsed >= BLOB_SYNC_TIMEOUT_SEC) { - int remaining = (int)downloadItems.size() - downloaded - skipped; - LOG("[CloudStorage] SyncFromCloud app %u: blob download TIMEOUT after %llds, " - "%d downloaded, %d skipped, ~%d remaining", - appId, (long long)elapsed, downloaded, skipped, remaining); - timedOut = true; - break; - } - if (isSweep && g_foregroundSyncCount.load(std::memory_order_seq_cst) > 0) { - int remaining = (int)downloadItems.size() - downloaded - skipped; - LOG("[CloudStorage] SyncFromCloud app %u: sweep yielding blob loop to foreground sync (downloaded=%d skipped=%d remaining=%d)", - appId, downloaded, skipped, remaining); - timedOut = true; - break; - } + std::atomic<size_t> nextIdx{0}; + std::atomic<int> aDownloaded{0}, aSkipped{0}, aFailed{0}; + std::atomic<bool> aStopped{false}; // timeout or sweep-yield + std::mutex stagedMtx; + // Per-item download logic (runs on worker threads). + auto downloadOne = [&](const DownloadItem& item) { std::string localBlobFile = LocalBlobPath(accountId, appId, item.filename); std::error_code existsEc; bool localExists = std::filesystem::exists(FileUtil::Utf8ToPath(localBlobFile), existsEc); if (existsEc) localExists = false; if (localExists && !cloudHadNewerCN) { - skipped++; - continue; // already cached + aSkipped.fetch_add(1, std::memory_order_relaxed); + return; } LOG("[CloudStorage] SyncFromCloud app %u: downloading blob %s...", appId, item.filename.c_str()); std::vector<uint8_t> data; - if (g_provider->Download(item.cloudPath, data)) { - if (cloudHadNewerCN) { - stagedNewerBlobs.push_back({ item.filename, std::move(data) }); - downloaded++; - continue; - } + bool ok = g_provider->Download(item.cloudPath, data); - const uint8_t* writeData = data.empty() ? nullptr : data.data(); - if (LocalStorage::WriteFileNoIncrement(accountId, appId, item.filename, - writeData, data.size())) { - downloaded++; - LOG("[CloudStorage] SyncFromCloud app %u: blob %s downloaded (%zu bytes)", - appId, item.filename.c_str(), data.size()); - } else { - failed++; - LOG("[CloudStorage] SyncFromCloud app %u: failed to write blob %s", - appId, item.filename.c_str()); - continue; - } - } else if (!syncManifest.empty()) { - // Canonical failed -- cascade: legacy CAS, then pre-CAS filename. + // Fallback cascade (CAS manifest only). + if (!ok && !syncManifest.empty()) { auto mit = syncManifest.find(item.filename); std::string fallbackPath; - if (mit != syncManifest.end() && !mit->second.sha.empty()) { - fallbackPath = CloudBlobPathBySHA(accountId, appId, - ShaToHex(mit->second.sha)); - } - bool fellBack = false; + if (mit != syncManifest.end() && !mit->second.sha.empty()) + fallbackPath = CloudBlobPathBySHA(accountId, appId, ShaToHex(mit->second.sha)); if (!fallbackPath.empty() && g_provider->Download(fallbackPath, data)) { - fellBack = true; + ok = true; LOG("[CloudStorage] SyncFromCloud app %u: blob %s downloaded from legacy CAS path (%zu bytes)", appId, item.filename.c_str(), data.size()); } else { std::string legacyPath = CloudBlobPath(accountId, appId, item.filename); if (g_provider->Download(legacyPath, data)) { - fellBack = true; + ok = true; LOG("[CloudStorage] SyncFromCloud app %u: blob %s downloaded from legacy filename path (%zu bytes)", appId, item.filename.c_str(), data.size()); } } - if (fellBack) { - if (cloudHadNewerCN) { - stagedNewerBlobs.push_back({ item.filename, std::move(data) }); - downloaded++; - continue; - } - const uint8_t* writeData = data.empty() ? nullptr : data.data(); - if (LocalStorage::WriteFileNoIncrement(accountId, appId, item.filename, - writeData, data.size())) { - downloaded++; - } else { - failed++; - LOG("[CloudStorage] SyncFromCloud app %u: failed to write blob %s (legacy fallback)", - appId, item.filename.c_str()); - } + } + + if (!ok) { + aFailed.fetch_add(1, std::memory_order_relaxed); + LOG("[CloudStorage] SyncFromCloud app %u: FAILED to download blob %s", + appId, item.filename.c_str()); + return; + } + + if (cloudHadNewerCN) { + std::lock_guard<std::mutex> lk(stagedMtx); + stagedNewerBlobs.push_back({ item.filename, std::move(data) }); + aDownloaded.fetch_add(1, std::memory_order_relaxed); + } else { + const uint8_t* writeData = data.empty() ? nullptr : data.data(); + if (LocalStorage::WriteFileNoIncrement(accountId, appId, item.filename, + writeData, data.size())) { + aDownloaded.fetch_add(1, std::memory_order_relaxed); + LOG("[CloudStorage] SyncFromCloud app %u: blob %s downloaded (%zu bytes)", + appId, item.filename.c_str(), data.size()); } else { - failed++; - LOG("[CloudStorage] SyncFromCloud app %u: FAILED to download blob %s", + aFailed.fetch_add(1, std::memory_order_relaxed); + LOG("[CloudStorage] SyncFromCloud app %u: failed to write blob %s", appId, item.filename.c_str()); } - } else { - failed++; - LOG("[CloudStorage] SyncFromCloud app %u: FAILED to download blob %s", - appId, item.filename.c_str()); } + }; + + // Worker: claim items by index until exhausted, stopped, or failed. + auto worker = [&]() { + for (;;) { + if (aStopped.load(std::memory_order_relaxed)) return; + size_t i = nextIdx.fetch_add(1, std::memory_order_relaxed); + if (i >= downloadItems.size()) return; + try { + downloadOne(downloadItems[i]); + } catch (const std::exception& e) { + aFailed.fetch_add(1, std::memory_order_relaxed); + LOG("[CloudStorage] SyncFromCloud app %u: download worker threw on %s: %s", + appId, downloadItems[i].filename.c_str(), e.what()); + } catch (...) { + aFailed.fetch_add(1, std::memory_order_relaxed); + LOG("[CloudStorage] SyncFromCloud app %u: download worker threw (unknown) on %s", + appId, downloadItems[i].filename.c_str()); + } + } + }; + + size_t workerCount = (std::min)(kMaxDownloadWorkers, downloadItems.size()); + if (workerCount < 1) workerCount = 1; + + if (downloadItems.size() <= 1) { + // Single item or empty: run inline, no thread overhead. + if (!downloadItems.empty()) downloadOne(downloadItems[0]); + } else { + // Spawn workers; monitor timeout/sweep-yield on main thread. + std::vector<std::thread> pool; + pool.reserve(workerCount); + for (size_t t = 0; t < workerCount; ++t) pool.emplace_back(worker); + + // Poll for timeout/sweep-yield while workers run. + while (nextIdx.load(std::memory_order_relaxed) < downloadItems.size() && + !aStopped.load(std::memory_order_relaxed)) { + auto elapsed = std::chrono::duration_cast<std::chrono::seconds>( + std::chrono::steady_clock::now() - blobStart).count(); + if (elapsed >= BLOB_SYNC_TIMEOUT_SEC) { + int done = aDownloaded.load() + aSkipped.load(); + int remaining = (int)downloadItems.size() - done; + LOG("[CloudStorage] SyncFromCloud app %u: blob download TIMEOUT after %llds, " + "%d downloaded, %d skipped, ~%d remaining", + appId, (long long)elapsed, aDownloaded.load(), aSkipped.load(), remaining); + aStopped.store(true, std::memory_order_relaxed); + break; + } + if (isSweep && g_foregroundSyncCount.load(std::memory_order_seq_cst) > 0) { + int done = aDownloaded.load() + aSkipped.load(); + int remaining = (int)downloadItems.size() - done; + LOG("[CloudStorage] SyncFromCloud app %u: sweep yielding blob loop to foreground sync (downloaded=%d skipped=%d remaining=%d)", + appId, aDownloaded.load(), aSkipped.load(), remaining); + aStopped.store(true, std::memory_order_relaxed); + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + for (auto& th : pool) th.join(); } + downloaded = aDownloaded.load(); + skipped = aSkipped.load(); + failed = aFailed.load(); + timedOut = aStopped.load(); + if (cloudHadNewerCN && failed == 0 && !timedOut) { struct PromotedBlob { std::string filename; diff --git a/src/common/cloud_storage.h b/src/common/cloud_storage.h index 664992b1..23abcb56 100644 --- a/src/common/cloud_storage.h +++ b/src/common/cloud_storage.h @@ -51,16 +51,10 @@ bool PromoteStagedBatchForCommit(uint32_t accountId, uint32_t appId, // Verifies every file in `state` has its CAS blob durably on the provider before // its manifest is published; heals from the local cache or drops phantom entries. // Returns false only when the cloud blob listing is unavailable (don't publish). -// -// `confirmedDurable`, when non-null, lists filenames whose blobs were just uploaded -// (and provider-confirmed with a 2xx) in this batch. These are durable by definition -// -- exactly the signal native trusts (the upload EResult; see YldUploadFiles) -- so -// they need no re-listing. If every file in `state` is confirmed, the (slow, ~20s for -// GDrive) blob listing is skipped entirely; otherwise it lists only to verify the -// carried-forward remainder (files from a prior CN that this batch did not re-upload). +// Files whose SHA is in the session durable cache (RecordDurableBlobShas) are +// trusted without re-listing -- the session lock prevents GC by other machines. bool VerifyAndHealManifestForPublish(uint32_t accountId, uint32_t appId, - CloudAppState& state, - const std::unordered_set<std::string>* confirmedDurable = nullptr); + CloudAppState& state); std::vector<uint64_t> ListStagedBatchIds(uint32_t accountId, uint32_t appId); bool RemoveStagedBatch(uint32_t accountId, uint32_t appId, uint64_t batchId); diff --git a/src/common/cloud_work_queue.cpp b/src/common/cloud_work_queue.cpp index e30442e0..42e56cb0 100644 --- a/src/common/cloud_work_queue.cpp +++ b/src/common/cloud_work_queue.cpp @@ -37,10 +37,18 @@ static std::unordered_map<std::string, WorkItem> g_failedWorkItems; static std::condition_variable g_drainCV; static constexpr int WORKER_THREAD_COUNT = 8; +// Number of background worker threads. Defaults to WORKER_THREAD_COUNT. +// Tests set it to 1 for deterministic, single-worker repro runs. +static std::atomic<int> g_workerThreadCount{WORKER_THREAD_COUNT}; + static constexpr int MAX_DRAIN_REQUEUES = 3; static constexpr int FAIL_THRESHOLD = 5; static constexpr auto RECENT_UPLOAD_TTL = std::chrono::seconds(120); +// Base unit for exponential retry backoff (delay = unit << (retries - 1)). +// Defaults to 1000ms (1s/2s/4s). Tests set it to 0 for deterministic, fast runs. +static std::atomic<int> g_retryBackoffUnitMs{1000}; + // Error reporter -- set once at Init. Tests inject a no-op or spy. // Never changed after Init; no mutex needed. static Reporter g_reporter; @@ -227,9 +235,13 @@ static void RequeueFailedWorkForPrefixLocked(const std::string& prefix) { g_failedPaths.erase(it->first); if (!g_activePaths.count(item.cloudPath)) { ++item.drainRequeues; + bool isUpload = item.type == WorkItem::Upload; g_workQueue.push_back(std::move(item)); - // Don't update g_uploadIndex for requeued items -- iterator - // would be invalidated by concurrent push_back/erase. + // Re-index requeued uploads: std::list iterators stay valid until the + // node is erased, so the stored iterator tracks this entry. + if (isUpload) { + g_uploadIndex[g_workQueue.back().cloudPath] = std::prev(g_workQueue.end()); + } } else { // Path is actively being processed; preserve the failed item // so it can be retried on the next drain cycle. @@ -306,7 +318,7 @@ static void WorkerLoop(int threadId) { } if (consecutiveFailures > 0) { - int delayMs = 1000 * (1 << (consecutiveFailures < 5 ? consecutiveFailures : 5)); + int delayMs = g_retryBackoffUnitMs.load() * (1 << (consecutiveFailures < 5 ? consecutiveFailures : 5)); if (delayMs > 30000) delayMs = 30000; LOG("[CloudStorage] Worker %d backing off %d ms after %d consecutive failure(s)", threadId, delayMs, consecutiveFailures); @@ -352,11 +364,11 @@ static void WorkerLoop(int threadId) { LOG("[CloudStorage] BG upload deferred after existence check failure [%d]: %s", threadId, item.cloudPath.c_str()); OnCloudFailure("Exists", item.cloudPath); - int delaySecs = 1 << (item.existsCheckRetries - 1); + int delayMs = g_retryBackoffUnitMs.load() << (item.existsCheckRetries - 1); item.notBefore = std::chrono::steady_clock::now() - + std::chrono::seconds(delaySecs); - LOG("[CloudStorage] Exists retry %d in %ds: %s", - item.existsCheckRetries, delaySecs, item.cloudPath.c_str()); + + std::chrono::milliseconds(delayMs); + LOG("[CloudStorage] Exists retry %d in %dms: %s", + item.existsCheckRetries, delayMs, item.cloudPath.c_str()); requeued = RequeueFromWorker(std::move(item)); if (!requeued) droppedAsStale = true; break; @@ -378,11 +390,11 @@ static void WorkerLoop(int threadId) { LOG("[CloudStorage] BG upload FAILED [%d]: %s", threadId, item.cloudPath.c_str()); OnCloudFailure("Upload", item.cloudPath); if (item.transferRetries++ < 3) { - int delaySecs = 1 << (item.transferRetries - 1); + int delayMs = g_retryBackoffUnitMs.load() << (item.transferRetries - 1); item.notBefore = std::chrono::steady_clock::now() - + std::chrono::seconds(delaySecs); - LOG("[CloudStorage] Upload retry %d in %ds: %s", - item.transferRetries, delaySecs, item.cloudPath.c_str()); + + std::chrono::milliseconds(delayMs); + LOG("[CloudStorage] Upload retry %d in %dms: %s", + item.transferRetries, delayMs, item.cloudPath.c_str()); requeued = RequeueFromWorker(std::move(item)); if (!requeued) droppedAsStale = true; } @@ -397,11 +409,11 @@ static void WorkerLoop(int threadId) { LOG("[CloudStorage] BG delete FAILED [%d]: %s", threadId, item.cloudPath.c_str()); OnCloudFailure("Delete", item.cloudPath); if (item.transferRetries++ < 3) { - int delaySecs = 1 << (item.transferRetries - 1); + int delayMs = g_retryBackoffUnitMs.load() << (item.transferRetries - 1); item.notBefore = std::chrono::steady_clock::now() - + std::chrono::seconds(delaySecs); - LOG("[CloudStorage] Delete retry %d in %ds: %s", - item.transferRetries, delaySecs, item.cloudPath.c_str()); + + std::chrono::milliseconds(delayMs); + LOG("[CloudStorage] Delete retry %d in %dms: %s", + item.transferRetries, delayMs, item.cloudPath.c_str()); requeued = RequeueFromWorker(std::move(item)); if (!requeued) droppedAsStale = true; } @@ -513,27 +525,14 @@ void EnqueueWork(WorkItem item) { static bool RequeueFromWorker(WorkItem item) { std::lock_guard<std::mutex> lock(g_queueMutex); if (item.type == WorkItem::Upload) { - auto indexIt = g_uploadIndex.find(item.cloudPath); - if (indexIt != g_uploadIndex.end()) { - // Verify iterator is still valid before trusting it - bool iteratorValid = false; - for (auto it = g_workQueue.begin(); it != g_workQueue.end(); ++it) { - if (it == indexIt->second) { - iteratorValid = true; - break; - } - } - if (!iteratorValid) { - LOG("[CloudStorage] Stale upload index entry for %s, cleaning up", - item.cloudPath.c_str()); - g_uploadIndex.erase(indexIt); - } else { - LOG("[CloudStorage] Retry dropped: newer upload already queued for %s", - item.cloudPath.c_str()); - g_failedPaths.erase(item.cloudPath); - g_failedWorkItems.erase(item.cloudPath); - return false; - } + // A present index entry means a newer upload is already queued for this + // path, so drop the retry instead of walking the list under the lock. + if (g_uploadIndex.find(item.cloudPath) != g_uploadIndex.end()) { + LOG("[CloudStorage] Retry dropped: newer upload already queued for %s", + item.cloudPath.c_str()); + g_failedPaths.erase(item.cloudPath); + g_failedWorkItems.erase(item.cloudPath); + return false; } } if (item.type == WorkItem::Delete) { @@ -678,10 +677,12 @@ void Init(ICloudProvider* provider, Reporter reporter) { if (g_provider) { g_workerRunning = true; - for (int i = 0; i < WORKER_THREAD_COUNT; ++i) { + int workerCount = g_workerThreadCount.load(); + if (workerCount < 1) workerCount = 1; + for (int i = 0; i < workerCount; ++i) { g_workerThreads.emplace_back(WorkerLoop, i); } - LOG("[CloudStorage] Started %d background worker threads", WORKER_THREAD_COUNT); + LOG("[CloudStorage] Started %d background worker threads", workerCount); } } @@ -690,6 +691,16 @@ void SetShuttingDown() { g_queueCV.notify_all(); } +void SetRetryBackoffUnitMs(int unitMs) { + if (unitMs < 0) unitMs = 0; + g_retryBackoffUnitMs.store(unitMs); +} + +void SetWorkerThreadCount(int count) { + if (count < 1) count = 1; + g_workerThreadCount.store(count); +} + void Shutdown() { g_shuttingDown.store(true, std::memory_order_seq_cst); g_workerRunning = false; diff --git a/src/common/cloud_work_queue.h b/src/common/cloud_work_queue.h index d8514df1..a0c3a112 100644 --- a/src/common/cloud_work_queue.h +++ b/src/common/cloud_work_queue.h @@ -39,4 +39,12 @@ void ClearFailedWorkForPrefix(const std::string& prefix); // Report error via installed reporter. void ShowErrorDialog(const std::string& message); +// Set the base unit (ms) for exponential retry backoff: delay = unit << (retries-1). +// Default 1000ms. Tests set 0 for deterministic, fast retries. +void SetRetryBackoffUnitMs(int unitMs); + +// Set the number of background worker threads (clamped to >= 1). +// Default 8. Tests set 1 for deterministic, single-worker repro. +void SetWorkerThreadCount(int count); + } // namespace CloudWorkQueue diff --git a/src/common/manifest_store.cpp b/src/common/manifest_store.cpp index 2bf11f16..0d7c35e6 100644 --- a/src/common/manifest_store.cpp +++ b/src/common/manifest_store.cpp @@ -661,6 +661,13 @@ ManifestDelta ComputeManifestDelta(uint32_t accountId, uint32_t appId, ManifestDelta delta; delta.serverCN = serverCN; + // serverCN < clientCN means the cloud lost progress; local is authoritative, so + // hold the client CN rather than diff a stale server manifest (would regress saves). + if (serverCN < clientCN) { + delta.serverCN = clientCN; // hold the client's CN; do not regress + return delta; // empty files: nothing newer to pull + } + bool snapshotExists = ManifestSnapshotExists(accountId, appId, clientCN); Manifest baseline = snapshotExists ? LoadManifestSnapshotInternal(accountId, appId, clientCN) diff --git a/src/common/metadata_sync.cpp b/src/common/metadata_sync.cpp index 888b414e..5bf26947 100644 --- a/src/common/metadata_sync.cpp +++ b/src/common/metadata_sync.cpp @@ -7,9 +7,8 @@ std::atomic<bool> syncLuas{false}; // Default OFF: WIP opt-in features; the user enables them. std::atomic<bool> syncAchievements{false}; std::atomic<bool> syncPlaytime{false}; -// Default OFF: experimental opt-in feature. -std::atomic<bool> schemaFetch{false}; -// Default OFF: gate metadata features to ST clients (unsupported WIP override). +// Default ON: fetch missing schemas from CM when StGateOpen(). +std::atomic<bool> schemaFetch{true}; std::atomic<bool> overrideNonStGate{false}; } diff --git a/src/common/metadata_sync.h b/src/common/metadata_sync.h index b4c89ab5..64ee52d8 100644 --- a/src/common/metadata_sync.h +++ b/src/common/metadata_sync.h @@ -8,21 +8,14 @@ extern std::atomic<bool> steamToolsPresent; extern std::atomic<bool> syncLuas; // Native stats/playtime sync gates (config: sync_achievements / sync_playtime). -// When false, the corresponding native path does NOT interfere at all: stats -// pass straight through to Steam's real server (no import/synthesize), and -// playtime is neither tracked nor merged. Default true (sync enabled). extern std::atomic<bool> syncAchievements; extern std::atomic<bool> syncPlaytime; -// Experimental: proactively fetch missing achievement/stats schemas from the CM -// (config: experimental_schema_fetch). When false, no schema requests are sent. -// Default false (opt-in experimental feature). +// Fetch missing achievement/stats schemas from the CM (config: schema_fetch). extern std::atomic<bool> schemaFetch; -// UNSUPPORTED WIP OVERRIDE NON-ST CLIENT GATE. -// Metadata features (achievements, playtime, schema fetch, non-Steam-game spoof) -// are hard-gated to SteamTools clients. config override_non_st_client_gate lifts -// the gate so a non-ST client honors the per-feature flags. Default false. +// Lifts the SteamTools-client gate on metadata features (config +// override_non_st_client_gate). Default false. extern std::atomic<bool> overrideNonStGate; inline bool IsEnabled() { @@ -30,15 +23,17 @@ inline bool IsEnabled() { syncLuas.load(std::memory_order_relaxed); } -// True when the ST-gate is open: either a SteamTools client, or the unsupported -// override is set. Metadata features must AND their per-feature flag with this. +// The SteamTools-client gate. Windows-only; Linux always runs under SLSsteam. inline bool StGateOpen() { +#if defined(__linux__) + return true; +#else return steamToolsPresent.load(std::memory_order_relaxed) || overrideNonStGate.load(std::memory_order_relaxed); +#endif } -// Per-feature flag AND'd with the ST-gate. Use these everywhere instead of the raw -// flags so a missed call site can't bypass the gate. +// Per-feature flag AND'd with the ST-gate. inline bool AchievementsEnabled() { return syncAchievements.load(std::memory_order_relaxed) && StGateOpen(); } diff --git a/src/common/rpc_handlers.cpp b/src/common/rpc_handlers.cpp index 41bddd77..9e98c2d8 100644 --- a/src/common/rpc_handlers.cpp +++ b/src/common/rpc_handlers.cpp @@ -580,13 +580,18 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB uint64_t appBuildIdHwm = 0; CloudStorage::CloudAppState fetchedState; // retained for quota caching bool haveFetchedState = false; + // Timeout/FetchFailed/ParseFailed: cloud state is UNKNOWN, not empty. Must never + // present authoritative-empty (is_only_delta=0, CN=0) -- Steam reads that as + // "cloud empty" and uploads local over a newer cloud save (the rollback bug). + bool inconclusiveFetch = false; if (CloudStorage::IsCloudActive()) { SetRpcCrashContext("GetChangelist:fetch-cloud", "Cloud.GetAppFileChangelist#1", appId); // Bounded -- runs on Steam's main-loop thread, where a slow download used to // stall BMainLoop past the 15s watchdog. On timeout we fall through to the // local-manifest fallback and the background fetch warms the cache. - static constexpr int kChangelistFetchDeadlineMs = 5000; + // ~11s observed for a Drive cold fetch; stay under the 15s BMainLoop watchdog. + static constexpr int kChangelistFetchDeadlineMs = 12000; auto stateResult = CloudStorage::FetchCloudStateBounded( accountId, appId, kChangelistFetchDeadlineMs); if (stateResult.status == CloudStorage::StateFetchStatus::Ok) { @@ -619,9 +624,11 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB LOG("[NS-CL] GetAppFileChangelist app=%u: no cloud state (new app), using local", appId); } else if (stateResult.status == CloudStorage::StateFetchStatus::Timeout) { + inconclusiveFetch = true; LOG("[NS-CL] GetAppFileChangelist app=%u: cloud fetch timed out, using local " "(avoids BMainLoop stall; cache warms in background)", appId); } else { + inconclusiveFetch = true; LOG("[NS-CL] GetAppFileChangelist app=%u: cloud state fetch failed (status=%d), using local", appId, static_cast<int>(stateResult.status)); } @@ -659,12 +666,42 @@ RpcResult HandleGetChangelist(uint32_t appId, const std::vector<PB::Field>& reqB uint64_t serverChangeNumber = 0; // Initialize to prevent UB in edge cases bool responseIsDelta = true; - if (haveCloudManifest && cloudManifest.empty() && cloudCN == 0) { + if (inconclusiveFetch) { + // Cloud state unknown: serve local files as a pure delta (is_only_delta=1) + // so Steam never reconcile-deletes or treats it as cloud-empty. Don't rewind CN. + SetRpcCrashContext("GetChangelist:inconclusive-fetch", "Cloud.GetAppFileChangelist#1", appId); + files = LocalStorage::GetFileList(accountId, appId); + serverChangeNumber = clientChangeNumber > cloudCN ? clientChangeNumber : cloudCN; + responseIsDelta = true; + files.erase(std::remove_if(files.begin(), files.end(), + [](const LocalStorage::FileEntry& fe) { + return IsReservedBlobFilename(fe.filename); + }), files.end()); + LOG("[NS-CL] GetAppFileChangelist app=%u: INCONCLUSIVE cloud fetch -- serving %zu " + "local files as delta at CN=%llu (clientCN=%llu); will NOT signal cloud-empty", + appId, files.size(), serverChangeNumber, clientChangeNumber); + } else if (haveCloudManifest && cloudManifest.empty() && cloudCN == 0) { // New app at CN=0 -- return empty authoritative inventory serverChangeNumber = cloudCN; responseIsDelta = false; LOG("[NS-CL] GetAppFileChangelist app=%u: cloud manifest is empty at CN=%llu, returning empty authoritative inventory", appId, cloudCN); + } else if (haveCloudManifest && !cloudManifest.empty() && + clientChangeNumber != 0 && cloudCN < clientChangeNumber) { + // cloudCN < clientCN means the cloud regressed; serve local as a delta so Steam + // re-uploads instead of pulling a stale save over a newer one. + SetRpcCrashContext("GetChangelist:cloud-rewind-heal", "Cloud.GetAppFileChangelist#1", appId); + LOG("[NS-CL] GetAppFileChangelist app=%u: HEAL cloudCN=%llu < clientCN=%llu " + "(cloud regressed); serving local as authoritative so Steam re-uploads", + appId, cloudCN, clientChangeNumber); + + files = LocalStorage::GetFileList(accountId, appId); + serverChangeNumber = clientChangeNumber; // do not rewind the client's CN + responseIsDelta = true; // delta: never reconcile-delete here + files.erase(std::remove_if(files.begin(), files.end(), + [](const LocalStorage::FileEntry& fe) { + return IsReservedBlobFilename(fe.filename); + }), files.end()); } else if (haveCloudManifest && !cloudManifest.empty()) { SetRpcCrashContext("GetChangelist:manifest-delta", "Cloud.GetAppFileChangelist#1", appId); // Steam-faithful delta: compute diff between clientCN snapshot and current manifest. @@ -1307,8 +1344,18 @@ RpcResult HandleBeginBatch(uint32_t appId, const std::vector<PB::Field>& reqBody if (bbMs > 500) LOG("[NS] BeginBatch app=%u: FetchCloudStateForServe took %lldms " "(on Steam's thread)", appId, (long long)bbMs); - if (cloud.status == CloudStorage::StateFetchStatus::Ok) + if (cloud.status == CloudStorage::StateFetchStatus::Ok) { cloudCN = cloud.state.cn; + } else if (cloud.status != CloudStorage::StateFetchStatus::NotFound) { + // Cloud CN UNKNOWN (Timeout/FetchFailed/ParseFailed). Native never assigns + // an upload CN client-side -- the server is sole CN authority. Assigning + // localCN+1 here can land BELOW the true cloud CN and roll the cloud back. + // Fail the batch; Steam retries once the fetch resolves (cache warms async). + LOG("[NS] BeginBatch app=%u: cloud CN unverifiable (status=%d) -- failing batch " + "to avoid regressive CN; Steam will retry", appId, + static_cast<int>(cloud.status)); + return RpcResult(PB::Writer(), kEResultFail); + } } uint64_t assignedCN = (std::max)(localCN, cloudCN) + 1; uint64_t appBuildId = 0; @@ -1773,8 +1820,13 @@ RpcResult HandleCompleteBatch(uint32_t appId, const std::vector<PB::Field>& reqB PersistFileTokens(accountId, appId); } } - std::vector<std::string> uploads(batch.uploads.begin(), batch.uploads.end()); - std::vector<std::string> deletes(batch.deletes.begin(), batch.deletes.end()); + // Heap-allocate uploads/deletes: the promote thread runs concurrently while + // PumpUntil yields the coroutine. A yield can invalidate the coroutine stack, + // so any data the promote thread touches must live on the heap. + auto uploads = std::make_shared<std::vector<std::string>>( + batch.uploads.begin(), batch.uploads.end()); + auto deletes = std::make_shared<std::vector<std::string>>( + batch.deletes.begin(), batch.deletes.end()); // Native runs upload+complete as a yielding job; doing it synchronously here // blocked BMainLoop past the 15s watchdog on large saves. Detach it instead. @@ -1810,26 +1862,28 @@ RpcResult HandleCompleteBatch(uint32_t appId, const std::vector<PB::Field>& reqB // only after the promote completes, keeping the CN advance below strictly after // durability; no CR mutex is held across the pump, so a re-entry can't deadlock. auto tPromote = std::chrono::steady_clock::now(); - std::atomic<bool> promoteDone{false}; - bool promoteOk = false; + // Heap-allocate sync state: if Steam's job watchdog destroys the coroutine + // during a yield, anything on the fiber stack becomes UAF. + auto promoteDone = std::make_shared<std::atomic<bool>>(false); + auto promoteOk = std::make_shared<bool>(false); std::thread promoteThread( - [&accountId, &appId, &workerBatchId, &uploads, &deletes, - &promoteOk, &promoteDone]() { - promoteOk = CloudStorage::PromoteStagedBatchForCommit( - accountId, appId, workerBatchId, uploads, deletes); - promoteDone.store(true, std::memory_order_release); + [accountId, appId, workerBatchId, uploads, deletes, + promoteOk, promoteDone]() { + *promoteOk = CloudStorage::PromoteStagedBatchForCommit( + accountId, appId, workerBatchId, *uploads, *deletes); + promoteDone->store(true, std::memory_order_release); }); // Cooperative wait: pumps BMainLoop via the guarded job-coroutine yield until the // promote thread signals completion. Degrades to a plain spin off Steam. - CoopYield::PumpUntil([&promoteDone]() { - return promoteDone.load(std::memory_order_acquire); + CoopYield::PumpUntil([promoteDone]() { + return promoteDone->load(std::memory_order_acquire); }); promoteThread.join(); auto promoteMs = std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::steady_clock::now() - tPromote).count(); LOG("[NS] CompleteBatch app=%u: promote done in %lldms (ok=%d)", - appId, (long long)promoteMs, promoteOk ? 1 : 0); - if (!promoteOk) { + appId, (long long)promoteMs, *promoteOk ? 1 : 0); + if (!*promoteOk) { LOG("[NS] CompleteBatch app=%u: staged promotion failed; " "leaving CN unchanged (Steam re-uploads next sync)", appId); PendingOpsJournal::RecordUploadBatchInterrupted(accountId, appId); @@ -1847,9 +1901,9 @@ RpcResult HandleCompleteBatch(uint32_t appId, const std::vector<PB::Field>& reqB auto syncMtx = CloudStorage::AcquireAppSyncMutex(accountId, appId); auto lock = CoopYield::LockCooperatively(*syncMtx); auto localManifest = CloudStorage::LoadLocalManifest(accountId, appId); - for (const auto& filename : deletes) + for (const auto& filename : *deletes) localManifest.erase(filename); - for (const auto& filename : uploads) { + for (const auto& filename : *uploads) { if (IsReservedBlobFilename(filename)) continue; CloudStorage::ManifestEntry me; auto metaIt = uploadMeta.find(filename); @@ -1879,8 +1933,8 @@ RpcResult HandleCompleteBatch(uint32_t appId, const std::vector<PB::Field>& reqB std::unordered_map<std::string, CloudIntercept::UploadFileMeta>>(uploadMeta); auto filePlatformsCopy = std::make_shared< std::unordered_map<std::string, uint32_t>>(filePlatforms); - auto uploadsCopy = std::make_shared<std::vector<std::string>>(uploads); - auto deletesCopy = std::make_shared<std::vector<std::string>>(deletes); + auto uploadsCopy = uploads; + auto deletesCopy = deletes; std::promise<void> publishPromise; CloudStorage::SetPendingPublish(accountId, appId, publishPromise.get_future().share()); @@ -1894,19 +1948,6 @@ RpcResult HandleCompleteBatch(uint32_t appId, const std::vector<PB::Field>& reqB return; } - // Files this batch uploaded are provider-confirmed durable (the promote's - // UploadBatch returned true, i.e. a 2xx per file). Pass them to the publish so - // it can skip the slow blob re-listing for them -- native trusts the same - // upload result and never re-lists. Deletes/reserved names are excluded; only - // carried-forward files (not in this set) still need a durability check. - std::unordered_set<std::string> confirmedDurable; - confirmedDurable.reserve(uploadsCopy->size()); - for (const auto& filename : *uploadsCopy) { - if (IsReservedBlobFilename(filename)) continue; - confirmedDurable.insert(filename); - } - for (const auto& filename : *deletesCopy) confirmedDurable.erase(filename); - bool publishSucceeded = false; constexpr int kMaxAttempts = 4; constexpr int kBaseDelayMs = 2000; @@ -2019,7 +2060,7 @@ RpcResult HandleCompleteBatch(uint32_t appId, const std::vector<PB::Field>& reqB publishState.cn = publishCN; publishState.appBuildId = publishBuildId; if (CloudStorage::PublishCloudState(accountId, appId, publishState, - /*lockOnly=*/false, &confirmedDurable)) { + /*lockOnly=*/false)) { LOG("[NS] CompleteBatch(pub): publish %d/%d succeeded for app %u at CN=%llu", attempt, kMaxAttempts, appId, (unsigned long long)publishCN); if (!evicted.empty()) { diff --git a/src/common/stats_handlers.cpp b/src/common/stats_handlers.cpp index dce035fa..bc8bcd11 100644 --- a/src/common/stats_handlers.cpp +++ b/src/common/stats_handlers.cpp @@ -229,13 +229,30 @@ std::optional<std::vector<uint8_t>> HandleLegacyGetUserStats( resp.WriteVarint(2, 1); // eresult = OK resp.WriteVarint(3, stats.crcStats); // crc_stats - // schema (field 4) - send if client CRC differs + // schema (field 4): send when CRC differs OR the client has no schema yet + // (clientCrc==0 / schemaVersion==-1); CRC-only would withhold it in the empty-store case. bool sentSchema = false; - if (clientCrc != stats.crcStats && !stats.schema.empty()) { + if (!stats.schema.empty() && + (clientCrc != stats.crcStats || clientCrc == 0 || schemaVersion == -1)) { resp.WriteBytes(4, stats.schema.data(), stats.schema.size()); sentSchema = true; } + // OR each achievement block's `bits` into the matching stat value; MergeAchievements + // updates bits without touching stats[].value (the "9 served, 8 displayed" bug). + for (auto& a : stats.achievements) { + for (auto& s : stats.stats) { + if (s.statId == a.statId) { + if ((s.value | a.bits) != s.value) { + LOG("[Stats] Reconcile stat %u: val 0x%X -> 0x%X (synced from ach bits)", + s.statId, s.value, s.value | a.bits); + s.value |= a.bits; + } + break; + } + } + } + // stats (field 5, repeated submessage): stat_id(1), stat_value(2) for (auto& s : stats.stats) { PB::Writer statMsg; @@ -282,8 +299,8 @@ std::optional<std::vector<uint8_t>> HandleLegacyStoreUserStats2( auto* f5 = PB::FindField(fields, 5); if (f5) explicitReset = (f5->varintVal != 0); - LOG("[Stats] Legacy StoreUserStats2 app=%u gameId=%llu reset=%d", - appId, (unsigned long long)gameId, explicitReset); + LOG("[Stats] Legacy StoreUserStats2 app=%u gameId=%llu reset=%d ns=%d", + appId, (unsigned long long)gameId, explicitReset, IsNamespaceApp(appId) ? 1 : 0); if (explicitReset) { StatsStore::ResetStats(appId); // clears stats/achievements under the store lock @@ -365,10 +382,7 @@ void ObserveGamesPlayed(const uint8_t* body, size_t bodyLen) { // unlock. The body has no timestamps, but Steam writes them to the native blob in // the same store job, so re-read the blob here to sync the new unlocks. void ObserveStoreUserStats(const uint8_t* body, size_t bodyLen) { - // Raw flag only -- this is SHARED (Windows+Linux) code. The ST-gate - // (AchievementsEnabled / StGateOpen) is applied by the WINDOWS callers at - // their hook sites; baking it in here would force Linux OFF since - // steamToolsPresent is structurally always-false on Linux. + // Raw flag only: shared code; Windows callers apply the ST-gate at their hook sites. if (!MetadataSync::syncAchievements.load(std::memory_order_relaxed)) return; auto fields = PB::Parse(body, bodyLen); diff --git a/src/common/stats_store.cpp b/src/common/stats_store.cpp index 611bbaa1..30ea5f5c 100644 --- a/src/common/stats_store.cpp +++ b/src/common/stats_store.cpp @@ -31,6 +31,8 @@ static bool ParseAppStatsJson(const std::string& content, AppStats& out); // Forward decl: count unlocked achievement bits (used by import diagnostics above // its definition). static size_t CountUnlockedAchievements(const std::vector<AchievementBlock>& a); +static bool ReconcileAchievementBits(std::vector<StatEntry>& stats, + std::vector<AchievementBlock>& achievements); static std::unordered_map<uint32_t, AppStats> g_cache; static std::unordered_map<uint32_t, bool> g_dirty; @@ -55,6 +57,17 @@ static std::unordered_set<uint32_t> g_cloudBlobMerged; // to be re-uploaded; cleared by PushAccountBlobIfDirty. Guarded by g_mutex. static bool g_accountBlobDirty = false; +// Apps whose native import was attempted (got data or confirmed none). Distinct +// from a cache entry, which reconcile/session tracking may create empty. g_mutex. +static std::unordered_map<uint32_t, bool> g_importAttempted; + +// Last accountId we cleared state for, to detect a genuine account switch and +// wipe the per-account cache once per switch. 0 = none seen. g_mutex. +static uint32_t g_lastSeenAccountId = 0; + +// Active play sessions: appId -> session start (unix time). Guarded by g_mutex. +static std::unordered_map<uint32_t, uint32_t> g_activeSessions; + // Resolves the current Steam accountId for locating native UserGameStats blobs. static AccountIdProvider g_accountIdProvider; // Fired when an import finds no schema for an app (platform requests it). @@ -67,7 +80,10 @@ static void WriteAppStats(uint32_t appId, const AppStats& stats, bool pushCloud, bool bypassDiskMerge = false); // Playtime helpers (defined below; forward-declared for use in (de)serialization). -static void RecomputePlaytimeTotals(PlaytimeData& pt); +// allowShrink: the migration-repair path intentionally lowers a double-counted +// bucket and needs the totals to follow it down; everywhere else the totals are +// monotonic (floored at their prior value). +static void RecomputePlaytimeTotals(PlaytimeData& pt, bool allowShrink = false); void SetCloudProvider(CloudPullAllFn pullAll, CloudPushAllFn pushAll, CloudPullLegacyFn pullLegacy, @@ -313,10 +329,50 @@ ParseSchemaAchievementNames(const std::vector<BkvNode>& schemaRoot) { return names; } +// Parse per-stat merge strategy from the schema BKV. Schema shape: +// <appId> > stats > <statId> > type_int, resolution_method +// Steam's rules: type_int=4 -> OR (achievements); else resolution_method: 1=OR, 2=MAX, 3=overwrite. +std::unordered_map<uint32_t, StatMerge> +ParseSchemaMergeMethods(const std::vector<BkvNode>& schemaRoot) { + std::unordered_map<uint32_t, StatMerge> out; + + const BkvNode* statsSec = nullptr; + for (const auto& top : schemaRoot) { + if (top.type != BKV_SECTION) continue; + if (auto* s = BkvFind(top.children, "stats")) { statsSec = s; break; } + if (top.name == "stats") { statsSec = ⊤ break; } + } + if (!statsSec) return out; + + for (const auto& stat : statsSec->children) { + if (stat.type != BKV_SECTION) continue; + bool numeric = !stat.name.empty(); + for (char c : stat.name) { if (c < '0' || c > '9') { numeric = false; break; } } + if (!numeric) continue; + uint32_t statId = (uint32_t)strtoul(stat.name.c_str(), nullptr, 10); + + int typeInt = 0, resMeth = 0; + if (const BkvNode* ti = BkvFind(stat.children, "type_int")) + typeInt = (int)ti->intVal; + if (const BkvNode* rm = BkvFind(stat.children, "resolution_method")) + resMeth = (int)rm->intVal; + + if (typeInt == 4) { + out[statId] = StatMerge::BitwiseOr; + } else if (resMeth == 1) { + out[statId] = StatMerge::BitwiseOr; + } else if (resMeth == 2) { + // type_int: 1=INT (signed), 2=FLOAT, 3=AVGRATE (float) + out[statId] = (typeInt >= 2) ? StatMerge::MaxFloat : StatMerge::MaxInt; + } else { + out[statId] = StatMerge::Overwrite; + } + } + return out; +} + } // namespace -// Active play sessions: appId -> session start (unix time) -static std::unordered_map<uint32_t, uint32_t> g_activeSessions; static uint32_t NowUnix() { return (uint32_t)std::chrono::duration_cast<std::chrono::seconds>( @@ -447,14 +503,24 @@ static void ReconcileLocalConfig(const std::string& cloudRoot, const std::string // bucket so it's surfaced and max'd across devices without // double-counting later CR-tracked sessions (keyed by hostname). static const std::string kMigratedBucket = "__migrated_localconfig"; - if (stats.playtime.perDevice.empty() && vdfPlaytime > 0) { + bool migExists = stats.playtime.perDevice.count(kMigratedBucket) > 0; + if (!migExists && vdfPlaytime > 0) { + // Shortfall above all other buckets (e.g. __legacy_* shimmed from + // cloud platform totals) so we never double-count. + uint64_t otherTotal = 0; + for (const auto& [dev, dp] : stats.playtime.perDevice) { + if (dev == kMigratedBucket) continue; + otherTotal += (uint64_t)dp.windows + dp.mac + dp.lin; + } + uint32_t shortfall = (vdfPlaytime > otherTotal) + ? (uint32_t)(vdfPlaytime - otherTotal) : 0u; DevicePlaytime& mig = stats.playtime.perDevice[kMigratedBucket]; #ifdef _WIN32 - mig.windows = vdfPlaytime; + mig.windows = shortfall; #elif defined(__APPLE__) - mig.mac = vdfPlaytime; + mig.mac = shortfall; #else - mig.lin = vdfPlaytime; + mig.lin = shortfall; #endif stats.playtime.minutesLastTwoWeeks = (std::max)(stats.playtime.minutesLastTwoWeeks, vdfPlaytime2wks); @@ -464,6 +530,12 @@ static void ReconcileLocalConfig(const std::string& cloudRoot, const std::string if (!changed) return true; + // Ensure derived totals reflect the authoritative per-device buckets + // before we persist/log: the lastPlayed-only branch above doesn't touch + // them, so the log line (and the on-disk derived fields) would otherwise + // show whatever was loaded rather than the reconciled state. + RecomputePlaytimeTotals(stats.playtime); + // Local-only persist: startup reconcile must not push to the cloud. WriteAppStats(appId, stats, false); reconciled++; @@ -492,6 +564,46 @@ void Init(const std::string& storageRoot, const std::string& steamPath) { LOG("[Stats] Store initialized at %s", g_storageRoot.c_str()); } +bool ResetForAccountSwitch(uint32_t newAccountId) { + std::lock_guard<std::mutex> lock(g_mutex); + // First account seen this process: record it, nothing to wipe. A genuine + // switch is only newAccountId != g_lastSeenAccountId with a prior non-zero + // value. accountId 0 (not-yet-resolved) must never be treated as a switch. + if (newAccountId == 0) return false; + if (g_lastSeenAccountId == 0) { + g_lastSeenAccountId = newAccountId; + return false; + } + if (newAccountId == g_lastSeenAccountId) return false; + + // Genuine account switch: hard-clear all per-account state and DROP pending + // dirty (don't flush) so no post-switch push carries the previous account's stats. + LOG("[Stats] Account switch %u -> %u: clearing %zu cached app(s), %zu dirty, " + "%zu active session(s)", g_lastSeenAccountId, newAccountId, + g_cache.size(), g_dirty.size(), g_activeSessions.size()); + g_cache.clear(); + g_importAttempted.clear(); + g_cloudBlobByApp.clear(); + g_cloudBlobMerged.clear(); + g_dirty.clear(); + g_activeSessions.clear(); + g_accountBlobDirty = false; + g_lastSeenAccountId = newAccountId; + return true; +} + +void ResetForTesting() { + std::lock_guard<std::mutex> lock(g_mutex); + g_cache.clear(); + g_importAttempted.clear(); + g_cloudBlobByApp.clear(); + g_cloudBlobMerged.clear(); + g_dirty.clear(); + g_activeSessions.clear(); + g_accountBlobDirty = false; + g_lastSeenAccountId = 0; +} + // Import Steam's native UserGameStats + schema blobs for an app into `out`. // Returns true if real stat data was imported. Used to seed our authoritative // store on first access (so we can answer GetUserStats with real data). @@ -509,10 +621,10 @@ static bool ImportNativeStats(uint32_t appId, AppStats& out) { if (sf.good()) { out.schema.assign(std::istreambuf_iterator<char>(sf), std::istreambuf_iterator<char>()); + LOG("[Stats] ImportNativeStats app=%u: schema read %zuB", appId, out.schema.size()); } - // No schema on disk -> ask the platform to fetch it from Steam's server - // (so achievement names become available on the next import). - if (out.schema.empty() && g_schemaMissingCb) + // Refresh schema from Steam's server (picks up newly-added achievements). + if (g_schemaMissingCb) g_schemaMissingCb(appId); } @@ -535,30 +647,34 @@ static bool ImportNativeStats(uint32_t appId, AppStats& out) { f.close(); if (blob.empty()) { LOG("[Stats] ImportNativeStats app=%u: native blob is empty (0 bytes)", appId); - return false; + return !out.schema.empty(); } size_t pos = 0, nodeCount = 0; std::vector<BkvNode> root; if (!BkvRead(blob.data(), blob.size(), pos, root, 0, nodeCount)) { LOG("[Stats] ImportNativeStats app=%u: BKV parse failed (%zu bytes)", appId, blob.size()); - return false; + return !out.schema.empty(); } const BkvNode* cache = BkvFind(root, "cache"); if (!cache) { LOG("[Stats] ImportNativeStats app=%u: no 'cache' node in native blob (%zu bytes)", appId, blob.size()); - return false; + return !out.schema.empty(); } - // Parse the schema (if present) for human-readable achievement names. + // Parse the schema (if present) for human-readable achievement names and + // per-stat merge strategies (type_int / resolution_method). std::unordered_map<uint64_t, std::string> achNames; + std::unordered_map<uint32_t, StatMerge> mergeMethods; if (!out.schema.empty()) { size_t spos = 0, snodes = 0; std::vector<BkvNode> sroot; - if (BkvRead(out.schema.data(), out.schema.size(), spos, sroot, 0, snodes)) + if (BkvRead(out.schema.data(), out.schema.size(), spos, sroot, 0, snodes)) { achNames = ParseSchemaAchievementNames(sroot); + mergeMethods = ParseSchemaMergeMethods(sroot); + } } size_t importedStats = 0, importedAch = 0; @@ -575,7 +691,10 @@ static bool ImportNativeStats(uint32_t appId, AppStats& out) { if (!dataNode) continue; uint32_t value = BkvDataAsU32(*dataNode); - out.stats.push_back(StatEntry{statId, value}); + StatMerge mm = StatMerge::Overwrite; + auto mmIt = mergeMethods.find(statId); + if (mmIt != mergeMethods.end()) mm = mmIt->second; + out.stats.push_back(StatEntry{statId, value, mm}); ++importedStats; // Achievement unlock times -> AchievementBlock. The 'data' INT is the @@ -603,10 +722,11 @@ static bool ImportNativeStats(uint32_t appId, AppStats& out) { } } + ReconcileAchievementBits(out.stats, out.achievements); LOG("[Stats] ImportNativeStats app=%u: imported %zu stat(s), %zu achievement block(s) " "(%zu unlocked), schema=%zu bytes", appId, importedStats, importedAch, CountUnlockedAchievements(out.achievements), out.schema.size()); - return importedStats > 0; + return importedStats > 0 || !out.schema.empty(); } // Parse the JSON document (stats/achievements/playtime) into `out`. @@ -627,6 +747,9 @@ static bool ParseAppStatsJson(const std::string& content, AppStats& out) { StatEntry e; e.statId = (uint32_t)item["id"].integer(); e.value = (uint32_t)item["value"].integer(); + const auto& mf = item["merge"]; + if (mf.type == Json::Type::Number) + e.merge = static_cast<StatMerge>((uint8_t)mf.integer()); out.stats.push_back(e); } } @@ -653,6 +776,8 @@ static bool ParseAppStatsJson(const std::string& content, AppStats& out) { } } + ReconcileAchievementBits(out.stats, out.achievements); + const auto& pt = root["playtime"]; if (pt.type == Json::Type::Object) { out.playtime.minutesForever = (uint32_t)pt["minutes_forever"].integer(); @@ -673,10 +798,10 @@ static bool ParseAppStatsJson(const std::string& content, AppStats& out) { dp.lin = (uint32_t)v["linux"].integer(); out.playtime.perDevice[dev] = dp; } - } else { - // Back-compat: a pre-per-device blob carried only platform totals. Shim - // each into a synthetic legacy bucket so sums survive and new writes - // accumulate. + } + // Empty perDevice with real platform totals (legacy blob or off-playtime + // push): shim totals into synthetic legacy buckets so they aren't zeroed. + if (out.playtime.perDevice.empty()) { if (out.playtime.playtimeWindows) out.playtime.perDevice["__legacy_windows"].windows = out.playtime.playtimeWindows; if (out.playtime.playtimeMac) @@ -700,6 +825,8 @@ static std::string BuildAppStatsJson(const AppStats& stats) { Json::Value item = Json::Object(); item.objVal["id"] = Json::Number(s.statId); item.objVal["value"] = Json::Number(s.value); + if (s.merge != StatMerge::Overwrite) + item.objVal["merge"] = Json::Number((uint32_t)s.merge); statsArr.arrVal.push_back(std::move(item)); } root.objVal["stats"] = std::move(statsArr); @@ -766,7 +893,7 @@ static const std::string& ThisDeviceId() { } // Recompute the derived totals from the authoritative per-device sub-totals. -static void RecomputePlaytimeTotals(PlaytimeData& pt) { +static void RecomputePlaytimeTotals(PlaytimeData& pt, bool allowShrink) { uint64_t win = 0, mac = 0, lin = 0; for (const auto& [dev, dp] : pt.perDevice) { win += dp.windows; mac += dp.mac; lin += dp.lin; @@ -774,10 +901,23 @@ static void RecomputePlaytimeTotals(PlaytimeData& pt) { auto clamp32 = [](uint64_t v) -> uint32_t { return v > 0xFFFFFFFFull ? 0xFFFFFFFFu : (uint32_t)v; }; - pt.playtimeWindows = clamp32(win); - pt.playtimeMac = clamp32(mac); - pt.playtimeLinux = clamp32(lin); - pt.minutesForever = clamp32(win + mac + lin); + if (allowShrink) { + // Migration repair: the bucket sum is now authoritative and may be lower + // than before (a double-count was removed). + pt.playtimeWindows = clamp32(win); + pt.playtimeMac = clamp32(mac); + pt.playtimeLinux = clamp32(lin); + pt.minutesForever = clamp32(win + mac + lin); + return; + } + // Playtime only ever goes up. The per-device buckets are the source of truth, + // but a partial/empty perDevice (e.g. a blob parsed before its legacy shim, or + // an off-playtime push that dropped buckets) must never drag a known total + // DOWN. Floor each recomputed total at whatever the struct already carried. + pt.playtimeWindows = (std::max)(pt.playtimeWindows, clamp32(win)); + pt.playtimeMac = (std::max)(pt.playtimeMac, clamp32(mac)); + pt.playtimeLinux = (std::max)(pt.playtimeLinux, clamp32(lin)); + pt.minutesForever = (std::max)(pt.minutesForever, clamp32(win + mac + lin)); } // Accrue minutes onto THIS device's own per-device sub-total for this platform. @@ -893,10 +1033,56 @@ static size_t CountUnlockedAchievements(const std::vector<AchievementBlock>& a) return n; } -// Merge stat values from src into dst, last-writer-wins per statId: src (native, -// authoritative for its own values) overwrites dst on any difference -- not max(), -// since stats are non-monotonic and a reset must win. dst-only statIds are kept; -// src-only are appended. Returns true if dst changed. +// OR each achievement block's `bits` into the matching stat value (MergeAchievements +// updates bits but not stats[].value). Only `a.bits`, so revoked unlocks stay cleared. +static bool ReconcileAchievementBits(std::vector<StatEntry>& stats, + std::vector<AchievementBlock>& achievements) { + bool changed = false; + for (auto& a : achievements) { + // Promote unlock times → bits only when the bit is already set (not orphaned). + // But DO promote bits → stat_val unconditionally (the actual bug fix). + for (auto& s : stats) { + if (s.statId == a.statId) { + if ((s.value | a.bits) != s.value) { s.value |= a.bits; changed = true; } + break; + } + } + } + return changed; +} + +// Resolve the effective merge method for two entries (prefer the one that has a +// non-default method; if both are set they should agree since they come from the +// same schema -- pick the more protective one). +static StatMerge EffectiveMerge(StatMerge a, StatMerge b) { + if (a != StatMerge::Overwrite) return a; + return b; +} + +// Apply schema-aware merge for a single stat value. Returns the merged value. +static uint32_t MergedValue(uint32_t dst, uint32_t src, StatMerge method) { + switch (method) { + case StatMerge::BitwiseOr: + return dst | src; + case StatMerge::MaxInt: { + int32_t d, s; + std::memcpy(&d, &dst, 4); + std::memcpy(&s, &src, 4); + return (s > d) ? src : dst; + } + case StatMerge::MaxFloat: { + float df, sf; + std::memcpy(&df, &dst, 4); + std::memcpy(&sf, &src, 4); + return (sf > df) ? src : dst; + } + default: // Overwrite: source wins + return src; + } +} + +// Merge src into dst per-stat: Overwrite for authoritative native reimport, OR/MAX +// for cloud merge (regression-safe). Returns true if dst changed. static bool MergeStatValues(std::vector<StatEntry>& dst, const std::vector<StatEntry>& src) { bool changed = false; @@ -904,7 +1090,12 @@ static bool MergeStatValues(std::vector<StatEntry>& dst, bool found = false; for (auto& d : dst) { if (d.statId == s.statId) { - if (d.value != s.value) { d.value = s.value; changed = true; } + StatMerge method = EffectiveMerge(d.merge, s.merge); + // Adopt the merge method from src if dst didn't have one. + if (d.merge == StatMerge::Overwrite && s.merge != StatMerge::Overwrite) + d.merge = s.merge; + uint32_t merged = MergedValue(d.value, s.value, method); + if (d.value != merged) { d.value = merged; changed = true; } found = true; break; } @@ -914,6 +1105,29 @@ static bool MergeStatValues(std::vector<StatEntry>& dst, return changed; } +// Fold `incoming` onto `base` (same app) with the in-store monotonic rules: +// union playtime, union achievements, stat-value merge. Pure function. +std::string MergeAppStatsJson(const std::string& base, const std::string& incoming) { + AppStats baseStats, incomingStats; + bool haveBase = !base.empty() && ParseAppStatsJson(base, baseStats); + bool haveIncoming = !incoming.empty() && ParseAppStatsJson(incoming, incomingStats); + + // Degenerate cases: if only one side parses, it is the answer. If neither + // parses, prefer the caller's own outgoing copy. + if (!haveBase && !haveIncoming) return incoming; + if (!haveBase) return incoming; + if (!haveIncoming) return base; + + // Union/max both sides; order-independent for monotonic fields. + MergePlaytime(baseStats.playtime, incomingStats.playtime); + MergeAchievements(baseStats.achievements, incomingStats.achievements); + MergeStatValues(baseStats.stats, incomingStats.stats); + ReconcileAchievementBits(baseStats.stats, baseStats.achievements); + // Recompute crc over the merged result, not either side's stale token. + baseStats.crcStats = ComputeCrcLocked(baseStats); + return BuildAppStatsJson(baseStats); +} + // Disk-only load (stats json + schema sidecar), NO network. Safe to call while // holding g_mutex. Returns true if local data existed. static bool LoadAppStatsLocalOnly(uint32_t appId, AppStats& out) { @@ -959,6 +1173,7 @@ static bool MergeCloudBlobLocked(uint32_t appId, AppStats& out, bool haveLocal) // preserved instead of clobbered on next push. MergeAchievements(out.achievements, cloudStats.achievements); MergeStatValues(out.stats, cloudStats.stats); + ReconcileAchievementBits(out.stats, out.achievements); // Schema is descriptive; adopt cloud's only when we hold none. if (out.schema.empty() && !cloudStats.schema.empty()) out.schema = std::move(cloudStats.schema); @@ -1033,6 +1248,17 @@ static void WriteAppStats(uint32_t appId, const AppStats& stats, bool pushCloud, } } + // Max-merge the cached cloud playtime so an achievement-path push carrying + // empty playtime can't regress account-wide minutes. Skipped on bypassDiskMerge. + if (pushCloud && !bypassDiskMerge) { + auto it = g_cloudBlobByApp.find(appId); + if (it != g_cloudBlobByApp.end() && !it->second.empty()) { + AppStats cloudPrior; + if (ParseAppStatsJson(it->second, cloudPrior)) + MergePlaytime(merged.playtime, cloudPrior.playtime); + } + } + std::string json = BuildAppStatsJson(merged); std::ofstream f(path, std::ios::trunc); @@ -1086,12 +1312,6 @@ void SaveAppStats(uint32_t appId, const AppStats& stats) { WriteAppStats(appId, stats, true); } -// Apps for which native import has been successfully attempted (imported real -// data OR confirmed Steam genuinely has none). Distinct from a cache entry, -// because reconcile/session-tracking can create an empty cache entry before any -// import runs -- we must still import on first stats access in that case. -static std::unordered_map<uint32_t, bool> g_importAttempted; - // Seed `stats` from Steam's native UserGameStats blob if we hold no stat data // yet. Retries across calls while accountId is unavailable (returns 0); only // marks "attempted" once we had a real accountId to look with. Caller holds mutex. @@ -1105,6 +1325,10 @@ static void EnsureNativeImportLocked(uint32_t appId, AppStats& stats) { // schema is still missing (e.g. SteamTools wrote it after our first try), keep // retrying so cloud-adopted achievements eventually become serveable. if (g_importAttempted.count(appId) && !stats.schema.empty()) return; + LOG("[Stats] EnsureNativeImport app=%u: stats=%zu schema=%zuB attempted=%d accountReady=%d", + appId, stats.stats.size(), stats.schema.size(), + (int)g_importAttempted.count(appId), + (g_accountIdProvider && g_accountIdProvider() != 0) ? 1 : 0); if (!g_accountIdProvider || g_accountIdProvider() == 0) { // accountId not ready yet (not logged in) -- don't mark attempted; retry later. return; @@ -1116,7 +1340,11 @@ static void EnsureNativeImportLocked(uint32_t appId, AppStats& stats) { g_importAttempted[appId] = true; // accountId was valid; this is a definitive attempt if (imported) { // Adopt the schema even on a schema-only import (no native stats blob). - if (!native.schema.empty()) stats.schema = std::move(native.schema); + bool schemaAdopted = false; + if (!native.schema.empty()) { + stats.schema = std::move(native.schema); + schemaAdopted = true; + } // Merge, don't overwrite: cloud-adopted state may hold more unlocks than this // device's native blob (e.g. DOOM 3 BFG -- cloud 9, local native 8). A wholesale // assign dropped the cloud-only unlock. Union achievements (monotonic); stat @@ -1128,6 +1356,7 @@ static void EnsureNativeImportLocked(uint32_t appId, AppStats& stats) { merged |= MergeAchievements(stats.achievements, native.achievements); if (!native.stats.empty()) merged |= MergeStatValues(stats.stats, native.stats); + merged |= ReconcileAchievementBits(stats.stats, stats.achievements); size_t haveAfter = CountUnlockedAchievements(stats.achievements); // If cloud (haveBefore) held more than native, the merge must keep the // superset -- haveAfter dropping below haveBefore means an unlock was lost. @@ -1135,7 +1364,7 @@ static void EnsureNativeImportLocked(uint32_t appId, AppStats& stats) { "(stats=%zu schema=%zuB)%s", appId, haveBefore, nativeHas, haveAfter, stats.stats.size(), stats.schema.size(), haveAfter < haveBefore ? " [WARN: unlock regressed]" : ""); - if (merged || !native.schema.empty()) { + if (merged || schemaAdopted) { stats.crcStats = ComputeCrcLocked(stats); g_dirty[appId] = true; SaveAppStats(appId, stats); @@ -1156,6 +1385,7 @@ static bool ReimportNativeStatsLocked(uint32_t appId, AppStats& stats) { bool changed = MergeStatValues(stats.stats, native.stats); if (MergeAchievements(stats.achievements, native.achievements)) changed = true; + if (ReconcileAchievementBits(stats.stats, stats.achievements)) changed = true; if (stats.schema.empty() && !native.schema.empty()) { stats.schema = std::move(native.schema); changed = true; @@ -1171,12 +1401,15 @@ void CaptureNativeUnlocks(uint32_t appId) { auto& stats = g_cache[appId]; // Make sure the base data exists (first observation may precede any import). if (stats.stats.empty()) EnsureNativeImportLocked(appId, stats); + size_t beforeCount = CountUnlockedAchievements(stats.achievements); if (ReimportNativeStatsLocked(appId, stats)) { + size_t afterCount = CountUnlockedAchievements(stats.achievements); g_dirty[appId] = true; SaveAppStats(appId, stats); // updates account blob + dirty flag g_dirty[appId] = false; changed = true; - LOG("[Stats] Captured native unlocks for app %u (crc=%u)", appId, stats.crcStats); + LOG("[Stats] Captured native unlocks for app %u (crc=%u) %zu->%zu unlocked", + appId, stats.crcStats, beforeCount, afterCount); } } // A genuine unlock just landed -- push the account blob now (off-lock). @@ -1236,6 +1469,10 @@ void ResetStats(uint32_t appId) { stats.achievements.clear(); stats.crcStats = 0; g_dirty[appId] = true; + // Persist the cleared record as a cloud tombstone so the monotonic union + // merge can't resurrect the old bits on the next fetch. + WriteAppStats(appId, stats, /*pushCloud=*/true); + g_dirty[appId] = false; } // One-time migration off the legacy per-app cloud layout: for each managed app @@ -1317,7 +1554,7 @@ static void ApplyLegacyPlaytime(uint32_t appId, uint32_t mins, (std::max)(stats.playtime.minutesLastTwoWeeks, twoWks); if (lastPlayed > stats.playtime.lastPlayedTime) stats.playtime.lastPlayedTime = lastPlayed; - RecomputePlaytimeTotals(stats.playtime); + RecomputePlaytimeTotals(stats.playtime, /*allowShrink=*/true); g_dirty[appId] = true; // bypassDiskMerge: this write must be allowed to shrink __migrated_localconfig. WriteAppStats(appId, stats, true, /*bypassDiskMerge=*/true); @@ -1451,6 +1688,70 @@ void SeedApps(const std::vector<uint32_t>& appIds) { PushAccountBlobIfDirty(); } +void RetryNativeImportsAfterLogin() { + // Needs a resolved accountId; the whole point is to run AFTER login so the + // import that the boot-time sweep skipped (accountReady=0) can succeed. + std::string steamPath; + { + std::lock_guard<std::mutex> lock(g_mutex); + if (!g_accountIdProvider || g_accountIdProvider() == 0) { + LOG("[Stats] RetryNativeImports: accountId not ready, skipping"); + return; + } + if (g_steamPath.empty() || !g_isNamespaceApp) { + LOG("[Stats] RetryNativeImports: steamPath/namespace predicate unset, skipping"); + return; + } + steamPath = g_steamPath; + } + + // Enumerate native schema blobs: appcache/stats/UserGameStatsSchema_<appId>.bin. + // Each such app has a schema on disk; a namespace app among them that never + // imported (boot sweep ran pre-login) is exactly what we want to retry. + std::error_code ec; + fs::path statsDir = FileUtil::Utf8ToPath(steamPath) / "appcache" / "stats"; + if (!fs::is_directory(statsDir, ec)) { + LOG("[Stats] RetryNativeImports: %s not a directory, skipping", statsDir.string().c_str()); + return; + } + + const std::string kPrefix = "UserGameStatsSchema_"; + int considered = 0, imported = 0; + for (const auto& entry : fs::directory_iterator(statsDir, ec)) { + if (ec) break; + if (!entry.is_regular_file(ec)) continue; + std::string fname = entry.path().filename().string(); + if (fname.rfind(kPrefix, 0) != 0) continue; + // Strip prefix and ".bin" suffix to recover the appId. + size_t dot = fname.rfind(".bin"); + if (dot == std::string::npos || dot <= kPrefix.size()) continue; + std::string idStr = fname.substr(kPrefix.size(), dot - kPrefix.size()); + if (idStr.empty()) continue; + uint32_t appId = 0; + bool numeric = true; + for (char c : idStr) { + if (c < '0' || c > '9') { numeric = false; break; } + appId = appId * 10 + (uint32_t)(c - '0'); + } + if (!numeric || appId == 0) continue; + if (!g_isNamespaceApp(appId)) continue; + ++considered; + // Sample emptiness before/after the import under one lock hold so only an + // empty->populated transition counts as a genuine new import. + std::lock_guard<std::mutex> lock(g_mutex); + auto preIt = g_cache.find(appId); + bool hadData = preIt != g_cache.end() && + (!preIt->second.stats.empty() || !preIt->second.schema.empty()); + AppStats& s = GetOrCreateLocked(appId); + bool hasData = !s.stats.empty() || !s.schema.empty(); + if (!hadData && hasData) ++imported; + } + LOG("[Stats] RetryNativeImports: considered %d namespace app(s) with schema, %d have data", + considered, imported); + // Deliberately NO PushAccountBlobIfDirty() here: the sweep only flags dirty; + // the next natural push (unlock capture / EndSession / FlushAll) uploads. +} + std::vector<uint32_t> RefreshFromCloud(const std::vector<uint32_t>& appIds) { std::vector<uint32_t> changed; // One network read for the whole account, then iterate from the cache. @@ -1484,6 +1785,7 @@ std::vector<uint32_t> RefreshFromCloud(const std::vector<uint32_t>& appIds) { // overwrites it on the cloud. bool achChanged = MergeAchievements(cur.achievements, cloudStats.achievements); bool statChanged = MergeStatValues(cur.stats, cloudStats.stats); + if (ReconcileAchievementBits(cur.stats, cur.achievements)) { achChanged = true; statChanged = true; } bool playtimeChanged = (cur.playtime.minutesForever != before.minutesForever || cur.playtime.lastPlayedTime != before.lastPlayedTime); // Another device advanced this app -> persist locally and report. @@ -1618,18 +1920,26 @@ uint32_t SetAchievement(uint32_t appId, uint32_t statId, uint32_t bit, uint32_t void SetSchema(uint32_t appId, const uint8_t* data, size_t len) { std::lock_guard<std::mutex> lock(g_mutex); - auto& stats = g_cache[appId]; + // Seed via GetOrCreateLocked, not operator[]: a first-touch SetSchema must not + // insert a blank record that bypasses the cloud-blob merge (other devices' + // achievements would then be missing from the entry a later push publishes). + AppStats& stats = GetOrCreateLocked(appId); stats.schema.assign(data, data + len); g_dirty[appId] = true; } const std::vector<uint8_t>& GetSchema(uint32_t appId) { std::lock_guard<std::mutex> lock(g_mutex); - return g_cache[appId].schema; + // Seed before returning: operator[] would default-construct an unmerged blank + // entry on a miss (same hazard the mutating setters avoid). + return GetOrCreateLocked(appId).schema; } void StartSession(uint32_t appId) { std::lock_guard<std::mutex> lock(g_mutex); + // Flush any open session first: native Steam resumes the existing per-app + // timer rather than re-arming, so a duplicate GamesPlayed can't drop minutes. + EndSessionLocked(appId); g_activeSessions[appId] = NowUnix(); // Seed via GetOrCreateLocked (local + cloud blob + native). Hand-rolling g_cache[] // skipped the cloud merge on reconcile-touched entries, so EndSession then pushed @@ -1640,40 +1950,53 @@ void StartSession(uint32_t appId) { LOG("[Stats] Session started for app %u", appId); } +// Accrue + persist the in-flight session for `appId`, erasing it from +// g_activeSessions. Caller holds g_mutex. Returns false if no session was open. +// Shared by EndSession and the re-entrant-StartSession flush. +static bool EndSessionLocked(uint32_t appId) { + auto it = g_activeSessions.find(appId); + if (it == g_activeSessions.end()) return false; + + uint32_t now = NowUnix(); + uint32_t elapsed = (now > it->second) ? (now - it->second) : 0; + // Wall-clock sanity cap: a forward clock jump (NTP correction, suspend/ + // resume) must not over-count a session. Backward jumps already clamp to 0. + const uint32_t kMaxSessionSecs = 24u * 60 * 60; + if (elapsed > kMaxSessionSecs) elapsed = kMaxSessionSecs; + uint32_t minutes = elapsed / 60; + g_activeSessions.erase(it); + + // Seed first so the cloud blob's cross-device unlocks are in the record we + // build+push (else EndSession drops another device's achievements). + AppStats& stats = GetOrCreateLocked(appId); + // Accrue onto THIS device's own per-device sub-total (keyed by device id), so + // a session here can never overwrite another device's contribution -- even a + // same-platform device's -- under the last-writer-wins cloud blob. + AccrueLocalPlaytime(stats.playtime, minutes); + // Do NOT accumulate minutesLastTwoWeeks: native Steam reads Playtime2wks as an + // authoritative stored VDF field, surfaced via ReconcileLocalConfig. + stats.playtime.lastPlayedTime = now; + + // Steam flushes the native blob on game close; merge any new unlocks (also + // catches another device's). Gated on sync_achievements, not sync_playtime + // (EndSession runs under the latter). + if (MetadataSync::syncAchievements.load(std::memory_order_relaxed) && + ReimportNativeStatsLocked(appId, stats)) + LOG("[Stats] Session end: merged new native achievements/stats for app %u (crc=%u)", + appId, stats.crcStats); + + g_dirty[appId] = true; + SaveAppStats(appId, stats); // updates account blob + dirty flag + g_dirty[appId] = false; + LOG("[Stats] Session ended for app %u: +%u min (total %u)", + appId, minutes, stats.playtime.minutesForever); + return true; +} + void EndSession(uint32_t appId) { { std::lock_guard<std::mutex> lock(g_mutex); - auto it = g_activeSessions.find(appId); - if (it == g_activeSessions.end()) return; - - uint32_t now = NowUnix(); - uint32_t elapsed = (now > it->second) ? (now - it->second) : 0; - uint32_t minutes = elapsed / 60; - g_activeSessions.erase(it); - - // Seed first so the cloud blob's cross-device unlocks are in the record we - // build+push (else EndSession drops another device's achievements). - AppStats& stats = GetOrCreateLocked(appId); - // Accrue onto THIS device's own per-device sub-total (keyed by device id), so - // a session here can never overwrite another device's contribution -- even a - // same-platform device's -- under the last-writer-wins cloud blob. - AccrueLocalPlaytime(stats.playtime, minutes); - stats.playtime.minutesLastTwoWeeks += minutes; - stats.playtime.lastPlayedTime = now; - - // Steam flushes the native blob on game close; merge any new unlocks (also - // catches another device's). Gated on sync_achievements, not sync_playtime - // (EndSession runs under the latter). - if (MetadataSync::syncAchievements.load(std::memory_order_relaxed) && - ReimportNativeStatsLocked(appId, stats)) - LOG("[Stats] Session end: merged new native achievements/stats for app %u (crc=%u)", - appId, stats.crcStats); - - g_dirty[appId] = true; - SaveAppStats(appId, stats); // updates account blob + dirty flag - g_dirty[appId] = false; - LOG("[Stats] Session ended for app %u: +%u min (total %u)", - appId, minutes, stats.playtime.minutesForever); + if (!EndSessionLocked(appId)) return; } // Push the account blob off-lock (the platform pushAll queues it async, so // this never blocks the net thread at game close). @@ -1682,16 +2005,24 @@ void EndSession(uint32_t appId) { PlaytimeData GetPlaytime(uint32_t appId) { std::lock_guard<std::mutex> lock(g_mutex); - auto& stats = g_cache[appId]; + // Seed via GetOrCreateLocked, not operator[]: a read on a not-yet-touched app + // must not insert a blank unmerged cache entry (which a later lock-holder could + // observe in place of the real cloud-merged data). + AppStats& stats = GetOrCreateLocked(appId); PlaytimeData pt = stats.playtime; auto it = g_activeSessions.find(appId); if (it != g_activeSessions.end()) { uint32_t now = NowUnix(); uint32_t elapsed = (now > it->second) ? (now - it->second) : 0; + // Wall-clock sanity cap, matching EndSession: a forward clock jump must not + // over-count the in-progress session's live playtime estimate. + const uint32_t kMaxSessionSecs = 24u * 60 * 60; + if (elapsed > kMaxSessionSecs) elapsed = kMaxSessionSecs; uint32_t minutes = elapsed / 60; pt.minutesForever += minutes; - pt.minutesLastTwoWeeks += minutes; + // minutesLastTwoWeeks is authoritative (server-maintained rolling window); + // do not pad the live estimate into it (see EndSessionLocked). } return pt; } diff --git a/src/common/stats_store.h b/src/common/stats_store.h index 64c01a35..52e1206a 100644 --- a/src/common/stats_store.h +++ b/src/common/stats_store.h @@ -32,9 +32,18 @@ void SetCloudProvider(CloudPullAllFn pullAll, CloudPushAllFn pushAll, CloudPullLegacyFn pullLegacy = nullptr, CloudPullLegacyPlaytimeFn pullLegacyPlaytime = nullptr); +// Merge strategies for stat values (mirrors Steam's resolution_method + type_int). +enum class StatMerge : uint8_t { + Overwrite = 0, // last-writer-wins (resolution_method=3 or unknown) + BitwiseOr = 1, // type_int=4 (achievements) or resolution_method=1 + MaxInt = 2, // resolution_method=2, type_int=1 (signed int max) + MaxFloat = 3, // resolution_method=2, type_int=2/3 (float max) +}; + struct StatEntry { uint32_t statId; uint32_t value; + StatMerge merge = StatMerge::Overwrite; }; struct AchievementUnlock { @@ -108,6 +117,18 @@ void SetNamespacePredicate(NamespacePredicate pred); // GetLastPlayedTimes has data before launch. Requires a logged-in accountId. void SeedApps(const std::vector<uint32_t>& appIds); +// Retry native imports for namespace apps with an on-disk schema that the +// boot-time sweep skipped (accountId not yet known). Flags dirty, no push. +void RetryNativeImportsAfterLogin(); + +// Clear per-account in-memory caches on a Steam account switch so one account's +// stats don't leak into the next. No push. Returns true if state was cleared. +bool ResetForAccountSwitch(uint32_t newAccountId); + +// Test-only: reset all per-account state plus the last-seen account id so tests +// start clean regardless of order. Never call from production. +void ResetForTesting(); + // Re-pull + merge each app's cloud blob; returns apps whose playtime advanced // (another device played) for a live notification. Runs in the background. std::vector<uint32_t> RefreshFromCloud(const std::vector<uint32_t>& appIds); @@ -146,10 +167,8 @@ uint32_t SetAchievement(uint32_t appId, uint32_t statId, uint32_t bit, uint32_t void SetSchema(uint32_t appId, const uint8_t* data, size_t len); const std::vector<uint8_t>& GetSchema(uint32_t appId); -// Re-read Steam's native blob for an app and merge any newly unlocked -// achievements / updated stat values into the store, then push to the cloud if -// anything changed. Called when an achievement-store message is observed on the -// wire (the genuine unlock event). Safe to call from the network thread. +// Re-read Steam's native blob, merge new unlocks/stat values, push if changed. +// Called when an achievement-store message is seen on the wire. void CaptureNativeUnlocks(uint32_t appId); // Playtime tracking @@ -163,4 +182,9 @@ std::vector<uint32_t> GetTrackedApps(); // Flush all dirty apps to disk. void FlushAll(); +// Merge two app-stats JSON docs (same app): monotonic playtime, union +// achievements, stat-value merge. Returns merged JSON; prefers incoming on parse +// failure. Used by the push layer to avoid clobbering another device's upload. +std::string MergeAppStatsJson(const std::string& base, const std::string& incoming); + } // namespace StatsStore diff --git a/src/common/steam_kv_injector.cpp b/src/common/steam_kv_injector.cpp index 55796b36..2f0e101d 100644 --- a/src/common/steam_kv_injector.cpp +++ b/src/common/steam_kv_injector.cpp @@ -454,6 +454,7 @@ struct FindSteamCtx { uintptr_t base = 0; uintptr_t textStart = 0; uintptr_t textEnd = 0; + uintptr_t moduleEnd = 0; // highest mapped VA across all PT_LOAD segments }; static int FindSteamPhdrCb(struct dl_phdr_info* info, size_t, void* data) { @@ -466,16 +467,17 @@ static int FindSteamPhdrCb(struct dl_phdr_info* info, size_t, void* data) { auto* ctx = static_cast<FindSteamCtx*>(data); ctx->base = info->dlpi_addr; - // Find the largest PF_X (executable) segment as the .text region. + // Largest PF_X segment is .text; track the highest mapped VA for bounds checks. for (int i = 0; i < info->dlpi_phnum; ++i) { const auto& ph = info->dlpi_phdr[i]; - if (ph.p_type == PT_LOAD && (ph.p_flags & PF_X)) { - uintptr_t segStart = info->dlpi_addr + ph.p_vaddr; - uintptr_t segEnd = segStart + ph.p_memsz; - if ((segEnd - segStart) > (ctx->textEnd - ctx->textStart)) { - ctx->textStart = segStart; - ctx->textEnd = segEnd; - } + if (ph.p_type != PT_LOAD) continue; + uintptr_t segStart = info->dlpi_addr + ph.p_vaddr; + uintptr_t segEnd = segStart + ph.p_memsz; + if (segEnd > ctx->moduleEnd) ctx->moduleEnd = segEnd; + if ((ph.p_flags & PF_X) && + (segEnd - segStart) > (ctx->textEnd - ctx->textStart)) { + ctx->textStart = segStart; + ctx->textEnd = segEnd; } } return 1; @@ -511,47 +513,51 @@ static uintptr_t DecodeRelCall(uintptr_t addr) { return addr + 5 + rel; } +// Match "lea reg, [ebx + disp32]" (8D /r with mod=10, rm=011); returns dest reg. +static bool IsLeaEbxDisp32(const uint8_t* p, uint8_t& outReg) { + if (p[0] != 0x8D || (p[1] & 0xC7) != 0x83) return false; + outReg = (p[1] >> 3) & 7; + return true; +} + // Search for "add reg, 0xB88" (81 C0-C7 88 0B 00 00) in a range, then // back-trace to find the GOT-relative lea that loads the global engine ptr. // Returns the absolute address of the global (the dereferenced GOT entry). static uintptr_t FindGlobalEnginePtr(uintptr_t textStart, uintptr_t textEnd, - uintptr_t soBase) { + uintptr_t soBase, uintptr_t soEnd) { // Pattern: 81 Cx 88 0B 00 00 where x is C0-C7 (add eax..edi, 0xB88) const uint8_t* mem = reinterpret_cast<const uint8_t*>(textStart); size_t len = textEnd - textStart; - for (size_t i = 0; i + 6 <= len; ++i) { + for (size_t i = 8; i + 6 <= len; ++i) { if (mem[i] != 0x81) continue; uint8_t modrm = mem[i + 1]; if (modrm < 0xC0 || modrm > 0xC7) continue; if (mem[i + 2] != 0x88 || mem[i + 3] != 0x0B || mem[i + 4] != 0x00 || mem[i + 5] != 0x00) continue; - // Found add reg, 0xB88. Back-trace: expect "mov reg, [eax]" (2 bytes) - // and before that "lea eax, [ebx + offset]" (6 bytes: 8D 83 xx xx xx xx). + // Found add reg, 0xB88. Back-trace: expect "mov reg, [reg2]" (2 bytes) + // and before that "lea reg2, [ebx + disp32]" (6 bytes: 8D /r xx xx xx xx). // The lea loads the address of the global from GOT-relative addressing. uintptr_t addAddr = textStart + i; - // Check the 2 bytes before: should be "mov reg, [eax]" (8B xx) - if (i < 2) continue; + // 2 bytes before: "mov reg, [reg2]" (8B /r, mod=00). Accept any base reg + // except esp(4)/ebp(5), whose modrm=00 encodings mean SIB/disp32 instead. if (mem[i - 2] != 0x8B) continue; - // The modrm byte for "mov reg, [eax]" is (reg<<3)|0x00 with mod=00,rm=000 uint8_t movModrm = mem[i - 1]; - if ((movModrm & 0xC7) != 0x00 && (movModrm & 0xC7) != 0x28) continue; - // Could be mov ebp,[eax] (8B 28) or mov eax,[eax] (8B 00) etc. - - // Check 6 bytes before that: "lea eax, [ebx + disp32]" = 8D 83 xx xx xx xx - if (i < 8) continue; - if (mem[i - 8] != 0x8D || mem[i - 7] != 0x83) continue; - - // Found lea eax, [ebx + disp32] -- ebx-relative global address. - // In 32-bit PIC, ebx = _GLOBAL_OFFSET_TABLE_ set by the function's thunk - // (call __x86.get_pc_thunk.bx; add ebx, imm32). The lea computes the - // absolute address of the engine-global variable (.bss), which is the - // `void**` we need. Decode it for real instead of using a hardcoded fallback - // RVA -- the fallback is build-specific and silently wrong on other builds. + if ((movModrm >> 6) != 0) continue; // require mod=00 ([reg]) + uint8_t movBase = movModrm & 7; + if (movBase == 4 || movBase == 5) continue; + + // 6 bytes before that: "lea reg2, [ebx + disp32]"; reg2 must feed the mov. + uint8_t leaReg = 0; + if (!IsLeaEbxDisp32(&mem[i - 8], leaReg)) continue; + if (leaReg != movBase) continue; + + // 32-bit PIC: ebx = _GLOBAL_OFFSET_TABLE_; the lea computes the .bss global's + // absolute address. Decode it (a hardcoded fallback RVA is build-specific). int32_t leaDisp; - memcpy(&leaDisp, &mem[i - 6], 4); // disp32 of lea eax,[ebx+disp] + memcpy(&leaDisp, &mem[i - 6], 4); // disp32 of lea reg2,[ebx+disp] LOG("[KvInjector] SigScan: found 'add reg, 0xB88' at 0x%lx (base+0x%lx), " "lea disp=0x%lx", @@ -584,6 +590,14 @@ static uintptr_t FindGlobalEnginePtr(uintptr_t textStart, uintptr_t textEnd, } uintptr_t engineVar = gotBase + (uint32_t)leaDisp; + if (engineVar <= soBase || (soEnd != 0 && engineVar >= soEnd)) { + // Decoded address outside the module's mapped range -- a false match; + // keep scanning rather than dereferencing garbage. + LOG("[KvInjector] SigScan: engine-global 0x%lx out of module bounds " + "[0x%lx,0x%lx); skipping match", + (unsigned long)engineVar, (unsigned long)soBase, (unsigned long)soEnd); + continue; + } LOG("[KvInjector] SigScan: decoded engine-global var at 0x%lx (base+0x%lx)", (unsigned long)engineVar, (unsigned long)(engineVar - soBase)); return engineVar; @@ -733,7 +747,8 @@ bool Init() { (unsigned long)kvSetI32, (unsigned long)(kvSetI32 - base)); } - uintptr_t globalEng = FindGlobalEnginePtr(ctx.textStart, ctx.textEnd, base); + uintptr_t globalEng = FindGlobalEnginePtr(ctx.textStart, ctx.textEnd, base, + ctx.moduleEnd); if (globalEng) { LOG("[KvInjector] SigScan: globalEnginePtr at 0x%lx (base+0x%lx)", (unsigned long)globalEng, (unsigned long)(globalEng - base)); @@ -1067,4 +1082,12 @@ bool InjectSaveFiles(uint32_t appId, const std::vector<SaveFileRule>& rules) { #endif // _WIN32 +void** GetEngineGlobalPtr() { +#ifdef _WIN32 + return nullptr; // Windows uses a different resolution path +#else + return g_r.globalEnginePtr; +#endif +} + } // namespace SteamKvInjector diff --git a/src/common/steam_kv_injector.h b/src/common/steam_kv_injector.h index 3097fe4d..6addd6d0 100644 --- a/src/common/steam_kv_injector.h +++ b/src/common/steam_kv_injector.h @@ -36,4 +36,9 @@ struct SaveFileRule { // Inject savefiles rules into UFS KV. Won't clobber existing children. bool InjectSaveFiles(uint32_t appId, const std::vector<SaveFileRule>& rules); +// Dynamically-resolved engine global pointer (Linux only). Returns the address +// of the pointer variable (void**) so callers can dereference it at call time. +// Null if Init hasn't resolved it yet. +void** GetEngineGlobalPtr(); + } // namespace SteamKvInjector diff --git a/src/platform/linux/achievement_inject.cpp b/src/platform/linux/achievement_inject.cpp index 48492533..dd3946db 100644 --- a/src/platform/linux/achievement_inject.cpp +++ b/src/platform/linux/achievement_inject.cpp @@ -1,6 +1,8 @@ #include "achievement_inject.h" +#include "metadata_sync.h" #include "stats_handlers.h" #include "cloud_intercept.h" +#include "steam_kv_injector.h" #include "protobuf.h" #include "log.h" @@ -10,6 +12,7 @@ #include <csignal> #include <mutex> #include <queue> +#include <vector> #include <unistd.h> namespace AchievementInject { @@ -25,8 +28,8 @@ using BRouteMsgFn = char(*)(int jobMgr, int connCtx, void* wrappedPkt, void* static constexpr uintptr_t RVA_WRAP_PACKET = 0x2AC1EC0; static constexpr uintptr_t RVA_BROUTE = 0x2A6A1E0; -static constexpr uintptr_t RVA_ENGINE_GLOBAL = 0x2ECDB40; -static constexpr uintptr_t RVA_JOBCUR_GLOBAL = 0x2F00A60; // g_pJobCur (current job) +static constexpr uintptr_t RVA_ENGINE_GLOBAL = 0x2ED1BC0; // resolved dynamically via KvInjector +static constexpr uintptr_t RVA_JOBCUR_GLOBAL = 0x2F04C20; // g_pJobCur (current job) static constexpr uint32_t ENGINE_OFF_JOBMGR = 0x1B8; // jobMgr = *engine + 0x1B8 static constexpr uint32_t CCM_OFF_CONNCTX = 1404; // connCtx = *(cmInterface+1404) static constexpr uint32_t JOB_OFF_JOBID = 16; // CJob+16 = jobid (CJob ctor sub_2A5A170) @@ -92,11 +95,14 @@ class CallGuard { struct sigaction m_bus = {}; }; -// One pending 819 response: the 818's jobid + the app + the captured cmInterface. +// One pending 819 response: the 818's jobid + the app + the captured cmInterface +// + the prebuilt 819 body. The body is built in ObserveOutbound so we only block +// the outbound 818 when we can actually serve a reply. struct Pending { uint64_t jobIdTarget; uint32_t appId; void* cmInterface; + std::vector<uint8_t> body; }; static std::queue<Pending> g_queue; static std::mutex g_queueMutex; @@ -119,8 +125,8 @@ bool Resolve(uintptr_t base, size_t size, SerializeBodyFn serialize) { bool Ready() { return g_wrapPacket && g_bRoute && g_base && g_serializeBody; } -void ObserveOutbound(uint32_t emsg, void* msgObj, void* cmInterface) { - if (!Ready() || emsg != EMSG_GET_USER_STATS || !msgObj || !cmInterface) return; +int ObserveOutbound(uint32_t emsg, void* msgObj, void* cmInterface) { + if (!Ready() || emsg != EMSG_GET_USER_STATS || !msgObj || !cmInterface) return 0; uint64_t jobId = 0; void* bodyObj = nullptr; @@ -129,28 +135,43 @@ void ObserveOutbound(uint32_t emsg, void* msgObj, void* cmInterface) { // Read it from the sending coroutine instead: g_pJobCur is the // CAPIJobRequestUserStats, jobid at CJob+16 (ctor sub_2A5A170). CallGuard guard; - if (sigsetjmp(g_jmp, 1) != 0) return; + if (sigsetjmp(g_jmp, 1) != 0) return 0; uintptr_t jobCur = *(uintptr_t*)(g_base + RVA_JOBCUR_GLOBAL); if (jobCur) jobId = *(uint64_t*)(jobCur + JOB_OFF_JOBID); bodyObj = *(void**)((uint8_t*)msgObj + 32); } - if (!bodyObj) return; + if (!bodyObj) return 0; // game_id is field 1 (fixed64) of the body. Serialize + parse it. size_t blen = 0; const uint8_t* bbytes = g_serializeBody(bodyObj, &blen); - if (!bbytes || blen == 0) return; + if (!bbytes || blen == 0) return 0; auto fields = PB::Parse(bbytes, blen); auto* f1 = PB::FindField(fields, 1); uint32_t appId = f1 ? (uint32_t)(f1->varintVal & 0xFFFFFF) : 0; - if (appId == 0 || !CloudIntercept::IsNamespaceApp(appId)) return; + if (appId == 0 || !CloudIntercept::IsNamespaceApp(appId)) return 0; + + // Block the 818 only when we can serve a reply; otherwise the jobid hangs and + // stalls Steam's shared stats worker for all apps. + PB::Writer reqBody; + reqBody.WriteFixed64(1, (uint64_t)appId); // game_id + reqBody.WriteVarint(2, 0); // crc_stats = 0 (force full send) + reqBody.WriteVarint(3, (uint64_t)(int64_t)-1);// schema_local_version = -1 + auto reqBytes = reqBody.Data(); + auto built = StatsHandlers::HandleLegacyGetUserStats(reqBytes.data(), reqBytes.size(), 0); + if (!built.has_value() || built->empty()) { + LOG("[Stats] Observed legacy GetUserStats(818) app=%u jobid=%llu -> nothing to serve, " + "passing through to Valve (avoids hung job)", appId, (unsigned long long)jobId); + return 0; // let Valve answer; its 819 also carries the schema + } { std::lock_guard<std::mutex> lock(g_queueMutex); - g_queue.push(Pending{jobId, appId, cmInterface}); + g_queue.push(Pending{jobId, appId, cmInterface, std::move(*built)}); } - LOG("[Stats] Observed legacy GetUserStats(818) app=%u jobid=%llu -> queued 819", + LOG("[Stats] Observed legacy GetUserStats(818) app=%u jobid=%llu -> queued 819 (blocking send)", appId, (unsigned long long)jobId); + return 1; // block the send -- we are the sole server for this app } // Build the raw CM wire bytes for a 819 response: [EMsg|protoflag][hdrLen][header] @@ -185,22 +206,12 @@ static std::vector<uint8_t> BuildWirePacket(uint64_t jobIdTarget, struct RawPkt { uint32_t pad0; const uint8_t* data; uint32_t size; uint32_t refcount; uint32_t copyBuf; uint32_t pad[3]; }; static void RouteOne(const Pending& p) { - // Build the 819 body from our store via the shared legacy handler. We hand it - // a minimal request body (game_id + crc=0 to force a full send) so it resolves - // the app and emits schema + stats + achievement_blocks. - PB::Writer reqBody; - reqBody.WriteFixed64(1, (uint64_t)p.appId); // game_id - reqBody.WriteVarint(2, 0); // crc_stats = 0 (force send) - reqBody.WriteVarint(3, (uint64_t)(int64_t)-1);// schema_local_version = -1 - auto reqBytes = reqBody.Data(); - - auto built = StatsHandlers::HandleLegacyGetUserStats(reqBytes.data(), reqBytes.size(), 0); - if (!built.has_value() || built->empty()) { - LOG("[Stats] 819 for app=%u: store had nothing to serve", p.appId); + if (p.body.empty()) { + LOG("[Stats] 819 for app=%u: empty body (unexpected) -- skipped", p.appId); return; } - auto wire = BuildWirePacket(p.jobIdTarget, *built); + auto wire = BuildWirePacket(p.jobIdTarget, p.body); CallGuard guard; if (sigsetjmp(g_jmp, 1) != 0) { @@ -219,7 +230,12 @@ static void RouteOne(const Pending& p) { return; } - uint32_t engine = *(uint32_t*)(g_base + RVA_ENGINE_GLOBAL); + void** engPtr = SteamKvInjector::GetEngineGlobalPtr(); + if (!engPtr || !*engPtr) { + LOG("[Stats] 819 inject app=%u: engine global not resolved", p.appId); + return; + } + uint32_t engine = (uint32_t)(uintptr_t)*engPtr; int jobMgr = (int)(engine + ENGINE_OFF_JOBMGR); int connCtx = *(int*)((uint8_t*)p.cmInterface + CCM_OFF_CONNCTX); diff --git a/src/platform/linux/achievement_inject.h b/src/platform/linux/achievement_inject.h index 78042d4d..9f09a70c 100644 --- a/src/platform/linux/achievement_inject.h +++ b/src/platform/linux/achievement_inject.h @@ -26,11 +26,12 @@ using SerializeBodyFn = const uint8_t* (*)(void* bodyObj, size_t* outLen); bool Resolve(uintptr_t steamclientBase, size_t steamclientSize, SerializeBodyFn serialize); bool Ready(); -// Called from the CCMInterface::Send observer for every outbound message. Detects +// Called from the CCMInterface::Send hook for every outbound message. Detects // EMsg 818, reads its appid + jobid from the message header, and (for a namespace -// app) queues a 819 response. Returns immediately; the response is routed on the -// next network-thread drain. msgObj = the CProtoBufMsg being sent. -void ObserveOutbound(uint32_t emsg, void* msgObj, void* cmInterface); +// app) queues a 819 response. Returns 1 if the send should be BLOCKED (we are +// the server), 0 to let it pass through. The response is routed on the next +// network-thread drain. msgObj = the CProtoBufMsg being sent. +int ObserveOutbound(uint32_t emsg, void* msgObj, void* cmInterface); // Route any queued 819 responses. MUST run on Steam's network thread (valid // coroutine TLS), same constraint as the live playtime drain. diff --git a/src/platform/linux/cloud_hooks.cpp b/src/platform/linux/cloud_hooks.cpp index a9c4c110..15b00e1c 100644 --- a/src/platform/linux/cloud_hooks.cpp +++ b/src/platform/linux/cloud_hooks.cpp @@ -4,6 +4,8 @@ #include "gamesplayed_hook.h" #include "live_playtime.h" #include "achievement_inject.h" +#include "schema_fetch.h" +#include "recvpkt_hook.h" #include "stats_store.h" #include "stats_handlers.h" #include "metadata_sync.h" @@ -13,6 +15,7 @@ #include "pending_ops_journal.h" #include "cloud_storage.h" #include "cloud_provider.h" +#include "cloud_provider_base.h" // g_uploadInFlightCapBytes #include "http_server.h" #include "protobuf.h" #include "json.h" @@ -55,6 +58,14 @@ static std::mutex g_pollerExitMtx; static std::condition_variable g_pollerExitCv; static std::atomic<bool> g_pollerExited{false}; +// SeedApps does per-app cloud I/O; run it off the init thread so a fresh install's +// hundreds of legacy-migration pulls don't block Steam's userdata load. Tracked + +// joined like the poller so a wedged curl can't run freed statics at shutdown. +static std::thread g_seedThread; +static std::mutex g_seedExitMtx; +static std::condition_variable g_seedExitCv; +static std::atomic<bool> g_seedExited{false}; + struct HookGuard { HookGuard() { g_hookRefCount.fetch_add(1, std::memory_order_acquire); } ~HookGuard() { g_hookRefCount.fetch_sub(1, std::memory_order_release); } @@ -259,6 +270,11 @@ void CloudHooks::InstallGamesPlayedObserver(uintptr_t steamclientBase, size_t st LivePlaytime::InstallUserCapture(); AchievementInject::Resolve(steamclientBase, steamclientSize, &SerializeBodyTL); + SchemaFetch::Resolve(steamclientBase, steamclientSize, g_parseFromArray); + + // Inbound CM observer: captures our schema-fetch 819 replies and writes the + // schema .bin (mirror of the Windows RecvPktMonitorHook). + RecvPktHook::Install(steamclientBase, steamclientSize); } static std::optional<CloudIntercept::RpcResult> DispatchCloudRpc( @@ -308,15 +324,26 @@ static void EnsureInitialized() { auto cfg = Json::Parse(configStr); std::string providerName = cfg["provider"].str(); - // Native stats/playtime sync gates. Absent -> keep default (ON). - // When off, the matching native path does not interfere with Steam. + // Native stats/playtime sync gates. Absent -> keep default. if (cfg["sync_achievements"].type == Json::Type::Bool) MetadataSync::syncAchievements = cfg["sync_achievements"].boolean(); if (cfg["sync_playtime"].type == Json::Type::Bool) MetadataSync::syncPlaytime = cfg["sync_playtime"].boolean(); - LOG("[Stats] Sync gates: achievements=%d, playtime=%d", + if (cfg["schema_fetch"].type == Json::Type::Bool) + MetadataSync::schemaFetch = cfg["schema_fetch"].boolean(); + + // Concurrency cap, not a speed knob (see g_uploadInFlightCapBytes). + // Clamp 24..64 MB; out-of-range/absent keeps the 24 MB default. + if (cfg["upload_inflight_mb"].type == Json::Type::Number) { + int mb = static_cast<int>(cfg["upload_inflight_mb"].integer()); + if (mb >= 24 && mb <= 64) + g_uploadInFlightCapBytes.store((uint64_t)mb << 20, + std::memory_order_relaxed); + } + LOG("[Stats] Sync gates: achievements=%d, playtime=%d, schemaFetch=%d", MetadataSync::syncAchievements.load() ? 1 : 0, - MetadataSync::syncPlaytime.load() ? 1 : 0); + MetadataSync::syncPlaytime.load() ? 1 : 0, + MetadataSync::schemaFetch.load() ? 1 : 0); if (!providerName.empty() && providerName != "local") { provider = CreateCloudProvider(providerName); @@ -399,7 +426,15 @@ static void EnsureInitialized() { for (const auto& [appId, json] : all) { if (appId == 0) continue; std::string key = std::to_string(appId); - Json::Value appVal = Json::Parse(json); + // Fold our outgoing entry onto the live cloud entry (monotonic + // playtime, union achievements/stats) instead of replacing it, + // so a stale/lower copy can't clobber a higher value another + // device wrote after our startup pull. + std::string baseEntry = root.has(key) + ? Json::Stringify(root.objVal[key]) : std::string(); + std::string mergedEntry = + StatsStore::MergeAppStatsJson(baseEntry, json); + Json::Value appVal = Json::Parse(mergedEntry); if (!root.has(key) || !Json::DeepEqual(root.objVal[key], appVal)) { root.objVal[key] = std::move(appVal); changed = true; @@ -444,13 +479,21 @@ static void EnsureInitialized() { []() -> uint32_t { return CloudIntercept::GetAccountId(); }); StatsStore::Init(cloudRedirectRoot, CloudIntercept::GetSteamPath()); StatsHandlers::Init(); - // Seed managed apps so playtime/achievements are available before the - // user launches anything: pulls each app's cloud stats blob and imports - // Steam's native data. SeedApps also uploads imported stats, so only run it - // when a stats feature is enabled (both off = inert). + // Seed managed apps on a background thread: SeedApps does a cloud read per + // app, which on the init thread would block Steam's userdata load. The store + // is g_mutex-serialized so a launch racing the seed is safe. if (MetadataSync::syncAchievements.load(std::memory_order_relaxed) || - MetadataSync::syncPlaytime.load(std::memory_order_relaxed)) - StatsStore::SeedApps(CloudIntercept::GetNamespaceApps()); + MetadataSync::syncPlaytime.load(std::memory_order_relaxed)) { + g_seedThread = std::thread([] { + if (!g_shuttingDown.load(std::memory_order_acquire)) + StatsStore::SeedApps(CloudIntercept::GetNamespaceApps()); + { + std::lock_guard<std::mutex> lk(g_seedExitMtx); + g_seedExited.store(true, std::memory_order_release); + } + g_seedExitCv.notify_all(); + }); + } // Re-pull the cloud every 60s for another device's playtime advances. // RefreshFromCloud merges to disk; advanced apps are queued for a live @@ -485,9 +528,9 @@ static void EnsureInitialized() { g_initialized.store(true, std::memory_order_release); - LOG("[Linux] Storage initialized: root=%s, accountId=%u, namespaceApps=%d", - storageRoot.c_str(), CloudIntercept::GetAccountId(), - CloudIntercept::HasNamespaceApps() ? 1 : 0); + LOG("[Linux] Storage initialized: root=%s, accountId=%u, namespaceApps=%zu", + storageRoot.c_str(), CloudIntercept::GetAccountId(), + CloudIntercept::GetNamespaceApps().size()); // Manifest system fetches CN/manifest on-demand; no bulk startup sync. if (CloudStorage::IsCloudActive()) { @@ -534,6 +577,8 @@ extern "C" int hook_BYieldingSend(void* pThis, const char* methodName, void* req // Queued 819 achievement responses route to their waiting job here (needs the // network thread's coroutine TLS, same as any inbound CM packet). AchievementInject::DrainOnNetThread(); + // Queued 818 schema-fetch requests fire via BAsyncSend on the net thread. + SchemaFetch::DrainOnNetThread(); // Native stats / playtime service methods (Player.*) ride this same path. // GetUserStats is answered from our store; GetLastPlayedTimes needs the real @@ -837,6 +882,8 @@ extern "C" bool hook_IsCloudEnabledForApp(void* pThis, unsigned int appId) void CloudHooks::BeginShutdown() { g_shuttingDown.store(true, std::memory_order_release); + SchemaFetch::Shutdown(); + RecvPktHook::Remove(); GamesPlayedHook::Remove(); LivePlaytime::RemoveUserCapture(); for (int i = 0; i < 300 && g_hookRefCount.load(std::memory_order_acquire) > 0; ++i) @@ -858,4 +905,19 @@ void CloudHooks::BeginShutdown() { g_cloudPollerThread.detach(); } } + + // Same bounded-join discipline for the background seed thread. + if (g_seedThread.joinable()) { + { + std::unique_lock<std::mutex> lk(g_seedExitMtx); + g_seedExitCv.wait_for(lk, std::chrono::seconds(5), + [] { return g_seedExited.load(std::memory_order_acquire); }); + } + if (g_seedExited.load(std::memory_order_acquire)) { + g_seedThread.join(); + } else { + LOG("[CloudHooks] seed wedged in network call -- detaching"); + g_seedThread.detach(); + } + } } diff --git a/src/platform/linux/gamesplayed_hook.cpp b/src/platform/linux/gamesplayed_hook.cpp index 5a063524..e2f26d05 100644 --- a/src/platform/linux/gamesplayed_hook.cpp +++ b/src/platform/linux/gamesplayed_hook.cpp @@ -1,6 +1,7 @@ #include "gamesplayed_hook.h" #include "stats_handlers.h" #include "achievement_inject.h" +#include "schema_fetch.h" #include "metadata_sync.h" #include "log.h" @@ -37,6 +38,7 @@ static std::atomic<bool> g_shuttingDown{false}; static std::atomic<int> g_inFlight{0}; // Detour bookkeeping. +static uint8_t* g_funcStart = nullptr; // resolved CCMInterface::Send entry static uint8_t* g_hookPoint = nullptr; // funcStart + PROLOGUE_LEN static uint8_t g_savedBytes[16]; // original bytes at the hook point static size_t g_savedLen = 0; @@ -73,16 +75,22 @@ static const uint8_t kSigMask[] = { }; static constexpr size_t kSigLen = sizeof(kSigBytes); -// Observer: runs on Steam's network thread. Read-only. -extern "C" void GamesPlayedHook_OnSend(int cmInterface, void* msg) { +// Hook: runs on Steam's network thread. Returns 1 to BLOCK the send (we are +// the server for this message), 0 to let it pass through normally. +extern "C" int GamesPlayedHook_OnSend(int cmInterface, void* msg) { + int block = 0; g_inFlight.fetch_add(1, std::memory_order_acquire); if (!g_shuttingDown.load(std::memory_order_acquire) && msg && g_serializeBody) { uint32_t emsg = *(uint32_t*)((uint8_t*)msg + OFF_EMSG) & EMSG_MASK; - // Legacy achievement fetch: queue a 819 response for namespace apps. + // Capture session/conn state for proactive schema fetch on every outbound msg + SchemaFetch::CaptureFromOutbound(emsg, msg, (void*)(uintptr_t)cmInterface); + + // Legacy achievement fetch: queue a 819 response and block the 818 + // from reaching Valve so our injected 819 is the sole server response. if (emsg == EMSG_GET_USER_STATS && MetadataSync::syncAchievements.load(std::memory_order_relaxed)) { - AchievementInject::ObserveOutbound(emsg, msg, (void*)(uintptr_t)cmInterface); + block = AchievementInject::ObserveOutbound(emsg, msg, (void*)(uintptr_t)cmInterface); } bool isGamesPlayed = (emsg == EMSG_GAMES_PLAYED || @@ -111,25 +119,38 @@ extern "C" void GamesPlayedHook_OnSend(int cmInterface, void* msg) { } } g_inFlight.fetch_sub(1, std::memory_order_release); + return block; } // Hand-written 32-bit trampoline, built at runtime to patch absolute targets. // The stolen instructions load esi=a1/eax=a2 from the stack, so they run first. +// OnSend returns 0 (pass through) or 1 (block send, return success to caller). // // <STOLEN_LEN stolen bytes> ; sub esp,1Ch; mov esi,[esp+30]=a1; mov eax,[esp+34]=a2 -// pushad ; 60 save the now-loaded esi/eax (+ all regs) +// pushad ; 60 // push eax ; 50 arg2 = msg (a2) // push esi ; 56 arg1 = cmInterface (a1) // mov eax, <OnSend> ; B8 xx xx xx xx // call eax ; FF D0 // add esp, 8 ; 83 C4 08 -// popad ; 61 restore esi/eax for the resume point -// push <resume> ; 68 xx xx xx xx +// test eax, eax ; 85 C0 +// jnz block_path ; 75 xx +// popad ; 61 +// push <resume> ; 68 xx xx xx xx +// ret ; C3 +// block_path: +// popad ; 61 +// add esp, 1Ch ; 83 C4 1C (undo stolen sub esp) +// mov eax, 1 ; B8 01 00 00 00 +// pop ebx ; 5B +// pop esi ; 5E +// pop edi ; 5F +// pop ebp ; 5D // ret ; C3 static bool BuildTrampoline(uint8_t* hookPoint) { long pageSize = sysconf(_SC_PAGESIZE); g_trampoline = (uint8_t*)mmap(nullptr, pageSize, PROT_READ | PROT_WRITE | PROT_EXEC, - MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (g_trampoline == MAP_FAILED) { g_trampoline = nullptr; LOG("[GamesPlayed] mmap trampoline failed"); @@ -154,12 +175,28 @@ static bool BuildTrampoline(uint8_t* hookPoint) { emit({0xB8}); emit32((uint32_t)(uintptr_t)&GamesPlayedHook_OnSend); // mov eax, OnSend emit({0xFF, 0xD0}); // call eax emit({0x83, 0xC4, 0x08}); // add esp, 8 - emit({0x61}); // popad + emit({0x85, 0xC0}); // test eax, eax + // jnz block_path (offset filled below) + uint8_t* jnzPatch = p; + emit({0x75, 0x00}); // jnz +?? (patched) - // Resume: push <funcStart+RESUME_OFF>; ret (leaves eax/esi intact). + // --- pass-through path --- + emit({0x61}); // popad uintptr_t resume = (uintptr_t)(hookPoint - PROLOGUE_LEN) + RESUME_OFF; emit({0x68}); emit32((uint32_t)resume); // push resume emit({0xC3}); // ret + + // --- block path: skip the original send, return 1 --- + uint8_t* blockAddr = p; + jnzPatch[1] = (uint8_t)(blockAddr - (jnzPatch + 2)); // patch jnz offset + emit({0x61}); // popad + emit({0x83, 0xC4, 0x1C}); // add esp, 1Ch (undo stolen sub esp) + emit({0xB8}); emit32(1); // mov eax, 1 (return success) + emit({0x5B}); // pop ebx + emit({0x5E}); // pop esi + emit({0x5F}); // pop edi + emit({0x5D}); // pop ebp + emit({0xC3}); // ret return true; } @@ -201,6 +238,7 @@ bool Install(uintptr_t steamclientBase, size_t steamclientSize) { LOG("[GamesPlayed] CCMInterface::Send found at %p (sc+0x%zx)", funcStart, (size_t)((uintptr_t)funcStart - steamclientBase)); + g_funcStart = funcStart; g_hookPoint = funcStart + PROLOGUE_LEN; if (!BuildTrampoline(g_hookPoint)) { @@ -242,4 +280,8 @@ void Remove() { g_installed.store(false, std::memory_order_release); } +// Resolved CCMInterface::Send entry. Valid after Install(); other modules reuse +// this instead of re-scanning, since the detour clobbers the post-prologue bytes. +void* GetSendFunc() { return g_funcStart; } + } // namespace GamesPlayedHook diff --git a/src/platform/linux/gamesplayed_hook.h b/src/platform/linux/gamesplayed_hook.h index 07deaa63..e62333ce 100644 --- a/src/platform/linux/gamesplayed_hook.h +++ b/src/platform/linux/gamesplayed_hook.h @@ -2,27 +2,25 @@ #include <cstddef> #include <cstdint> -// Playtime session tracking for namespace (lua) apps on Linux. -// -// Steam broadcasts CMsgClientGamesPlayed (EMsg 5410/742/715) through the CM -// send primitive CCMInterface::Send when a game starts or stops. We tap that -// send to observe the broadcast and start/stop StatsStore sessions by appid. -// This is read-only: we never modify or block the message. +// Taps CCMInterface::Send to track playtime sessions (CMsgClientGamesPlayed) and to +// block outbound 818s for namespace apps so our injected 819 is the sole response. namespace GamesPlayedHook { -// Serialize a protobuf message body object to raw bytes. Returns a pointer to a -// thread-local buffer valid until the next call on the same thread; sets *outLen. -// Installed by the platform layer so this module stays free of protobuf plumbing. +// Serialize a protobuf body to raw bytes (thread-local buffer, valid until next call). using SerializeBodyFn = const uint8_t* (*)(void* bodyObj, size_t* outLen); void SetSerializer(SerializeBodyFn fn); -// Resolve CCMInterface::Send in the loaded steamclient.so and install an inline -// detour that observes outbound GamesPlayed broadcasts. Safe to call once after -// steamclient is mapped and relocated. Returns true if the detour was installed. +// Resolve CCMInterface::Send and install the observer detour; returns true on success. bool Install(uintptr_t steamclientBase, size_t steamclientSize); // Remove the detour and wait for in-flight observers to drain. void Remove(); +// Resolved CCMInterface::Send entry (the real wire-send), valid after Install(). +// Returns nullptr if the signature was not found. Other modules reuse this rather +// than re-scanning, because the inline detour overwrites the post-prologue bytes +// that any signature would need to match. +void* GetSendFunc(); + } // namespace GamesPlayedHook diff --git a/src/platform/linux/http_server.cpp b/src/platform/linux/http_server.cpp index 06faff40..5575d965 100644 --- a/src/platform/linux/http_server.cpp +++ b/src/platform/linux/http_server.cpp @@ -11,6 +11,7 @@ #include <dirent.h> #include <cerrno> #include <thread> +#include <chrono> #include <atomic> #include <mutex> #include <vector> @@ -32,6 +33,8 @@ static std::atomic<bool> g_running{false}; static std::atomic<int> g_activeConnections{0}; static std::atomic<uint64_t> g_maxUploadBytes{256ULL * 1024 * 1024}; // 256 MB default static constexpr int MAX_CONCURRENT_CONNECTIONS = 64; +// At the cap, wait this long for a connection to free before sending 503 (backpressure). +static constexpr int kAcceptBackpressureMaxMs = 5000; static std::thread g_serverThread; struct ClientThread { std::thread thread; @@ -601,7 +604,14 @@ bool Start(const std::string& blobRoot, uint32_t accountId) { struct timeval tv = { .tv_sec = 30, .tv_usec = 0 }; setsockopt(clientFd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); setsockopt(clientFd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); - // Reject if too many concurrent connections (local DoS protection) + // At the cap: backpressure before giving up, so large download bursts + // (high-file-count manifests) drain instead of failing the sync. + int backpressureMs = 0; + while (g_activeConnections.load() >= MAX_CONCURRENT_CONNECTIONS && + g_running.load() && backpressureMs < kAcceptBackpressureMaxMs) { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + backpressureMs += 20; + } if (g_activeConnections.load() >= MAX_CONCURRENT_CONNECTIONS) { const char* resp = "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n"; send(clientFd, resp, strlen(resp), 0); diff --git a/src/platform/linux/init.cpp b/src/platform/linux/init.cpp index e38704af..d1d9de70 100644 --- a/src/platform/linux/init.cpp +++ b/src/platform/linux/init.cpp @@ -162,6 +162,7 @@ static void CleanLdPreload() static void DoInit() { DebugLog("[CR] DoInit: version=" CR_VERSION_STRING " finding steamclient.so in /proc/self/maps\n"); + Log::Info("=== CloudRedirect loaded [BUILD:" CR_RELEASE_VERSION "] ==="); Log::Info("CloudRedirect build %s", CR_VERSION_STRING); // Kill-switch: if disable file exists, bail without hooking @@ -316,6 +317,151 @@ static uintptr_t FaultInstructionPointer(void* ctx) #endif } +// Async-signal-safe: scan /proc/self/maps via raw read() for the mapping containing +// `addr`. Returns its base (0 if not found); dladdr is unreliable on stripped i386. +static uintptr_t ResolveModule(uintptr_t addr, char* nameOut, size_t nameCap) +{ + if (nameCap) nameOut[0] = '\0'; + int fd = open("/proc/self/maps", O_RDONLY | O_CLOEXEC); + if (fd < 0) return 0; + char buf[8192]; + char line[512]; + size_t lineLen = 0; + uintptr_t result = 0; + ssize_t n; + bool done = false; + while (!done && (n = read(fd, buf, sizeof(buf))) > 0) { + for (ssize_t i = 0; i < n; ++i) { + char c = buf[i]; + if (c != '\n') { + if (lineLen < sizeof(line) - 1) line[lineLen++] = c; + continue; + } + line[lineLen] = '\0'; + // Parse "start-end perms ... path" + uintptr_t start = 0, e = 0; + const char* p = line; + while (*p && *p != '-') { start = start * 16 + (*p <= '9' ? *p - '0' : (*p | 0x20) - 'a' + 10); ++p; } + if (*p == '-') ++p; + while (*p && *p != ' ') { e = e * 16 + (*p <= '9' ? *p - '0' : (*p | 0x20) - 'a' + 10); ++p; } + if (addr >= start && addr < e) { + // path is the last token after the final space + const char* path = line; + for (const char* q = line; *q; ++q) if (*q == ' ' && q[1] && q[1] != ' ') path = q + 1; + const char* slash = path; + for (const char* q = path; *q; ++q) if (*q == '/') slash = q + 1; + size_t j = 0; + if (*slash && *slash != ' ') + for (; slash[j] && j < nameCap - 1; ++j) nameOut[j] = slash[j]; + nameOut[j] = '\0'; + result = start; + done = true; + break; + } + lineLen = 0; + } + } + close(fd); + return result; +} + +static void WriteFrame(uintptr_t retAddr) +{ + char buf[256]; + char* out = buf; + char* end = buf + sizeof(buf); + char mod[128]; + uintptr_t base = ResolveModule(retAddr, mod, sizeof(mod)); + out = AppendLiteral(out, end, "[CR] "); + out = AppendHex(out, end, retAddr); + if (base) { + out = AppendLiteral(out, end, " "); + out = AppendLiteral(out, end, mod[0] ? mod : "?"); + out = AppendLiteral(out, end, "+"); + out = AppendHex(out, end, retAddr - base); + } + out = AppendLiteral(out, end, "\n"); + if (g_debugFd >= 0) write(g_debugFd, buf, (size_t)(out - buf)); +} + +// Find steamclient.so's executable mapping (r-xp) range by scanning /proc/self/maps. +static bool SteamclientExecRange(uintptr_t& lo, uintptr_t& hi) +{ + lo = hi = 0; + int fd = open("/proc/self/maps", O_RDONLY | O_CLOEXEC); + if (fd < 0) return false; + char buf[8192], line[512]; + size_t lineLen = 0; + ssize_t n; + bool found = false; + while ((n = read(fd, buf, sizeof(buf))) > 0) { + for (ssize_t i = 0; i < n; ++i) { + char c = buf[i]; + if (c != '\n') { if (lineLen < sizeof(line) - 1) line[lineLen++] = c; continue; } + line[lineLen] = '\0'; lineLen = 0; + // need executable + steamclient.so + bool isExec = false; + for (const char* q = line; *q && *q != '\n'; ++q) { + if (q[0] == ' ' && q[1] == 'r') { isExec = (q[3] == 'x'); break; } + } + bool isSc = false; + for (const char* q = line; *q; ++q) + if (q[0]=='s'&&q[1]=='t'&&q[2]=='e'&&q[3]=='a'&&q[4]=='m'&&q[5]=='c') { isSc = true; break; } + if (isExec && isSc) { + uintptr_t s = 0, e = 0; const char* p = line; + while (*p && *p != '-') { s = s*16 + (*p<='9'?*p-'0':(*p|0x20)-'a'+10); ++p; } + if (*p=='-') ++p; + while (*p && *p != ' ') { e = e*16 + (*p<='9'?*p-'0':(*p|0x20)-'a'+10); ++p; } + if (!found) { lo = s; hi = e; found = true; } + else { if (s < lo) lo = s; if (e > hi) hi = e; } + } + } + } + close(fd); + return found; +} + +// Recover the call chain on stripped, fomit-frame-pointer i386 code: EBP-chaining is +// unreliable, so also scan the stack for words pointing into steamclient.so's .text. +static void ManualBacktrace(void* ctx) +{ +#if defined(__i386__) + ucontext_t* uc = static_cast<ucontext_t*>(ctx); + uintptr_t ip = (uintptr_t)uc->uc_mcontext.gregs[14]; // REG_EIP + uintptr_t ebp = (uintptr_t)uc->uc_mcontext.gregs[6]; // REG_EBP + uintptr_t esp = (uintptr_t)uc->uc_mcontext.gregs[7]; // REG_ESP + WriteFrame(ip); // frame 0 = faulting instruction + + // Pass 1: EBP chain (works when frame pointers are present). + const char* h1 = "[CR] -- ebp chain --\n"; + if (g_debugFd >= 0) write(g_debugFd, h1, strlen(h1)); + uintptr_t prev = 0, bp = ebp; + for (int depth = 0; depth < 32; ++depth) { + if (bp < 0x1000 || (bp & 3) || bp <= prev) break; + uintptr_t* fp = (uintptr_t*)bp; + uintptr_t ret = fp[1]; + if (ret < 0x1000) break; + WriteFrame(ret); + prev = bp; bp = fp[0]; + } + + // Pass 2: stack scan for return addresses into steamclient.so .text. + uintptr_t lo, hi; + if (SteamclientExecRange(lo, hi)) { + const char* h2 = "[CR] -- stack scan (steamclient .text return addrs) --\n"; + if (g_debugFd >= 0) write(g_debugFd, h2, strlen(h2)); + uintptr_t scanEnd = esp + 0x4000; // 16KB window up the stack + int printed = 0; + for (uintptr_t s = esp & ~3u; s < scanEnd && printed < 48; s += 4) { + uintptr_t w = *(uintptr_t*)s; + if (w >= lo && w < hi) { WriteFrame(w); ++printed; } + } + } +#else + (void)ctx; +#endif +} + static void CrashDumpHandler(int sig, siginfo_t* info, void* ctx) { if (g_inCrashHandler) { _exit(128 + sig); } @@ -341,21 +487,13 @@ static void CrashDumpHandler(int sig, siginfo_t* info, void* ctx) write(g_debugFd, buf, (size_t)(out - buf)); - void* frames[64]; - int frameCount = backtrace(frames, 64); - if (frameCount > 0) { - const char* btHeader = "[CR] Backtrace:\n"; - if (g_debugFd >= 0) - write(g_debugFd, btHeader, strlen(btHeader)); - - // backtrace_symbols_fd writes directly to fd (async-signal-safe) - if (g_debugFd >= 0) - backtrace_symbols_fd(frames, frameCount, g_debugFd); - - const char* btFooter = "[CR] End backtrace\n"; - if (g_debugFd >= 0) - write(g_debugFd, btFooter, strlen(btFooter)); - } + // Manual EBP-chain unwind -> module+offset (glibc backtrace() is useless on + // stripped, -fomit-frame-pointer i386 steamclient code). + const char* btHeader = "[CR] Backtrace (manual ebp-walk, addr module+off):\n"; + if (g_debugFd >= 0) write(g_debugFd, btHeader, strlen(btHeader)); + ManualBacktrace(ctx); + const char* btFooter = "[CR] End backtrace\n"; + if (g_debugFd >= 0) write(g_debugFd, btFooter, strlen(btFooter)); // SA_RESETHAND already restored default - just re-raise for core dump raise(sig); diff --git a/src/platform/linux/live_playtime.cpp b/src/platform/linux/live_playtime.cpp index ef3520d9..b49eb30b 100644 --- a/src/platform/linux/live_playtime.cpp +++ b/src/platform/linux/live_playtime.cpp @@ -181,6 +181,123 @@ static bool BuildTrampoline(uint8_t* hookPoint) { return true; } +// Resolve vtable and descriptor from RTTI + code analysis (survives Steam updates). +// 1. Find RTTI typestring for CProtoBufMsg<...Response> in .rodata +// 2. Scan .data.rel.ro for typeinfo (ptr to typestring at offset +4) +// 3. Scan .data.rel.ro for vtable header ({0, typeinfo_ptr}) -> vptr = header + 8 +// 4. Find wrapper function that stores the vptr, extract descriptor from wrapper[1] store +static bool ResolveVtableAndDescriptor(uintptr_t base, size_t size) { + // Step 1: find the RTTI name string + static const char kRttiName[] = "12CProtoBufMsgI35CPlayer_GetLastPlayedTimes_ResponseE"; + const uint8_t* s = (const uint8_t*)base; + const uint8_t* end = s + size - sizeof(kRttiName); + uintptr_t nameAddr = 0; + for (const uint8_t* p = s; p <= end; ++p) { + if (memcmp(p, kRttiName, sizeof(kRttiName) - 1) == 0) { + nameAddr = (uintptr_t)p; + break; + } + } + if (!nameAddr) { + LOG("[Stats] LivePlaytime: RTTI name string not found"); + return false; + } + + // Step 2: find typeinfo (scan for 4-byte pointer to nameAddr) + uintptr_t tiAddr = 0; + uint32_t nameVal = (uint32_t)nameAddr; + for (const uint8_t* p = s; p <= end - 3; p += 4) { + if (*(const uint32_t*)p == nameVal) { + tiAddr = (uintptr_t)p - 4; // typeinfo = { vptr_to_typeinfo_class, name_ptr } + break; + } + } + if (!tiAddr) { + LOG("[Stats] LivePlaytime: typeinfo not found for RTTI name at %p", (void*)nameAddr); + return false; + } + + // Step 3: find vtable header ({offset_to_top=0, typeinfo_ptr=tiAddr}) + uint32_t tiVal = (uint32_t)tiAddr; + uintptr_t vptr = 0; + for (const uint8_t* p = s + 4; p <= end - 3; p += 4) { + if (*(const uint32_t*)p == tiVal && *(const uint32_t*)(p - 4) == 0) { + vptr = (uintptr_t)p + 4; // skip past typeinfo_ptr -> first vfunc slot + break; + } + } + if (!vptr) { + LOG("[Stats] LivePlaytime: vtable not found for typeinfo at %p", (void*)tiAddr); + return false; + } + g_respWrapperVt = vptr; + LOG("[Stats] LivePlaytime: RTTI-resolved vptr=%p (typeinfo=%p)", (void*)vptr, (void*)tiAddr); + + // Step 4: find descriptor pointer. The CProtoBufMsg wrapper stores + // [0]=wrapper_vptr, [1]=&raw_response_vptr. Resolve the raw Response class's + // vptr from its RTTI, then scan .data.rel.ro for a pointer to it. + static const char kRawRttiName[] = "35CPlayer_GetLastPlayedTimes_Response"; + uintptr_t rawNameAddr = 0; + for (const uint8_t* p = s; p <= end - sizeof(kRawRttiName); ++p) { + if (memcmp(p, kRawRttiName, sizeof(kRawRttiName) - 1) == 0) { + // Distinguish from the CProtoBufMsg wrapper's RTTI + if (p > s && *(p - 1) == 'I') continue; // "...I35CPlayer..." is the wrapper + rawNameAddr = (uintptr_t)p; + break; + } + } + if (!rawNameAddr) { + LOG("[Stats] LivePlaytime: raw Response RTTI name not found"); + return false; + } + + // Find raw typeinfo (pointer to rawNameAddr at offset +4 of typeinfo) + uint32_t rawNameVal = (uint32_t)rawNameAddr; + uintptr_t rawTiAddr = 0; + for (const uint8_t* p = s; p <= end - 3; p += 4) { + if (*(const uint32_t*)p == rawNameVal) { + rawTiAddr = (uintptr_t)p - 4; + break; + } + } + if (!rawTiAddr) { + LOG("[Stats] LivePlaytime: raw Response typeinfo not found"); + return false; + } + + // Find raw vtable header ({0, rawTiAddr}) -> raw vptr = header + 8 + uint32_t rawTiVal = (uint32_t)rawTiAddr; + uintptr_t rawVptr = 0; + for (const uint8_t* p = s + 4; p <= end - 3; p += 4) { + if (*(const uint32_t*)p == rawTiVal && *(const uint32_t*)(p - 4) == 0) { + rawVptr = (uintptr_t)p + 4; + break; + } + } + if (!rawVptr) { + LOG("[Stats] LivePlaytime: raw Response vtable not found"); + return false; + } + + // Descriptor = pointer in .data.rel.ro whose value is rawVptr + uint32_t rawVptrVal = (uint32_t)rawVptr; + uintptr_t descAddr = 0; + for (const uint8_t* p = s; p <= end - 3; p += 4) { + if (*(const uint32_t*)p == rawVptrVal && (uintptr_t)p != (rawVptr - 8 + 4)) { + // Skip the vtable header itself (which also contains rawVptr-8's neighborhood) + descAddr = (uintptr_t)p; + break; + } + } + if (!descAddr) { + LOG("[Stats] LivePlaytime: descriptor pointer not found for raw vptr %p", (void*)rawVptr); + return false; + } + g_respDescriptor = descAddr; + LOG("[Stats] LivePlaytime: RTTI-resolved descriptor=%p (rawVptr=%p)", (void*)descAddr, (void*)rawVptr); + return true; +} + bool Resolve(uintptr_t base, size_t size, ParseFromArrayFn parse) { g_base = base; g_parseFromArray = parse; @@ -200,8 +317,11 @@ bool Resolve(uintptr_t base, size_t size, ParseFromArrayFn parse) { g_msgCtor = (MsgCtorFn)ctor; g_msgInit = (MsgInitFn)init; g_msgDtor = (MsgDtorFn)dtor; - g_respWrapperVt = base + 0x2E15B4C; - g_respDescriptor = base + 0x2EA3FFC; + + if (!ResolveVtableAndDescriptor(base, size)) { + LOG("[Stats] LivePlaytime: vtable/descriptor resolution failed -- live UI updates disabled"); + return false; + } LOG("[Stats] LivePlaytime resolved: writer=%p ctor=%p init=%p dtor=%p", writer, ctor, init, dtor); diff --git a/src/platform/linux/recvpkt_hook.cpp b/src/platform/linux/recvpkt_hook.cpp new file mode 100644 index 00000000..5c7cf600 --- /dev/null +++ b/src/platform/linux/recvpkt_hook.cpp @@ -0,0 +1,249 @@ +#include "recvpkt_hook.h" +#include "schema_fetch.h" +#include "metadata_sync.h" +#include "log.h" + +#include <atomic> +#include <csetjmp> +#include <csignal> +#include <cstring> +#include <initializer_list> +#include <sys/mman.h> +#include <unistd.h> + +namespace RecvPktHook { + +// EMsg constants (low 31 bits; bit 31 = CMsgBase protobuf flag). +static constexpr uint32_t EMSG_MASK = 0x7FFFFFFF; +static constexpr uint32_t PROTO_FLAG = 0x80000000; +static constexpr uint32_t EMSG_GET_USER_STATS_RESP = 819; + +// Raw netpacket layout (resolved from sub_2AC5EC0, the wrapper RecvPkt calls): +// +4 = data pointer (pubData), +8 = length (cubData). data[0] = emsg | flags. +static constexpr size_t PKT_OFF_DATA = 4; +static constexpr size_t PKT_OFF_LEN = 8; + +static std::atomic<bool> g_installed{false}; +static std::atomic<bool> g_shuttingDown{false}; +static std::atomic<int> g_inFlight{0}; + +// Detour bookkeeping. +static uint8_t* g_funcStart = nullptr; // resolved CCMInterface::RecvPkt entry +static uint8_t g_savedBytes[8]; // original prologue bytes +static size_t g_savedLen = 0; +static uint8_t* g_trampoline = nullptr; // stolen bytes + jmp back + +// RecvPkt uses an EBP frame, so we detour at the entry and steal exactly the +// first 5 bytes (push ebp; mov ebp,esp; push edi; push esi -- all position +// independent, exactly E9-rel32 sized). We must NOT steal into the following PIC +// call (get_pc_thunk + add esi,delta), which computes the GOT base from its own +// return EIP and would break if relocated to the trampoline. +// 55 push ebp +// 89 E5 mov ebp, esp +// 57 push edi +// 56 push esi +// E8 ?? ?? ?? ?? call get_pc_thunk <- left in place +// 81 C6 ?? ?? ?? ?? add esi, <PIC delta> +// 53 push ebx +// 81 EC CC 04 00 00 sub esp, 0x4CC +// 8B 45 08 mov eax, [ebp+8] ; a1 = thisptr +// 8B 7D 0C mov edi, [ebp+0xC] ; a2 = netpacket +static constexpr size_t STOLEN_LEN = 5; +static const uint8_t kSigBytes[] = { + 0x55, 0x89,0xE5, 0x57, 0x56, + 0xE8,0,0,0,0, 0x81,0xC6,0,0,0,0, + 0x53, 0x81,0xEC,0xCC,0x04,0x00,0x00, + 0x8B,0x45,0x08, 0x8B,0x7D,0x0C +}; +static const uint8_t kSigMask[] = { + 1, 1,1, 1, 1, + 1,0,0,0,0, 1,1,0,0,0,0, + 1, 1,1,1,1,1,1, + 1,1,1, 1,1,1 +}; +static constexpr size_t kSigLen = sizeof(kSigBytes); + +// Crash guard: the observer runs on Steam's net thread reading attacker-adjacent +// packet memory. A bad read must never take down the client -- catch and skip. +static thread_local sigjmp_buf t_jmp; +static thread_local volatile sig_atomic_t t_inCall = 0; +static struct sigaction g_oldSegv; +static struct sigaction g_oldBus; +static std::atomic<bool> g_guardInstalled{false}; + +static void CrashHandler(int sig) { + if (t_inCall) siglongjmp(t_jmp, sig); + // Not in our code: restore and re-raise. + sigaction(SIGSEGV, &g_oldSegv, nullptr); + sigaction(SIGBUS, &g_oldBus, nullptr); + raise(sig); +} + +// Observer: peek at the inbound packet, capture EMsg 819 schema responses. +// Pure pass-through -- never alters or blocks the packet. +extern "C" void RecvPktHook_OnRecv(void* netPacket) { + g_inFlight.fetch_add(1, std::memory_order_acquire); + + if (!g_shuttingDown.load(std::memory_order_acquire) && netPacket && + MetadataSync::SchemaFetchEnabled()) { + + t_inCall = 1; + if (sigsetjmp(t_jmp, 1) == 0) { + uint8_t* raw = (uint8_t*)netPacket; + const uint8_t* data = *(const uint8_t**)(raw + PKT_OFF_DATA); + uint32_t len = *(const uint32_t*)(raw + PKT_OFF_LEN); + if (data && len >= 8) { + uint32_t emsgRaw = *(const uint32_t*)data; + uint32_t emsg = emsgRaw & EMSG_MASK; + if ((emsgRaw & PROTO_FLAG) && emsg == EMSG_GET_USER_STATS_RESP) { + SchemaFetch::HandleInbound819(data, len); + } + } + } + t_inCall = 0; + } + g_inFlight.fetch_sub(1, std::memory_order_release); +} + +// Runtime 32-bit trampoline. At RecvPkt entry the stack is [esp]=ret, [esp+4]=a1, +// [esp+8]=a2 (the netpacket). We snapshot a2, call the observer, then run the +// stolen prologue bytes and jump back to funcStart+STOLEN_LEN. +// +// At trampoline entry (detour is an E9 jmp, pushes no return addr): +// [esp]=ret, [esp+4]=a1 (thisptr), [esp+8]=a2 (netpacket). +// pushad subtracts 0x20, so a2 is then at [esp+0x20+8] = [esp+0x28]. +// +// pushad ; 60 +// push dword [esp+0x28] ; FF 74 24 28 (a2: orig [esp+8] + 0x20) +// mov eax, <OnRecv> ; B8 xx xx xx xx +// call eax ; FF D0 +// add esp, 4 ; 83 C4 04 +// popad ; 61 +// <STOLEN_LEN stolen bytes> ; original prologue +// push <resume> ; 68 xx xx xx xx +// ret ; C3 +static bool BuildTrampoline(uint8_t* funcStart) { + long pageSize = sysconf(_SC_PAGESIZE); + g_trampoline = (uint8_t*)mmap(nullptr, pageSize, PROT_READ | PROT_WRITE | PROT_EXEC, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (g_trampoline == MAP_FAILED) { + g_trampoline = nullptr; + LOG("[RecvPkt] mmap trampoline failed"); + return false; + } + + uint8_t* p = g_trampoline; + auto emit = [&](std::initializer_list<uint8_t> bytes) { + for (uint8_t b : bytes) *p++ = b; + }; + auto emit32 = [&](uint32_t v) { + *p++ = v & 0xFF; *p++ = (v >> 8) & 0xFF; *p++ = (v >> 16) & 0xFF; *p++ = (v >> 24) & 0xFF; + }; + + emit({0x60}); // pushad (esp -= 0x20) + emit({0xFF, 0x74, 0x24, 0x28}); // push [esp+0x28] -> a2 (orig [esp+8] + 0x20) + emit({0xB8}); emit32((uint32_t)(uintptr_t)&RecvPktHook_OnRecv); // mov eax, OnRecv + emit({0xFF, 0xD0}); // call eax + emit({0x83, 0xC4, 0x04}); // add esp, 4 + emit({0x61}); // popad + + memcpy(p, funcStart, STOLEN_LEN); // stolen prologue + p += STOLEN_LEN; + + uintptr_t resume = (uintptr_t)funcStart + STOLEN_LEN; + emit({0x68}); emit32((uint32_t)resume); // push resume + emit({0xC3}); // ret + return true; +} + +static bool FindBySignature(uintptr_t base, size_t size, uint8_t*& outFuncStart) { + if (size < kSigLen) return false; + const uint8_t* start = (const uint8_t*)base; + const uint8_t* end = start + size - kSigLen; + for (const uint8_t* s = start; s <= end; ++s) { + bool match = true; + for (size_t i = 0; i < kSigLen; ++i) { + if (kSigMask[i] && s[i] != kSigBytes[i]) { match = false; break; } + } + if (match) { outFuncStart = const_cast<uint8_t*>(s); return true; } + } + return false; +} + +static bool MakeWritable(void* addr, size_t len) { + long pageSize = sysconf(_SC_PAGESIZE); + uintptr_t page = (uintptr_t)addr & ~(uintptr_t)(pageSize - 1); + uintptr_t endAddr = (uintptr_t)addr + len; + size_t pageLen = ((endAddr - page) + (pageSize - 1)) & ~(size_t)(pageSize - 1); + return mprotect((void*)page, pageLen, PROT_READ | PROT_WRITE | PROT_EXEC) == 0; +} + +bool Install(uintptr_t steamclientBase, size_t steamclientSize) { + bool expected = false; + if (!g_installed.compare_exchange_strong(expected, true)) return true; + + uint8_t* funcStart = nullptr; + if (!FindBySignature(steamclientBase, steamclientSize, funcStart)) { + LOG("[RecvPkt] CCMInterface::RecvPkt signature not found -- inbound capture disabled"); + g_installed.store(false); + return false; + } + LOG("[RecvPkt] CCMInterface::RecvPkt found at %p (sc+0x%zx)", + funcStart, (size_t)((uintptr_t)funcStart - steamclientBase)); + + g_funcStart = funcStart; + + if (!BuildTrampoline(g_funcStart)) { + g_installed.store(false); + return false; + } + + // Install the SIGSEGV/SIGBUS guard once (process-wide, restored on Remove). + bool guardExpected = false; + if (g_guardInstalled.compare_exchange_strong(guardExpected, true)) { + struct sigaction sa = {}; + sa.sa_handler = CrashHandler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + sigaction(SIGSEGV, &sa, &g_oldSegv); + sigaction(SIGBUS, &sa, &g_oldBus); + } + + g_savedLen = STOLEN_LEN; + memcpy(g_savedBytes, g_funcStart, g_savedLen); + + if (!MakeWritable(g_funcStart, g_savedLen)) { + LOG("[RecvPkt] mprotect RWX failed at entry"); + g_installed.store(false); + return false; + } + + // E9 rel32 jmp to trampoline (exactly 5 bytes = STOLEN_LEN). + int32_t rel = (int32_t)((uintptr_t)g_trampoline - ((uintptr_t)g_funcStart + 5)); + g_funcStart[0] = 0xE9; + memcpy(g_funcStart + 1, &rel, 4); + + __builtin___clear_cache((char*)g_funcStart, (char*)g_funcStart + g_savedLen); + LOG("[RecvPkt] Inline detour installed at %p -> trampoline %p", g_funcStart, g_trampoline); + return true; +} + +void Remove() { + if (!g_installed.load(std::memory_order_acquire)) return; + g_shuttingDown.store(true, std::memory_order_release); + + if (g_funcStart && MakeWritable(g_funcStart, g_savedLen)) { + memcpy(g_funcStart, g_savedBytes, g_savedLen); + __builtin___clear_cache((char*)g_funcStart, (char*)g_funcStart + g_savedLen); + } + for (int i = 0; i < 300 && g_inFlight.load(std::memory_order_acquire) > 0; ++i) + usleep(10000); // up to 3s + + if (g_guardInstalled.exchange(false)) { + sigaction(SIGSEGV, &g_oldSegv, nullptr); + sigaction(SIGBUS, &g_oldBus, nullptr); + } + g_installed.store(false, std::memory_order_release); +} + +} // namespace RecvPktHook diff --git a/src/platform/linux/recvpkt_hook.h b/src/platform/linux/recvpkt_hook.h new file mode 100644 index 00000000..6f1a9201 --- /dev/null +++ b/src/platform/linux/recvpkt_hook.h @@ -0,0 +1,16 @@ +#pragma once +#include <cstddef> +#include <cstdint> + +// Inbound CM packet observer (mirror of Windows RecvPktMonitorHook): pass-through +// detour on CCMInterface::RecvPkt that hands EMsg 819 to SchemaFetch::HandleInbound819. + +namespace RecvPktHook { + +// Resolve CCMInterface::RecvPkt and install the observer detour; returns true on success. +bool Install(uintptr_t steamclientBase, size_t steamclientSize); + +// Remove the detour and wait for in-flight observers to drain. +void Remove(); + +} // namespace RecvPktHook diff --git a/src/platform/linux/schema_fetch.cpp b/src/platform/linux/schema_fetch.cpp new file mode 100644 index 00000000..c50741b5 --- /dev/null +++ b/src/platform/linux/schema_fetch.cpp @@ -0,0 +1,630 @@ +#include "schema_fetch.h" +#include "cloud_intercept.h" +#include "metadata_sync.h" +#include "protobuf.h" +#include "cloud_provider_base.h" +#include "vtable_hook.h" +#include "gamesplayed_hook.h" +#include "log.h" + +#include <atomic> +#include <cstdio> +#include <cstring> +#include <csetjmp> +#include <csignal> +#include <memory> +#include <mutex> +#include <queue> +#include <thread> +#include <unordered_set> +#include <vector> +#include <sys/stat.h> +#include <unistd.h> + +namespace SchemaFetch { + +// CProtoBufMsg 32-bit struct layout. +static constexpr size_t MSG_OFF_VTABLE = 0; +static constexpr size_t MSG_OFF_DESC = 4; // descriptor / default instance ptr +static constexpr size_t MSG_OFF_EMSG = 20; // emsg | 0x80000000 +static constexpr size_t MSG_OFF_HDR = 28; // header object ptr +static constexpr size_t MSG_OFF_BODY = 32; // body object ptr (set by Finalize) +static constexpr size_t MSG_OFF_FLAGS = 36; +static constexpr size_t MSG_OFF_EXTRA = 40; + +static constexpr uint32_t EMSG_GET_USER_STATS = 818; +static constexpr uint32_t EMSG_MASK = 0x7FFFFFFF; + +// CMsgProtoBufHeader field numbers (from steammessages_base.proto) +static constexpr uint32_t HDR_STEAMID = 1; // fixed64 +static constexpr uint32_t HDR_SESSION_ID = 2; // int32 +static constexpr uint32_t HDR_JOBID_SOURCE = 10; // fixed64 +static constexpr uint32_t HDR_REALM = 32; // uint32 +static constexpr uint32_t HDR_TIMEOUT_MS = 33; // int32 + +// CMsgClientGetUserStats body field numbers +static constexpr uint32_t BODY_GAME_ID = 1; // fixed64 +static constexpr uint32_t BODY_CRC_STATS = 2; // varint +static constexpr uint32_t BODY_SCHEMA_LOCAL_VERSION = 3; // int32 (varint) +static constexpr uint32_t BODY_STEAM_ID_FOR_USER = 4; // fixed64 + +// CMsgClientGetUserStatsResponse (EMsg 819) body field numbers +static constexpr uint32_t RESP_GAME_ID = 1; // fixed64 (low 24 bits = appid) +static constexpr uint32_t RESP_ERESULT = 2; // int32 (varint), 1 = OK +static constexpr uint32_t RESP_SCHEMA = 4; // bytes (the schema blob) + +static constexpr uint32_t EMSG_GET_USER_STATS_RESP = 819; +static constexpr uint32_t PROTO_FLAG = 0x80000000; + +// cmInterface field offsets (32-bit Linux steamclient.so) +static constexpr uint32_t CCM_OFF_SESSION_ID = 0xD8; // +216 +static constexpr uint32_t CCM_OFF_STEAMID = 0x15C; // +348 +static constexpr uint32_t CCM_OFF_CONNHANDLE = 0x4FC; // +1276 + +// Function pointer types. +using CtorFn = void(*)(void* msg, int emsg, int flags); +using FinalizeFn = void*(*)(void* msg); +using CmSendFn = uint8_t(*)(void* cmInterface, void* msg); +using CleanupFn = void(*)(void* msg); + +// Resolved entry points. +static uintptr_t g_base = 0; +static CtorFn g_ctor = nullptr; +static FinalizeFn g_finalize = nullptr; +static CmSendFn g_cmSend = nullptr; +static CleanupFn g_cleanup = nullptr; +static void* g_typedVtable = nullptr; // CProtoBufMsg<CMsgClientGetUserStats> vptr +static void* g_defaultInstance = nullptr;// CMsgClientGetUserStats default instance +static ParseFromArrayFn g_parseFromArray = nullptr; + +// Captured session state. +static std::atomic<uint64_t> g_steamId{0}; +static std::atomic<uint32_t> g_sessionId{0}; +static std::atomic<uint32_t> g_realm{0}; +static std::atomic<bool> g_sessionCaptured{false}; +static std::atomic<uint32_t> g_connHandle{0}; +static std::atomic<uint32_t> g_statsConnHandle{0}; +// The live CCMInterface pointer (sub_10E6C90's arg_0), captured straight from the +// outbound hook. This is the exact base sub_10E6C90 dereferences (it reads +// [+0x4FC] conn, [+0xD8] session, [+0x15C] steamid). Used to fire schema sends. +static std::atomic<void*> g_cmInterface{nullptr}; + +// Schema fetch state. +struct SchemaSendItem { uint32_t appId; uint64_t owner; }; +static std::mutex g_sendMutex; +static std::queue<SchemaSendItem> g_sendQueue; +static std::mutex g_fetchMutex; +static std::unordered_set<uint32_t> g_fetchAttempted; +static std::atomic<bool> g_shuttingDown{false}; +static std::atomic<bool> g_sweepScheduled{false}; +static thread_local bool t_draining = false; + +// Forward declarations +static void MaybeScheduleSweep(); +static void SweepNamespaceSchemas(); + +// Fallback owners (large-library accounts for apps with no reviews). +static const uint64_t kFallbackOwnerIds[] = { + 76561197978902089ull, 76561198028121353ull, 76561198017975643ull, + 76561198001678750ull, 76561198355953202ull, 76561197993544755ull, +}; + +// Crash guard (same pattern as AchievementInject). +static sigjmp_buf g_jmp; +static volatile sig_atomic_t g_inCall = 0; +static void CrashHandler(int sig) { if (g_inCall) siglongjmp(g_jmp, sig); raise(sig); } +class CallGuard { +public: + CallGuard() { + struct sigaction sa = {}; + sa.sa_handler = CrashHandler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = SA_RESETHAND; + sigaction(SIGSEGV, &sa, &m_segv); + sigaction(SIGBUS, &sa, &m_bus); + // Don't trap SIGABRT: it fires async on the CM thread after this guard dies; + // let init.cpp's CrashDumpHandler own it. + g_inCall = 1; + } + ~CallGuard() { + g_inCall = 0; + sigaction(SIGSEGV, &m_segv, nullptr); + sigaction(SIGBUS, &m_bus, nullptr); + } +private: + struct sigaction m_segv = {}; + struct sigaction m_bus = {}; +}; + +// Signature scanning. +static void* ScanSig(uintptr_t base, size_t size, + const uint8_t* b, const uint8_t* m, size_t len) { + if (size < len) return nullptr; + const uint8_t* s = (const uint8_t*)base; + const uint8_t* end = s + size - len; + for (; s <= end; ++s) { + bool ok = true; + for (size_t i = 0; i < len; ++i) + if (m[i] && s[i] != b[i]) { ok = false; break; } + if (ok) return (void*)s; + } + return nullptr; +} + +// CProtoBufMsg::ctor: 57 56 53 8B 74 24 10 E8 ?? ?? ?? ?? 81 C3 ?? ?? ?? ?? +// 83 EC 08 C7 46 04 00 00 00 00 +// push edi; push esi; push ebx; mov esi,[esp+10h]; call PIC; add ebx,??; +// sub esp,8; mov [esi+4],0 +static const uint8_t kCtorB[] = { + 0x57,0x56,0x53, 0x8B,0x74,0x24,0x10, 0xE8,0,0,0,0, 0x81,0xC3,0,0,0,0, + 0x83,0xEC,0x08, 0xC7,0x46,0x04,0x00,0x00,0x00,0x00 +}; +static const uint8_t kCtorM[] = { + 1,1,1, 1,1,1,1, 1,0,0,0,0, 1,1,0,0,0,0, + 1,1,1, 1,1,1,1,1,1,1 +}; + +// CProtoBufMsg::Finalize (sub_2ACE490): 56 53 E8 ?? ?? ?? ?? 81 C3 ?? ?? ?? ?? +// 83 EC 04 8B 74 24 10 8B 46 20 85 C0 74 22 +// push esi; push ebx; call PIC; add ebx,??; sub esp,4; mov esi,[esp+10h]; +// mov eax,[esi+20h]; test eax,eax; jz +0x22. The trailing test/jz is required to +// disambiguate from sub_F82850 (a ScheduledFunction dispatcher with an identical +// prologue through 85 C0, diverging at the jz target). Matches the proven sig in +// live_playtime.cpp (kInitB). +static const uint8_t kFinalizeB[] = { + 0x56,0x53, 0xE8,0,0,0,0, 0x81,0xC3,0,0,0,0, 0x83,0xEC,0x04, + 0x8B,0x74,0x24,0x10, 0x8B,0x46,0x20, 0x85,0xC0,0x74,0x22 +}; +static const uint8_t kFinalizeM[] = { + 1,1, 1,0,0,0,0, 1,1,0,0,0,0, 1,1,1, + 1,1,1,1, 1,1,1, 1,1,1,1 +}; + +// CM send = CCMInterface::Send (sub_10E6C90). Not sig-scanned: GamesPlayedHook detours +// it first, clobbering the prologue, so we reuse its entry via GetSendFunc(). + +// RTTI-based vtable/instance resolution. +bool Resolve(uintptr_t base, size_t size, ParseFromArrayFn parseFromArray) { + g_base = base; + g_parseFromArray = parseFromArray; + + // Sig-scan for ctor and Finalize. + g_ctor = (CtorFn)ScanSig(base, size, kCtorB, kCtorM, sizeof(kCtorB)); + g_finalize = (FinalizeFn)ScanSig(base, size, kFinalizeB, kFinalizeM, sizeof(kFinalizeB)); + + // Reuse GamesPlayedHook's resolved CCMInterface::Send entry (see note above). + g_cmSend = (CmSendFn)GamesPlayedHook::GetSendFunc(); + + g_typedVtable = VtableHook::FindVtableByRTTIName( + "12CProtoBufMsgI22CMsgClientGetUserStatsE", base, size); + + // Standalone class name has NO trailing 'E' (that only closes templates); the + // "...E" form is a false-positive substring of the wrapper's RTTI name. + void* msgVtable = VtableHook::FindVtableByRTTIName( + "22CMsgClientGetUserStats", base, size); + if (msgVtable) { + g_defaultInstance = VtableHook::FindGlobalWithVtable(msgVtable, base, size); + } + + // No dtor resolved: each request leaks ~236B (header+body), bounded per session. + bool ok = g_ctor && g_finalize && g_cmSend && g_typedVtable && + g_defaultInstance && g_parseFromArray; + LOG("[SchemaFetch] Resolve: ctor=%p finalize=%p cmSend=%p vtable=%p desc=%p parse=%p -> %s", + (void*)g_ctor, (void*)g_finalize, (void*)g_cmSend, + g_typedVtable, g_defaultInstance, (void*)g_parseFromArray, + ok ? "OK" : "FAILED"); + return ok; +} + +// Session capture. +void CaptureFromOutbound(uint32_t emsg, void* msgObj, void* cmInterface) { + if (!cmInterface || !msgObj) return; + + // Capture the CCMInterface pointer itself -- this is sub_10E6C90's arg_0. + g_cmInterface.store(cmInterface, std::memory_order_relaxed); + + // Capture connection handle from cmInterface + uint32_t conn = *(uint32_t*)((uint8_t*)cmInterface + CCM_OFF_CONNHANDLE); + if (conn != 0) { + // Prefer the handle from GetUserStats (818) -- same CM connection + if (emsg == EMSG_GET_USER_STATS) { + if (g_statsConnHandle.exchange(conn, std::memory_order_relaxed) != conn) + LOG("[SchemaFetch] captured conn=%u from Steam's GetUserStats", conn); + } + g_connHandle.store(conn, std::memory_order_relaxed); + } + + // Capture session fields from cmInterface (always valid once logged in) + if (!g_sessionCaptured.load(std::memory_order_relaxed)) { + uint64_t sid = *(uint64_t*)((uint8_t*)cmInterface + CCM_OFF_STEAMID); + uint32_t ses = *(uint32_t*)((uint8_t*)cmInterface + CCM_OFF_SESSION_ID); + if (sid != 0) { + g_steamId.store(sid, std::memory_order_relaxed); + g_sessionId.store(ses, std::memory_order_relaxed); + // Realm: not directly accessible from cmInterface at a known offset. + // Default realm for public Steam is 1 (EUniverse_Public). + g_realm.store(1, std::memory_order_relaxed); + g_sessionCaptured.store(true, std::memory_order_relaxed); + LOG("[SchemaFetch] captured session: steamid=0x%llX session=%u", + (unsigned long long)sid, ses); + } + } + + // Schedule the proactive schema sweep once we have session + connection + if (g_sessionCaptured.load(std::memory_order_relaxed) && + g_connHandle.load(std::memory_order_relaxed) != 0) { + MaybeScheduleSweep(); + } +} + +// HTTP owner discovery (uses libcurl via IHttpTransport). +static std::vector<uint64_t> FetchReviewOwnerIds(uint32_t appId) { + std::vector<uint64_t> ids; + auto transport = CreateHttpTransport("[SchemaFetch]"); + if (!transport || !transport->Init()) return ids; + + std::string path = "/appreviews/" + std::to_string(appId) + + "?json=1&filter=recent&language=all&purchase_type=all&num_per_page=20"; + auto resp = transport->Request("GET", "store.steampowered.com", path, {}, {}); + if (resp.status != 200 || resp.body.empty()) return ids; + + // Parse "steamid":"<digits>" from JSON + constexpr uint64_t kSteamId64Base = 76561197960265728ull; + size_t pos = 0; + while ((pos = resp.body.find("\"steamid\"", pos)) != std::string::npos) { + pos = resp.body.find(':', pos); + if (pos == std::string::npos) break; + ++pos; + while (pos < resp.body.size() && (resp.body[pos] == ' ' || resp.body[pos] == '"')) ++pos; + size_t start = pos; + while (pos < resp.body.size() && resp.body[pos] >= '0' && resp.body[pos] <= '9') ++pos; + if (start == pos) continue; + uint64_t sid = strtoull(resp.body.c_str() + start, nullptr, 10); + if (sid >= kSteamId64Base) { + bool dup = false; + for (uint64_t existing : ids) if (existing == sid) { dup = true; break; } + if (!dup) ids.push_back(sid); + } + } + return ids; +} + +static bool HasPublicStats(uint32_t appId, uint64_t steamId) { + auto transport = CreateHttpTransport("[SchemaFetch]"); + if (!transport || !transport->Init()) return false; + + std::string path = "/profiles/" + std::to_string(steamId) + + "/stats/" + std::to_string(appId) + "/?xml=1"; + auto resp = transport->Request("GET", "steamcommunity.com", path, {}, {}); + return resp.status == 200 && + resp.body.find("<playerstats>") != std::string::npos && + resp.body.find("<privacyState>public</privacyState>") != std::string::npos; +} + +// Send schema request. +static bool SendSchemaRequest(uint32_t appId, uint64_t ownerId, + void* cmInterface, uint32_t connHandle) { + if (!g_ctor || !g_finalize || !g_cmSend || !g_typedVtable || + !g_defaultInstance || !g_parseFromArray || !cmInterface) + return false; + + // CProtoBufMsg<CMsgClientGetUserStats> on the stack (~44B; over-allocate). + alignas(16) uint8_t msg[128] = {0}; + + CallGuard guard; + int jmpSig = sigsetjmp(g_jmp, 1); + if (jmpSig != 0) { + LOG("[SchemaFetch] SendSchemaRequest app=%u caught signal %d -- aborting", appId, jmpSig); + return false; + } + + g_ctor(msg, (int)EMSG_GET_USER_STATS, 0); + *(void**)(msg + MSG_OFF_VTABLE) = g_typedVtable; + *(void**)(msg + MSG_OFF_DESC) = g_defaultInstance; + g_finalize(msg); + + void* body = *(void**)(msg + MSG_OFF_BODY); + void* hdr = *(void**)(msg + MSG_OFF_HDR); + if (!body || !hdr) { + LOG("[SchemaFetch] app=%u: body=%p hdr=%p NULL -> bail", appId, body, hdr); + return false; + } + + PB::Writer bodyW; + bodyW.WriteFixed64(BODY_GAME_ID, (uint64_t)appId); + bodyW.WriteVarint(BODY_CRC_STATS, 0); + // schema_local_version = -1: sign-extended 64-bit varint (forces "send latest") + bodyW.WriteVarint(BODY_SCHEMA_LOCAL_VERSION, (uint64_t)(int64_t)(-1)); + bodyW.WriteFixed64(BODY_STEAM_ID_FOR_USER, ownerId); + if (!g_parseFromArray(body, bodyW.Data().data(), (int)bodyW.Size())) { + LOG("[SchemaFetch] body ParseFromArray failed for app=%u", appId); + return false; + } + + // jobid_source must be a unique non-negative id (mirrors Windows); the CM + // routes the 819 reply back by it. -1 means "no source job" -> no reply. + static std::atomic<uint64_t> s_jobIdCounter{0x5C00000000000001ull}; + uint64_t jobId = s_jobIdCounter.fetch_add(1, std::memory_order_relaxed); + PB::Writer hdrW; + hdrW.WriteFixed64(HDR_STEAMID, g_steamId.load(std::memory_order_relaxed)); + hdrW.WriteVarint(HDR_SESSION_ID, (uint64_t)(uint32_t)g_sessionId.load(std::memory_order_relaxed)); + hdrW.WriteFixed64(HDR_JOBID_SOURCE, jobId); + hdrW.WriteVarint(HDR_REALM, (uint64_t)g_realm.load(std::memory_order_relaxed)); + // timeout_ms = -1 (no deadline). Required: assertion at msgprotobuf.cpp:980. + hdrW.WriteVarint(HDR_TIMEOUT_MS, (uint64_t)(int64_t)(-1)); + if (!g_parseFromArray(hdr, hdrW.Data().data(), (int)hdrW.Size())) { + LOG("[SchemaFetch] header ParseFromArray failed for app=%u", appId); + return false; + } + + uint8_t sent = g_cmSend(cmInterface, msg); + LOG("[SchemaFetch] SendSchemaRequest app=%u owner=%llu jobid=0x%llX -> %s", + appId, (unsigned long long)ownerId, + (unsigned long long)jobId, sent ? "sent" : "FAILED"); + return sent != 0; +} + +// Request + queue logic. +static void RequestSchemaForApp(uint32_t appId) { + if (!MetadataSync::SchemaFetchEnabled()) return; + if (appId == 0) return; + if (g_connHandle.load(std::memory_order_relaxed) == 0) return; + + { + std::lock_guard<std::mutex> lock(g_fetchMutex); + if (!g_fetchAttempted.insert(appId).second) return; + } + + std::string steamPath = CloudIntercept::GetSteamPath(); + std::string schemaPath = steamPath + "appcache/stats/UserGameStatsSchema_" + + std::to_string(appId) + ".bin"; + + struct stat st; + if (stat(schemaPath.c_str(), &st) == 0 && st.st_size > 0) { + LOG("[SchemaFetch] app %u: schema already on disk (%ld bytes), skipping", + appId, (long)st.st_size); + return; + } + LOG("[SchemaFetch] app %u: needs fetch", appId); + + // Phase 1: discover owners from reviews + verify public stats + std::vector<uint64_t> owners = FetchReviewOwnerIds(appId); + std::vector<uint64_t> verified; + for (uint64_t sid : owners) { + if (g_shuttingDown.load(std::memory_order_acquire)) return; + if (HasPublicStats(appId, sid)) { + verified.push_back(sid); + if (verified.size() >= 3) break; + } + } + + // Phase 2: fallback owners if review discovery found nothing + bool usingFallback = verified.empty(); + if (usingFallback) { + for (uint64_t id : kFallbackOwnerIds) + verified.push_back(id); + } + if (verified.empty()) return; + + // Enqueue sends + { + std::lock_guard<std::mutex> lock(g_sendMutex); + for (uint64_t owner : verified) + g_sendQueue.push({appId, owner}); + } + LOG("[SchemaFetch] app %u: queued %zu request(s) via %s", + appId, verified.size(), usingFallback ? "fallback" : "review-owner discovery"); +} + +// Drain on net thread. +void DrainOnNetThread() { + if (!MetadataSync::SchemaFetchEnabled()) return; + if (t_draining) return; + if (g_shuttingDown.load(std::memory_order_acquire)) return; + + // Only log/work when the queue actually has something, to avoid spamming + // every BYieldingSend tick. + { + std::lock_guard<std::mutex> lock(g_sendMutex); + if (g_sendQueue.empty()) return; + } + + if (!g_sessionCaptured.load(std::memory_order_relaxed)) { + LOG("[SchemaFetch] Drain: queue non-empty but session not captured, deferring"); + return; + } + + uint32_t conn = g_statsConnHandle.load(std::memory_order_relaxed); + if (conn == 0) conn = g_connHandle.load(std::memory_order_relaxed); + if (conn == 0) { + LOG("[SchemaFetch] Drain: queue non-empty but conn==0, deferring"); + return; + } + + void* cmInterface = g_cmInterface.load(std::memory_order_relaxed); + if (!cmInterface) { + LOG("[SchemaFetch] Drain: queue non-empty but cmInterface==null, deferring"); + return; + } + + size_t qsize; + { + std::lock_guard<std::mutex> lock(g_sendMutex); + qsize = g_sendQueue.size(); + } + LOG("[SchemaFetch] Drain: on net thread, conn=%u, %zu queued", conn, qsize); + + t_draining = true; + constexpr int kMaxPerTick = 2; + for (int i = 0; i < kMaxPerTick; ++i) { + SchemaSendItem item; + { + std::lock_guard<std::mutex> lock(g_sendMutex); + if (g_sendQueue.empty()) break; + item = g_sendQueue.front(); + g_sendQueue.pop(); + } + SendSchemaRequest(item.appId, item.owner, cmInterface, conn); + } + t_draining = false; +} + +// Inbound 819 capture (mirror of Windows TryHandleSchemaResponse). + +// Per-user stats template (binary KV cache{ crc=0; PendingChanges=0 }). Steam +// needs UserGameStats_<acctid>_<appid>.bin alongside the schema or stats reading +// fails. Only written when absent so real progress is never clobbered. +static const uint8_t kUserStatsTemplate[38] = { + 0x00,0x63,0x61,0x63,0x68,0x65,0x00,0x02,0x63,0x72,0x63,0x00,0x00,0x00, + 0x00,0x00,0x02,0x50,0x65,0x6e,0x64,0x69,0x6e,0x67,0x43,0x68,0x61,0x6e, + 0x67,0x65,0x73,0x00,0x00,0x00,0x00,0x00,0x08,0x08 +}; + +static bool WriteFileBytes(const std::string& path, const uint8_t* data, size_t len) { + FILE* f = fopen(path.c_str(), "wb"); + if (!f) return false; + size_t w = fwrite(data, 1, len, f); + fclose(f); + return w == len; +} + +bool HandleInbound819(const uint8_t* data, uint32_t len) { + if (!data || len < 8) return false; + + uint32_t emsgRaw = *(const uint32_t*)data; + if ((emsgRaw & PROTO_FLAG) == 0) return false; // not a protobuf msg + if ((emsgRaw & EMSG_MASK) != EMSG_GET_USER_STATS_RESP) return false; + + uint32_t headerLen = *(const uint32_t*)(data + 4); + if ((uint64_t)8 + headerLen > len) return false; + const uint8_t* bodyData = data + 8 + headerLen; + uint32_t bodyLen = len - 8 - headerLen; + if (bodyLen == 0) return false; + + auto bodyFields = PB::Parse(bodyData, bodyLen); + + // Correlate by game_id (appid), since the framework assigns its own jobid. + const PB::Field* gameIdF = PB::FindField(bodyFields, RESP_GAME_ID); + if (!gameIdF) return false; + uint32_t appId = (uint32_t)(gameIdF->varintVal & 0xFFFFFF); + if (appId == 0) return false; + + { + std::lock_guard<std::mutex> lock(g_fetchMutex); + if (g_fetchAttempted.find(appId) == g_fetchAttempted.end()) + return false; // not an app we asked about + } + + int32_t eresult = 2; + if (auto* er = PB::FindField(bodyFields, RESP_ERESULT)) eresult = (int32_t)er->varintVal; + const PB::Field* schemaF = PB::FindField(bodyFields, RESP_SCHEMA); + + bool hasSchema = (eresult == 1 && schemaF && + schemaF->wireType == PB::LengthDelimited && schemaF->dataLen > 0); + if (!hasSchema) { + // Owner doesn't own the game or sent no schema; another owner's reply may land it. + LOG("[SchemaFetch] HandleInbound819: app %u no schema (eresult=%d)", appId, eresult); + return true; + } + + std::string steamPath = CloudIntercept::GetSteamPath(); + std::string schemaPath = steamPath + "appcache/stats/UserGameStatsSchema_" + + std::to_string(appId) + ".bin"; + + // Skip if a file already exists with the same size (no new achievements); + // overwrite if size differs (developer added/removed achievements). + struct stat st; + if (stat(schemaPath.c_str(), &st) == 0 && st.st_size > 0) { + if ((uint32_t)st.st_size == schemaF->dataLen) return true; + LOG("[SchemaFetch] app %u: schema changed (%ld -> %u bytes), updating", + appId, (long)st.st_size, schemaF->dataLen); + } + + if (!WriteFileBytes(schemaPath, schemaF->data, schemaF->dataLen)) { + LOG("[SchemaFetch] app %u: failed to write %s", appId, schemaPath.c_str()); + return true; + } + LOG("[SchemaFetch] app %u: wrote schema (%u bytes) from server response", + appId, schemaF->dataLen); + + // Per-user stats file -- only create if absent. + uint32_t acctId = CloudIntercept::GetAccountId(); + if (acctId != 0) { + std::string statsPath = steamPath + "appcache/stats/UserGameStats_" + + std::to_string(acctId) + "_" + std::to_string(appId) + ".bin"; + struct stat ss; + if (stat(statsPath.c_str(), &ss) != 0) { + if (WriteFileBytes(statsPath, kUserStatsTemplate, sizeof(kUserStatsTemplate))) + LOG("[SchemaFetch] app %u: wrote per-user stats template (acct %u)", appId, acctId); + } + } + return true; +} + +// Proactive sweep. +static void SweepNamespaceSchemas() { + if (!MetadataSync::SchemaFetchEnabled()) return; + if (g_connHandle.load(std::memory_order_relaxed) == 0) return; + + auto apps = CloudIntercept::GetNamespaceApps(); + if (apps.empty()) return; + + LOG("[SchemaFetch] Proactive sweep: checking %zu namespace app(s)", apps.size()); + + std::string steamPath = CloudIntercept::GetSteamPath(); + std::vector<uint32_t> needed; + for (uint32_t appId : apps) { + std::string path = steamPath + "appcache/stats/UserGameStatsSchema_" + + std::to_string(appId) + ".bin"; + struct stat st; + if (stat(path.c_str(), &st) != 0 || st.st_size == 0) { + needed.push_back(appId); + } + } + if (needed.empty()) { + LOG("[SchemaFetch] All schemas present on disk"); + return; + } + + LOG("[SchemaFetch] %zu app(s) need schemas, fetching with 4 workers", needed.size()); + + constexpr int kWorkers = 4; + std::atomic<size_t> idx{0}; + std::atomic<int> totalRequested{0}; + std::vector<std::thread> workers; + for (int w = 0; w < kWorkers; ++w) { + workers.emplace_back([&needed, &idx, &totalRequested] { + while (true) { + if (g_shuttingDown.load(std::memory_order_acquire)) break; + size_t i = idx.fetch_add(1, std::memory_order_relaxed); + if (i >= needed.size()) break; + RequestSchemaForApp(needed[i]); + totalRequested.fetch_add(1, std::memory_order_relaxed); + } + }); + } + for (auto& t : workers) t.join(); + LOG("[SchemaFetch] Sweep complete: enqueued schemas for %d app(s)", + totalRequested.load(std::memory_order_relaxed)); +} + +static void MaybeScheduleSweep() { + if (!MetadataSync::SchemaFetchEnabled()) return; + if (g_sweepScheduled.exchange(true)) return; + std::thread([] { + constexpr int kSettleMs = 15000; + for (int waited = 0; waited < kSettleMs; waited += 500) { + if (g_shuttingDown.load(std::memory_order_acquire)) return; + usleep(500000); + } + if (g_shuttingDown.load(std::memory_order_acquire)) return; + SweepNamespaceSchemas(); + }).detach(); +} + +// Shutdown. +void Shutdown() { + g_shuttingDown.store(true, std::memory_order_release); +} + +} // namespace SchemaFetch diff --git a/src/platform/linux/schema_fetch.h b/src/platform/linux/schema_fetch.h new file mode 100644 index 00000000..38f695a1 --- /dev/null +++ b/src/platform/linux/schema_fetch.h @@ -0,0 +1,30 @@ +#pragma once +#include <cstdint> +#include <cstddef> + +// Linux schema fetch: sends CMsgClientGetUserStats (818) for a discovered owner's +// SteamID, captures the 819 reply, and writes UserGameStatsSchema_<appid>.bin. +namespace SchemaFetch { + +// Matches the existing ParseFromArray_t in cloud_hooks.cpp / LivePlaytime. +using ParseFromArrayFn = int(*)(void* msg, const void* data, int len); + +// Resolve steamclient.so entry points; called once during init. +bool Resolve(uintptr_t steamclientBase, size_t steamclientSize, + ParseFromArrayFn parseFromArray); + +// Capture connHandle + session fields (steamid, session_id, realm) from a live +// outbound CM message; needed by our injected 818 requests. +void CaptureFromOutbound(uint32_t emsg, void* msgObj, void* cmInterface); + +// Drain queued schema-fetch sends on the network thread. +void DrainOnNetThread(); + +// Handle an inbound 819: correlate by game_id (appid) and, if it carries a schema, +// write the .bin + stats template. Returns true if a matching reply was consumed. +bool HandleInbound819(const uint8_t* data, uint32_t len); + +// Signal shutdown to abort pending HTTP work and stop sending. +void Shutdown(); + +} // namespace SchemaFetch diff --git a/src/platform/linux/vtable_hook.cpp b/src/platform/linux/vtable_hook.cpp index 481537f3..1304eb6e 100644 --- a/src/platform/linux/vtable_hook.cpp +++ b/src/platform/linux/vtable_hook.cpp @@ -514,6 +514,120 @@ void** VtableHook::FindRemoteStorageVtable(uintptr_t steamBase, size_t steamSize return vtableFuncs; } +void** VtableHook::FindVtableByRTTIName(const char* mangledName, + uintptr_t steamBase, size_t steamSize) +{ + const size_t nameLen = strlen(mangledName); + + // 1) RTTI type-name string (.rodata, never relocated). + const uint8_t* rttiStr = FindBytes(mangledName, nameLen + 1, steamBase, steamSize); + if (!rttiStr) + { + Log::Error("RTTI string '%s' not found in steamclient.so", mangledName); + return nullptr; + } + uintptr_t rttiStrAddr = reinterpret_cast<uintptr_t>(rttiStr); + uintptr_t rttiStrVaddr = rttiStrAddr - steamBase; // file vaddr (pre-relocation) + Log::Debug("RTTI '%s' at %p (vaddr 0x%zx)", mangledName, rttiStr, (size_t)rttiStrVaddr); + + // 2) typeinfo: name_ptr holds relocated absolute or unrelocated file vaddr. + const uintptr_t* nameField = FindPointerValue(rttiStrAddr, rttiStrVaddr, + steamBase, steamSize); + if (!nameField) + { + Log::Error("typeinfo for '%s' not found (rel=0x%zx unrel=0x%zx)", + mangledName, (size_t)rttiStrAddr, (size_t)rttiStrVaddr); + return nullptr; + } + const uintptr_t* typeinfo = nameField - 1; // typeinfo starts one slot before name + uintptr_t typeinfoAddr = reinterpret_cast<uintptr_t>(typeinfo); + bool relocated = (*nameField == rttiStrAddr); + Log::Debug("typeinfo for '%s' at %p (%s)", mangledName, typeinfo, + relocated ? "relocated" : "unrelocated"); + + // 3) Wait for .data.rel.ro relocations if pending (vtable typeinfo ptr is + // the runtime absolute typeinfoAddr only once relocated). + if (!relocated) + { + volatile uintptr_t* nameSlot = const_cast<volatile uintptr_t*>(nameField); + int waitMs = 0; + const int maxWaitMs = 30000; + while (*nameSlot != rttiStrAddr && waitMs < maxWaitMs) + { + usleep(50000); + waitMs += 50; + } + if (*nameSlot != rttiStrAddr) + { + Log::Error("relocations did not complete for '%s' after %dms", mangledName, waitMs); + return nullptr; + } + } + + // 4) vtable: scan for [offset_to_top=0, typeinfo_ptr] header. + for (int r = 0; r < g_readableCount; r++) + { + if (g_readableRanges[r].end <= steamBase || + g_readableRanges[r].start >= steamBase + steamSize) + continue; + + const uintptr_t* scanStart = reinterpret_cast<const uintptr_t*>( + (g_readableRanges[r].start + sizeof(uintptr_t) - 1) & ~(sizeof(uintptr_t) - 1)); + const uintptr_t* scanEnd = reinterpret_cast<const uintptr_t*>( + g_readableRanges[r].end & ~(sizeof(uintptr_t) - 1)); + + for (const uintptr_t* p = scanStart; p + 1 < scanEnd; p++) + { + if (*p == 0 && *(p + 1) == typeinfoAddr) + { + void** vtableFuncs = reinterpret_cast<void**>(const_cast<uintptr_t*>(p + 2)); + if (!CanReadMemory(vtableFuncs, sizeof(void*))) + continue; + Log::Info("vtable for '%s' at %p (offset 0x%zx)", mangledName, + vtableFuncs, reinterpret_cast<uintptr_t>(vtableFuncs) - steamBase); + return vtableFuncs; + } + } + } + + Log::Error("vtable for '%s' not found (typeinfo=%p)", mangledName, typeinfo); + return nullptr; +} + +void* VtableHook::FindGlobalWithVtable(void* vtablePtr, + uintptr_t steamBase, size_t steamSize) +{ + uintptr_t target = reinterpret_cast<uintptr_t>(vtablePtr); + + // Default instances live in writable .data/.bss. Scan writable ranges. + for (int r = 0; r < g_writableCount; r++) + { + if (g_writableRanges[r].end <= steamBase || + g_writableRanges[r].start >= steamBase + steamSize) + continue; + + const uintptr_t* scanStart = reinterpret_cast<const uintptr_t*>( + (g_writableRanges[r].start + sizeof(uintptr_t) - 1) & ~(sizeof(uintptr_t) - 1)); + const uintptr_t* scanEnd = reinterpret_cast<const uintptr_t*>( + g_writableRanges[r].end & ~(sizeof(uintptr_t) - 1)); + + for (const uintptr_t* p = scanStart; p + 2 < scanEnd; p++) + { + // First word == vtable, next two words == 0 (arena / has_bits). + if (*p == target && *(p + 1) == 0 && *(p + 2) == 0) + { + void* inst = reinterpret_cast<void*>(const_cast<uintptr_t*>(p)); + Log::Info("default instance for vtable %p at %p (offset 0x%zx)", + vtablePtr, inst, reinterpret_cast<uintptr_t>(inst) - steamBase); + return inst; + } + } + } + + Log::Error("default instance for vtable %p not found", vtablePtr); + return nullptr; +} + bool VtableHook::InstallCloudEnabledHook(void** vtable, CloudEnabledHookInfo& info) { size_t slotIndex = ResolveCloudEnabledSlot(vtable, 0, UINTPTR_MAX); diff --git a/src/platform/linux/vtable_hook.h b/src/platform/linux/vtable_hook.h index 2343074f..cd22e28d 100644 --- a/src/platform/linux/vtable_hook.h +++ b/src/platform/linux/vtable_hook.h @@ -33,6 +33,16 @@ namespace VtableHook // Locate CUserRemoteStorage vtable via RTTI scan. void** FindRemoteStorageVtable(uintptr_t steamBase, size_t steamSize); + // Locate the primary vtable for a type by its Itanium-mangled RTTI name + // (e.g. "22CMsgClientGetUserStatsE"). Returns slot-0 pointer or nullptr. + void** FindVtableByRTTIName(const char* mangledName, + uintptr_t steamBase, size_t steamSize); + + // Find a global instance whose first word holds `vtablePtr` by scanning + // writable ranges. Returns nullptr if none found. + void* FindGlobalWithVtable(void* vtablePtr, + uintptr_t steamBase, size_t steamSize); + // Swap vtable slots 5, 7, 8 with our hooks. Saves originals into `info`. bool InstallHooks(void** vtable, VtableInfo& info); diff --git a/src/platform/win/cloud_intercept.cpp b/src/platform/win/cloud_intercept.cpp index 7cb1df4a..a085c860 100644 --- a/src/platform/win/cloud_intercept.cpp +++ b/src/platform/win/cloud_intercept.cpp @@ -15,6 +15,7 @@ #include "cloud_storage.h" #include "coop_yield.h" #include "cloud_provider.h" +#include "cloud_provider_base.h" // g_uploadInFlightCapBytes #include "pending_ops_journal.h" #include "json.h" #include "legacy_metadata_cleanup.h" @@ -373,6 +374,22 @@ static void ScheduleStartupMetadataSync() { // Manifest system fetches CN/manifest on-demand per app; no bulk startup sync. if (!CloudStorage::IsCloudActive()) return; LOG("[StartupSync] Cloud active; metadata will be fetched on-demand per app"); + + // Wipe the per-account stats cache when a different accountId is captured so a + // second account can't inherit the previous one's stats. No-op if unchanged. + uint32_t accountId = GetAccountId(); + bool switched = StatsStore::ResetForAccountSwitch(accountId); + + // Re-run native imports now that login set the accountId (the boot sweep ran + // before it was known, leaving never-launched apps at stats=0 -> 0.0% rarity). + // Detached off the net thread; runs once per login and re-arms on a switch. + static std::atomic<bool> s_postLoginImportStarted{false}; + if (switched) s_postLoginImportStarted.store(false); + if (!s_postLoginImportStarted.exchange(true)) { + std::thread([]() { + StatsStore::RetryNativeImportsAfterLogin(); + }).detach(); + } } #define g_syncLuas MetadataSync::syncLuas @@ -507,7 +524,7 @@ static std::vector<uint32_t> GetNamespaceApps() { } uint32_t GetAccountId(); // defined later -void RequestSchemaForApp(uint32_t appId); // defined later (schema auto-fetch) +void RequestSchemaForApp(uint32_t appId, bool forceRefresh = false); // defined later // "Mark as private" support: Steam stores per-user private appIds as a JSON array // under PrivateApps_<accountId> in localconfig.vdf. We honor it so the friends @@ -1708,12 +1725,10 @@ static bool __fastcall ServiceMethodDirectHook(void* thisptr, const char* method return result; } - // Native Player.GetUserStats#1 (per IDA, this lands on slot 4 / vtable+32). - // Bodies here are RAW protobuf objects (no CProtoBufMsg +48 wrapper). - // Gated by sync_achievements: when off, do not interfere -- pass straight - // through to Steam's real server. + // Native Player.GetUserStats#1 (slot 4 / vtable+32). RAW protobuf bodies (no +48 + // wrapper). Serving our schema+stats makes Steam display the achievement page. if (strcmp(methodName, StatsHandlers::RPC_GET_USER_STATS) == 0 - && MetadataSync::AchievementsEnabled()) { + && MetadataSync::SchemaFetchEnabled()) { if (requestBody && responseBody && g_serializeToArray) { auto reqBytes = SerializeBodyToBytes(requestBody); auto reqFields = PB::Parse(reqBytes.data(), reqBytes.size()); @@ -1910,12 +1925,10 @@ static bool __fastcall ServiceMethodHook(void* thisptr, const char* methodName, } // ---- Native stats / playtime service RPCs -------------------------------- - // Player.GetUserStats#1 (per-app): for namespace apps, answer from our store. - // Player.ClientGetLastPlayedTimes#1 (account-wide): let the real server reply, - // then APPEND our namespace apps' playtime so Steam shows it. Real owned games - // keep their server playtime (the client merges per-appid). See IDA notes. + // Player.GetUserStats#1 (per-app, +48 wrapper): answer namespace apps from our + // store so Steam displays the achievement page. Gated by schema_fetch. if (strcmp(methodName, StatsHandlers::RPC_GET_USER_STATS) == 0 - && MetadataSync::AchievementsEnabled()) { + && MetadataSync::SchemaFetchEnabled()) { if (request && response) { void* reqBody = *(void**)((uintptr_t)request + 48); if (reqBody) { @@ -2944,6 +2957,80 @@ static __int64 __fastcall BuildDepotDependencyHook(__int64* a1, unsigned int a2, static bool TryHandleSchemaResponse(const uint8_t* data, uint32_t size); // fwd decl +// Inject the on-disk schema into an incoming 819 that lacks one, so Steam registers +// the achievement page for lua-unlocked games Valve won't serve stats for. +static void TryInjectSchemaInto819(CNetPacket* pkt) { + if (!MetadataSync::SchemaFetchEnabled()) return; + PacketView p; + if (!ParsePacket(pkt->pubData, pkt->cubData, p)) return; + + auto bodyFields = PB::Parse(p.bodyData, p.bodyLen); + const PB::Field* gameIdF = PB::FindField(bodyFields, 1); + if (!gameIdF) return; + uint32_t appId = (uint32_t)(gameIdF->varintVal & 0xFFFFFF); + if (appId == 0 || !IsNamespaceApp(appId)) return; + + // Already has a schema -- nothing to do. + const PB::Field* schemaF = PB::FindField(bodyFields, 4); + if (schemaF && schemaF->wireType == PB::LengthDelimited && schemaF->dataLen > 0) + return; + + // Load schema from disk. + std::string schemaPath = g_steamPath + "appcache\\stats\\UserGameStatsSchema_" + + std::to_string(appId) + ".bin"; + HANDLE hFile = CreateFileA(schemaPath.c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (hFile == INVALID_HANDLE_VALUE) return; + DWORD fileSize = GetFileSize(hFile, nullptr); + if (fileSize == 0 || fileSize == INVALID_FILE_SIZE) { CloseHandle(hFile); return; } + std::vector<uint8_t> schema(fileSize); + DWORD bytesRead = 0; + if (!ReadFile(hFile, schema.data(), fileSize, &bytesRead, nullptr) || bytesRead != fileSize) { + CloseHandle(hFile); + return; + } + CloseHandle(hFile); + + // Rebuild body with schema injected. Preserve all original fields except + // eresult (force to OK) and schema (replace from disk). + PB::Writer newBody; + for (auto& f : bodyFields) { + if (f.fieldNum == 2) continue; // eresult -- we force OK below + if (f.fieldNum == 4) continue; // schema -- replaced from disk + if (f.wireType == PB::Varint) + newBody.WriteVarint(f.fieldNum, f.varintVal); + else if (f.wireType == PB::Fixed64) + newBody.WriteFixed64(f.fieldNum, f.varintVal); + else if (f.wireType == PB::Fixed32) + newBody.WriteFixed32(f.fieldNum, (uint32_t)f.varintVal); + else if (f.wireType == PB::LengthDelimited) + newBody.WriteBytes(f.fieldNum, f.data, f.dataLen); + } + newBody.WriteVarint(2, 1); // eresult = OK + newBody.WriteBytes(4, schema.data(), schema.size()); + + // Rebuild entire packet: [emsg(4)][hdrLen(4)][header][body] + uint32_t emsgRaw = (EMSG_CLIENT_GET_USER_STATS_RESP | PROTO_FLAG); + uint32_t headerLen = p.headerLen; + size_t newPktSize = 8 + headerLen + newBody.Size(); + uint8_t* newBuf = (uint8_t*)VirtualAlloc(nullptr, newPktSize, + MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if (!newBuf) return; + memcpy(newBuf, &emsgRaw, 4); + memcpy(newBuf + 4, &headerLen, 4); + memcpy(newBuf + 8, p.headerData, headerLen); + memcpy(newBuf + 8 + headerLen, newBody.Data().data(), newBody.Size()); + + // Swap the packet data. The old buffer is owned by Steam's allocator so we + // can't free it -- just replace the pointer. The new buffer leaks (one per + // 819 response for namespace apps, tiny -- ~40KB, once per app per session). + pkt->pubData = newBuf; + pkt->cubData = (uint32_t)newPktSize; + + LOG("[Schema] Injected disk schema (%u bytes) into 819 for namespace app %u", + fileSize, appId); +} + // RecvPkt monitor hook (logging + Approach D injection drain) static int64_t __fastcall RecvPktMonitorHook(void* thisptr, CNetPacket* pkt) { HookGuard guard; @@ -2964,6 +3051,10 @@ static int64_t __fastcall RecvPktMonitorHook(void* thisptr, CNetPacket* pkt) { // Capture our injected schema-fetch responses (legacy EMsg 819). if (emsg == EMSG_CLIENT_GET_USER_STATS_RESP) { TryHandleSchemaResponse(pkt->pubData, pkt->cubData); + // For namespace apps: if Valve's response has no schema, inject it from + // disk so Steam registers the achievement page. Without this, lua-unlocked + // games show no achievements because Valve doesn't recognize ownership. + TryInjectSchemaInto819(pkt); return g_originalRecvPkt(thisptr, pkt); // let Steam process it too } @@ -4126,8 +4217,7 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa g_manifestPinsEnabled = pinCfg["manifest_pinning"].boolean(); if (pinCfg["auto_comment"].type == Json::Type::Bool) g_autoComment = pinCfg["auto_comment"].boolean(); - if (pinCfg["show_non_steam_game"].type == Json::Type::Bool) - g_showNonSteamGame = pinCfg["show_non_steam_game"].boolean(); + // show_non_steam_game is per-user, so it lives in the AppData config (read below). size_t totalPins = 0; @@ -4492,6 +4582,15 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa HttpServer::SetMaxUploadMB(mb); } + // Concurrency cap, not a speed knob (see g_uploadInFlightCapBytes). Clamp + // 24..64 MB; out-of-range/absent keeps the 24 MB default. + if (cfg["upload_inflight_mb"].type == Json::Type::Number) { + int mb = static_cast<int>(cfg["upload_inflight_mb"].integer()); + if (mb >= 24 && mb <= 64) + g_uploadInFlightCapBytes.store((uint64_t)mb << 20, + std::memory_order_relaxed); + } + // Lua sync requires SteamTools. if (MetadataSync::steamToolsPresent.load(std::memory_order_relaxed)) { if (cfg["sync_luas"].type == Json::Type::Bool) @@ -4503,9 +4602,9 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa MetadataSync::syncAchievements = cfg["sync_achievements"].boolean(); if (cfg["sync_playtime"].type == Json::Type::Bool) MetadataSync::syncPlaytime = cfg["sync_playtime"].boolean(); - // Experimental: proactive schema fetch (opt-in, default off). - if (cfg["experimental_schema_fetch"].type == Json::Type::Bool) - MetadataSync::schemaFetch = cfg["experimental_schema_fetch"].boolean(); + // Schema fetch (default on). + if (cfg["schema_fetch"].type == Json::Type::Bool) + MetadataSync::schemaFetch = cfg["schema_fetch"].boolean(); // UNSUPPORTED WIP OVERRIDE NON-ST CLIENT GATE (default off). Lets a non-ST // client run the metadata features that are otherwise hard-gated to ST. if (cfg["override_non_st_client_gate"].type == Json::Type::Bool) @@ -4519,6 +4618,9 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa MetadataSync::overrideNonStGate.load() ? 1 : 0, MetadataSync::StGateOpen() ? 1 : 0); if (!cloudSaveOnly) { + // Per-user toggle: defaults to true (set at declaration) when absent. + if (cfg["show_non_steam_game"].type == Json::Type::Bool) + g_showNonSteamGame = cfg["show_non_steam_game"].boolean(); if (cfg["parental_bypass_playtime"].type == Json::Type::Bool) g_parentalBypassPlaytime = cfg["parental_bypass_playtime"].boolean(); if (cfg["parental_ignore_playtime"].type == Json::Type::Bool) @@ -4599,11 +4701,19 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa } // Overlay our app entries (already content-merged in the store). + // Fold each onto the live cloud entry (monotonic playtime, union + // achievements/stats) instead of replacing it, so a stale/lower copy + // can't clobber a higher value another device wrote after our startup + // pull -- the account-blob RMW mirror of the WriteAppStats guard. bool changed = false; for (const auto& [appId, json] : all) { if (appId == 0) continue; std::string key = std::to_string(appId); - Json::Value appVal = Json::Parse(json); + std::string baseEntry = root.has(key) + ? Json::Stringify(root.objVal[key]) : std::string(); + std::string mergedEntry = + StatsStore::MergeAppStatsJson(baseEntry, json); + Json::Value appVal = Json::Parse(mergedEntry); if (!root.has(key) || !Json::DeepEqual(root.objVal[key], appVal)) { root.objVal[key] = std::move(appVal); changed = true; @@ -4645,19 +4755,30 @@ void Init(const std::string& steamPath, bool cloudSaveOnly, CR_NotifyFn notifyCa // UserGameStats blobs (appcache\stats\UserGameStats_<accountId>_<appId>.bin). StatsStore::SetAccountIdProvider([]() -> uint32_t { return GetAccountId(); }); StatsStore::SetNamespacePredicate([](uint32_t appId) { return IsNamespaceApp(appId); }); - // When an import finds no achievement schema, fetch it from Steam's server. + // On import, refresh schema from Steam (forceRefresh detects new achievements; + // deduped per app per session by g_schemaFetchAttempted). StatsStore::SetSchemaMissingCallback([](uint32_t appId) { - // Run off-thread: this is called under the store mutex during import, - // and the fetch sends network messages + sleeps. - std::thread([appId] { RequestSchemaForApp(appId); }).detach(); + std::thread([appId] { RequestSchemaForApp(appId, true); }).detach(); }); StatsStore::Init(cloudRoot, g_steamPath); StatsHandlers::Init(); // Only seed when a stats feature is enabled: SeedApps also uploads imported // stats, so with both off it must stay inert (no cloud reads or writes). + // Run on a background thread: SeedApps does a cloud read per app, which on the + // init thread would block Steam's userdata load. g_mutex-serialized, so a launch + // racing the seed is safe. Tracked in g_bgThreads (joined at shutdown). if (MetadataSync::AchievementsEnabled() || - MetadataSync::PlaytimeEnabled()) - StatsStore::SeedApps(GetNamespaceApps()); + MetadataSync::PlaytimeEnabled()) { + std::thread seed([] { + if (g_shuttingDown.load(std::memory_order_acquire)) return; + StatsStore::SeedApps(GetNamespaceApps()); + }); + std::lock_guard<std::mutex> lock(g_bgThreadsMutex); + if (g_shuttingDown.load(std::memory_order_acquire)) + seed.detach(); + else + g_bgThreads.push_back(std::move(seed)); + } // Background: poll the cloud for another device's playtime advances and push the // new totals into the running client's tracking map + library UI -- mirroring @@ -4873,7 +4994,7 @@ static void MaybeScheduleSchemaSweep() { if (!MetadataSync::SchemaFetchEnabled()) return; if (g_schemaSweepScheduled.exchange(true)) return; // once per session std::thread([] { - constexpr int kStartupSettleMs = 90000; // wait for startup to finish + constexpr int kStartupSettleMs = 15000; // wait for startup to settle for (int waited = 0; waited < kStartupSettleMs; waited += 500) { if (g_shuttingDown.load(std::memory_order_acquire)) return; Sleep(500); @@ -4884,38 +5005,32 @@ static void MaybeScheduleSchemaSweep() { } static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { - // Capture a live connection handle for our injected schema-fetch requests. - // Do NOT kick the schema sweep here: this fires during Steam's login/startup - // RPC burst, and injecting our requests then stalls the client (spinning-logo - // hang). The sweep is started on a delay timer (see SweepNamespaceSchemas / - // the deferred-start thread below) once startup has settled. + // Capture a live connection handle for injected schema-fetch requests. Don't kick + // the sweep here -- doing so during the startup RPC burst stalls the client. if (connHandle && pMsg) { - // Capture the connection handle, preferring the one Steam itself uses for - // GetUserStats (EMsg 818) -- that is the CM connection that receives 819 - // replies. A handle grabbed from an unrelated send may be a different - // connection, so the server's reply would not route to where we listen. + // Prefer the conn Steam uses for GetUserStats (EMsg 818); that CM conn receives + // the 819 replies, so the server's reply routes back to where we listen. uint32_t hookEmsg = *(uint32_t*)((uintptr_t)pMsg + CPROTOBUFMSG_OFF_EMSG) & EMSG_MASK; if (hookEmsg == EMSG_CLIENT_GET_USER_STATS) { if (g_statsConnHandle.exchange(connHandle, std::memory_order_relaxed) != connHandle) LOG("[Schema] captured CM conn=%u from Steam's own GetUserStats", connHandle); - // Capture Steam's own header session fields (steamid, client_sessionid, - // realm) so we can stamp them onto our injected requests -- the CM - // server drops a GetUserStats whose header lacks these. - if (!g_hdrCaptured.load(std::memory_order_relaxed)) { - void* ownHdr = *(void**)((uintptr_t)pMsg + 0x28); - if (ownHdr) { - uint8_t* hb = (uint8_t*)ownHdr; - uint64_t sid = *(uint64_t*)(hb + 104); - uint32_t ses = *(uint32_t*)(hb + 112); - uint32_t rlm = *(uint32_t*)(hb + 156); - if (sid != 0) { - g_hdrSteamId.store(sid, std::memory_order_relaxed); - g_hdrSessionId.store(ses, std::memory_order_relaxed); - g_hdrRealm.store(rlm, std::memory_order_relaxed); - g_hdrCaptured.store(true, std::memory_order_relaxed); - LOG("[Schema] captured header session: steamid=0x%llX sessionid=%u realm=%u", - (unsigned long long)sid, ses, rlm); - } + } + // Capture session fields from any outgoing message so injected 818s carry valid + // context (if no game triggers a GetUserStats, the CM would drop our requests). + if (!g_hdrCaptured.load(std::memory_order_relaxed)) { + void* ownHdr = *(void**)((uintptr_t)pMsg + 0x28); + if (ownHdr) { + uint8_t* hb = (uint8_t*)ownHdr; + uint64_t sid = *(uint64_t*)(hb + 104); + uint32_t ses = *(uint32_t*)(hb + 112); + uint32_t rlm = *(uint32_t*)(hb + 156); + if (sid != 0) { + g_hdrSteamId.store(sid, std::memory_order_relaxed); + g_hdrSessionId.store(ses, std::memory_order_relaxed); + g_hdrRealm.store(rlm, std::memory_order_relaxed); + g_hdrCaptured.store(true, std::memory_order_relaxed); + LOG("[Schema] captured header session: steamid=0x%llX sessionid=%u realm=%u (from emsg=%u)", + (unsigned long long)sid, ses, rlm, hookEmsg); } } } @@ -4972,13 +5087,8 @@ static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { } else if (emsg == EMSG_CLIENT_GET_USER_STATS && MetadataSync::AchievementsEnabled()) { - // Namespace apps fetch stats over the legacy 818 path (appid below the - // service-method threshold; see CAPIJobRequestUserStats_BYieldingRun), - // so serve a 819 from the store. The job MERGES our stats into its - // native blob loaded from disk (YieldingMergeStats -> "using SERVER - // stats data"); with no local UserGameStats_<acct>_<appid>.bin the load - // fails and it skips, so this surfaces another device's unlocks only - // when the game has been run here. + // We are the server for namespace apps: inject our 819 and suppress the 818 + // so Valve's CM can't send a competing response that clobbers our stats. void* bodyObj = *(void**)((uintptr_t)pMsg + CPROTOBUFMSG_OFF_BODY); if (bodyObj) { auto reqBytes = SerializeBodyToBytes(bodyObj); @@ -4991,11 +5101,13 @@ static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { auto built = StatsHandlers::HandleLegacyGetUserStats( reqBytes.data(), reqBytes.size(), g_steamId.load()); if (built.has_value() && !built->empty()) { - LOG("[Stats] GetUserStats(818) observed app=%u jobid=%llu -> serving 819", + LOG("[Stats] GetUserStats(818) app=%u jobid=%llu -> serving 819 (blocking send)", appId, (unsigned long long)jobId); InjectLegacyUserStatsResponse(jobId, appId, *built); + // Don't forward to Valve -- we are the sole server. + return 1; } else { - LOG("[Stats] GetUserStats(818) app=%u: store had nothing to serve", appId); + LOG("[Stats] GetUserStats(818) app=%u: store had nothing to serve, passing through", appId); } } } @@ -5007,31 +5119,333 @@ static uint8_t __fastcall BAsyncSendHook(void* pMsg, uint32_t connHandle) { } // ── Achievement-schema auto-fetch ────────────────────────────────────── -// -// When a namespace (lua) app has no UserGameStatsSchema_<appid>.bin, we ask -// Steam's server for the schema by sending CMsgClientGetUserStats (EMsg 818) -// with schema_local_version=-1 ("send latest") on behalf of a SteamID that -// OWNS the game (steam_id_for_user). The server only returns the schema to an -// owner, so we rotate through a list of public accounts that own huge -// libraries until one succeeds. Steam's own 819 response handler writes the -// schema .bin to disk -- we only trigger the request. -// (Technique verified against steamclient64 + SLScheevo / GBE_Tools.) - -// Public SteamID64s with very large libraries (subset of SLScheevo's list). -static const uint64_t kSchemaOwnerIds[] = { - 76561197978902089ull, // Terrum (https://steamcommunity.com/id/Terrum/) - 76561198028121353ull, 76561198017975643ull, 76561198001678750ull, - 76561198355953202ull, 76561197993544755ull, 76561198121643357ull, - 76561198001237877ull, 76561197979911851ull, 76561198217186687ull, - 76561198152618007ull, 76561197973009892ull, 76561198237402290ull, - 76561198213148949ull, 76561198108581917ull, 76561198037867621ull, - 76561197965319961ull, 76561197976597747ull, 76561198019712127ull, - 76561198094227663ull, 76561197969050296ull, +// Request a missing schema via CMsgClientGetUserStats (818, schema_local_version=-1) +// on behalf of a SteamID that OWNS the game; the CM only returns schemas to owners. +// Owner discovery: (1) appdetails confirms category 22; (2) probe recent reviewers +// for public stats; (3) fall back to known large-library accounts. + +// Fallback SteamID64s, used only if review-owner discovery yields no candidates. +static const uint64_t kFallbackOwnerIds[] = { + 76561197978902089ull, 76561198028121353ull, 76561198017975643ull, + 76561198001678750ull, 76561198355953202ull, 76561197993544755ull, +}; + +// Per-app schema fetch state. Tracks which owners we've tried and manages the +// review-owner discovery + retry pipeline. +struct SchemaFetchState { + std::vector<uint64_t> owners; // owners queued to try (review + fallback) + size_t nextOwnerIdx = 0; // next owner in `owners` to enqueue + uint32_t responsesReceived = 0; // 819 responses received (with or without schema) + uint32_t requestsSent = 0; // requests dispatched so far + bool reviewFetched = false; // true once we've tried the review API + bool resolved = false; // true once schema written to disk }; -// Apps we've already attempted a schema fetch for this session (avoid spamming). static std::mutex g_schemaFetchMutex; static std::unordered_set<uint32_t> g_schemaFetchAttempted; +static std::unordered_map<uint32_t, SchemaFetchState> g_schemaFetchStates; + +// Cache of apps checked for achievement support. true = supports, false = does not. +static std::unordered_map<uint32_t, bool> g_achievementSupportCache; + +// Persistent skip-list of apps with no fetchable schema (cr_schema_skip.txt). +// Prevents the proactive sweep from re-queuing ~120 schema-less apps every +// session. Re-probed after kSchemaSkipRetrySecs. +static std::mutex g_schemaSkipMutex; +static std::unordered_map<uint32_t, uint64_t> g_schemaSkip; // appId -> unix epoch when skip-listed +static std::atomic<bool> g_schemaSkipLoaded{false}; + +// Re-probe skip-listed apps after 14 days so games that add achievements later +// are eventually picked up. +static constexpr uint64_t kSchemaSkipRetrySecs = 14ull * 24 * 60 * 60; + +static uint64_t NowEpochSecs() { + using namespace std::chrono; + return (uint64_t)duration_cast<seconds>(system_clock::now().time_since_epoch()).count(); +} + +static std::string SchemaSkipPath() { + // Our private bookkeeping -- keep it in CloudRedirect's own dir, not buried + // in Steam's appcache\stats (which holds Steam's real schema/stats blobs). + return g_steamPath + "cloud_redirect\\cr_schema_skip.txt"; +} + +// Rewrite cr_schema_skip.txt from the in-memory map (one line per app, last +// timestamp wins). Caller must hold g_schemaSkipMutex. Keeps the file bounded +// since AddSchemaSkip only ever appends. +static void RewriteSchemaSkipFileLocked() { + std::string out; + out.reserve(g_schemaSkip.size() * 20); + for (const auto& kv : g_schemaSkip) + out += std::to_string(kv.first) + "," + std::to_string(kv.second) + "\r\n"; + HANDLE h = CreateFileA(SchemaSkipPath().c_str(), GENERIC_WRITE, FILE_SHARE_READ, + nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return; + DWORD w = 0; + WriteFile(h, out.data(), (DWORD)out.size(), &w, nullptr); + CloseHandle(h); +} + +static void LoadSchemaSkipList() { + if (g_schemaSkipLoaded.exchange(true)) return; + HANDLE h = CreateFileA(SchemaSkipPath().c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return; + std::string buf; + char chunk[4096]; + DWORD got = 0; + // Bound the read: a sane skip-list is a few KB. A pathologically large file + // (corruption) is truncated rather than ballooning RAM. + constexpr size_t kMaxSkipFileBytes = 4 * 1024 * 1024; + while (ReadFile(h, chunk, sizeof(chunk), &got, nullptr) && got > 0) { + buf.append(chunk, got); + if (buf.size() >= kMaxSkipFileBytes) break; + } + CloseHandle(h); + std::lock_guard<std::mutex> lock(g_schemaSkipMutex); + size_t pos = 0; + bool sawDuplicate = false; + while (pos < buf.size()) { + size_t nl = buf.find('\n', pos); + std::string line = buf.substr(pos, nl == std::string::npos ? std::string::npos : nl - pos); + // Lines are "appId,epoch"; tolerate a bare "appId" (epoch=0 -> retry soon). + size_t comma = line.find(','); + try { + uint32_t id = (uint32_t)std::stoul(line.substr(0, comma)); + uint64_t ts = (comma == std::string::npos) ? 0 : std::stoull(line.substr(comma + 1)); + if (id) { + if (!g_schemaSkip.emplace(id, ts).second) { g_schemaSkip[id] = ts; sawDuplicate = true; } + } + } catch (...) {} + if (nl == std::string::npos) break; + pos = nl + 1; + } + // Compact away accumulated append-duplicates so the file can't grow forever. + if (sawDuplicate) RewriteSchemaSkipFileLocked(); +} + +static bool IsSchemaSkipped(uint32_t appId) { + std::lock_guard<std::mutex> lock(g_schemaSkipMutex); + return g_schemaSkip.count(appId) != 0; +} + +// True if a skip-listed app is due for a re-probe (skip-listed long enough ago). +static bool SchemaSkipDueForRetry(uint32_t appId) { + std::lock_guard<std::mutex> lock(g_schemaSkipMutex); + auto it = g_schemaSkip.find(appId); + if (it == g_schemaSkip.end()) return true; + return NowEpochSecs() >= it->second + kSchemaSkipRetrySecs; +} + +// Skip-list an app (idempotent per timestamp). Re-appends on retry so the +// cooldown clock restarts; the file is compacted on next LoadSchemaSkipList +// (last line for an id wins). Only the CM exhaustion path calls this. +static void AddSchemaSkip(uint32_t appId) { + uint64_t now = NowEpochSecs(); + { + std::lock_guard<std::mutex> lock(g_schemaSkipMutex); + g_schemaSkip[appId] = now; + } + HANDLE h = CreateFileA(SchemaSkipPath().c_str(), FILE_APPEND_DATA, FILE_SHARE_READ, + nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (h == INVALID_HANDLE_VALUE) return; + SetFilePointer(h, 0, nullptr, FILE_END); + std::string line = std::to_string(appId) + "," + std::to_string(now) + "\r\n"; + DWORD w = 0; + WriteFile(h, line.data(), (DWORD)line.size(), &w, nullptr); + CloseHandle(h); +} + +// True if the Store API confirms achievements (category 22). Fail-open: HTTP errors +// return true so transient network issues don't silently skip the schema fetch. +static bool AppSupportsAchievements(uint32_t appId) { + { + std::lock_guard<std::mutex> lock(g_schemaFetchMutex); + auto it = g_achievementSupportCache.find(appId); + if (it != g_achievementSupportCache.end()) return it->second; + } + + wchar_t path[256]; + swprintf_s(path, L"/api/appdetails?appids=%u&filters=categories", appId); + + HINTERNET hSession = WinHttpOpen(L"CloudRedirect/2.2", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!hSession) return true; // fail-open + WinHttpSetTimeouts(hSession, 2000, 2000, 3000, 3000); + + HINTERNET hConn = WinHttpConnect(hSession, L"store.steampowered.com", + INTERNET_DEFAULT_HTTPS_PORT, 0); + if (!hConn) { WinHttpCloseHandle(hSession); return true; } + + HINTERNET hReq = WinHttpOpenRequest(hConn, L"GET", path, + nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, + WINHTTP_FLAG_SECURE); + if (!hReq) { WinHttpCloseHandle(hConn); WinHttpCloseHandle(hSession); return true; } + + BOOL ok = WinHttpSendRequest(hReq, WINHTTP_NO_ADDITIONAL_HEADERS, 0, nullptr, 0, 0, 0); + if (ok) ok = WinHttpReceiveResponse(hReq, nullptr); + if (!ok) { + WinHttpCloseHandle(hReq); WinHttpCloseHandle(hConn); WinHttpCloseHandle(hSession); + return true; // fail-open + } + + DWORD statusCode = 0, codeLen = sizeof(statusCode); + WinHttpQueryHeaders(hReq, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &statusCode, &codeLen, WINHTTP_NO_HEADER_INDEX); + if (statusCode != 200) { + WinHttpCloseHandle(hReq); WinHttpCloseHandle(hConn); WinHttpCloseHandle(hSession); + return true; // fail-open + } + + std::string body; + DWORD avail, got; + while (WinHttpQueryDataAvailable(hReq, &avail) && avail > 0) { + if (body.size() + avail > 64 * 1024) break; + size_t off = body.size(); + body.resize(off + avail); + got = 0; + WinHttpReadData(hReq, &body[off], avail, &got); + body.resize(off + got); + } + WinHttpCloseHandle(hReq); WinHttpCloseHandle(hConn); WinHttpCloseHandle(hSession); + + // Look for "success":false (unlisted/removed apps) + if (body.find("\"success\":false") != std::string::npos) { + // Can't determine -- fail-open (might be region-locked but still have achievements) + return true; + } + + // Category 22 = "Steam Achievements". Search for "id":22 in the categories array. + bool supports = (body.find("\"id\":22") != std::string::npos); + { + std::lock_guard<std::mutex> lock(g_schemaFetchMutex); + g_achievementSupportCache[appId] = supports; + } + if (!supports) { + LOG("[Schema] app %u: no achievement support (category 22 absent), skipping", appId); + } + return supports; +} + +// Fetch review-owner SteamIDs for an app from the Steam Store API. Returns the +// IDs parsed from the appreviews JSON, or empty on failure. This is an HTTP call +// so must NOT be called on the network thread. +static std::vector<uint64_t> FetchReviewOwnerIds(uint32_t appId) { + std::vector<uint64_t> ids; + wchar_t path[256]; + swprintf_s(path, L"/appreviews/%u?json=1&filter=recent&language=all" + L"&purchase_type=all&num_per_page=20", appId); + + HINTERNET hSession = WinHttpOpen(L"CloudRedirect/2.2", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!hSession) return ids; + WinHttpSetTimeouts(hSession, 3000, 3000, 5000, 5000); + + HINTERNET hConn = WinHttpConnect(hSession, L"store.steampowered.com", + INTERNET_DEFAULT_HTTPS_PORT, 0); + if (!hConn) { WinHttpCloseHandle(hSession); return ids; } + + HINTERNET hReq = WinHttpOpenRequest(hConn, L"GET", path, + nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, + WINHTTP_FLAG_SECURE); + if (!hReq) { WinHttpCloseHandle(hConn); WinHttpCloseHandle(hSession); return ids; } + + BOOL ok = WinHttpSendRequest(hReq, WINHTTP_NO_ADDITIONAL_HEADERS, 0, nullptr, 0, 0, 0); + if (ok) ok = WinHttpReceiveResponse(hReq, nullptr); + if (!ok) { + WinHttpCloseHandle(hReq); WinHttpCloseHandle(hConn); WinHttpCloseHandle(hSession); + return ids; + } + + DWORD statusCode = 0, codeLen = sizeof(statusCode); + WinHttpQueryHeaders(hReq, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &statusCode, &codeLen, WINHTTP_NO_HEADER_INDEX); + if (statusCode != 200) { + WinHttpCloseHandle(hReq); WinHttpCloseHandle(hConn); WinHttpCloseHandle(hSession); + return ids; + } + + std::string body; + DWORD avail, got; + while (WinHttpQueryDataAvailable(hReq, &avail) && avail > 0) { + if (body.size() + avail > 256 * 1024) break; + size_t off = body.size(); + body.resize(off + avail); + got = 0; + WinHttpReadData(hReq, &body[off], avail, &got); + body.resize(off + got); + } + WinHttpCloseHandle(hReq); WinHttpCloseHandle(hConn); WinHttpCloseHandle(hSession); + + // Parse "steamid":"<digits>" from the JSON response + constexpr uint64_t kSteamId64Base = 76561197960265728ull; + size_t pos = 0; + while ((pos = body.find("\"steamid\"", pos)) != std::string::npos) { + pos = body.find(':', pos); + if (pos == std::string::npos) break; + ++pos; + while (pos < body.size() && (body[pos] == ' ' || body[pos] == '"')) ++pos; + size_t start = pos; + while (pos < body.size() && body[pos] >= '0' && body[pos] <= '9') ++pos; + if (start == pos) continue; + uint64_t sid = strtoull(body.c_str() + start, nullptr, 10); + if (sid >= kSteamId64Base) { + bool dup = false; + for (uint64_t existing : ids) if (existing == sid) { dup = true; break; } + if (!dup) ids.push_back(sid); + } + } + return ids; +} + +// Check if a SteamID has public stats for an app (via community XML endpoint). +// Returns true if the profile's stats page is publicly accessible. +static bool HasPublicStats(uint32_t appId, uint64_t steamId) { + wchar_t path[256]; + swprintf_s(path, L"/profiles/%llu/stats/%u/?xml=1", + (unsigned long long)steamId, appId); + + HINTERNET hSession = WinHttpOpen(L"CloudRedirect/2.2", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!hSession) return false; + WinHttpSetTimeouts(hSession, 2000, 2000, 3000, 3000); + + HINTERNET hConn = WinHttpConnect(hSession, L"steamcommunity.com", + INTERNET_DEFAULT_HTTPS_PORT, 0); + if (!hConn) { WinHttpCloseHandle(hSession); return false; } + + HINTERNET hReq = WinHttpOpenRequest(hConn, L"GET", path, + nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, + WINHTTP_FLAG_SECURE); + if (!hReq) { WinHttpCloseHandle(hConn); WinHttpCloseHandle(hSession); return false; } + + BOOL ok = WinHttpSendRequest(hReq, WINHTTP_NO_ADDITIONAL_HEADERS, 0, nullptr, 0, 0, 0); + if (ok) ok = WinHttpReceiveResponse(hReq, nullptr); + if (!ok) { + WinHttpCloseHandle(hReq); WinHttpCloseHandle(hConn); WinHttpCloseHandle(hSession); + return false; + } + + // Read just enough to check for public stats markers + std::string body; + DWORD avail, got; + while (WinHttpQueryDataAvailable(hReq, &avail) && avail > 0) { + if (body.size() + avail > 32 * 1024) break; + size_t off = body.size(); + body.resize(off + avail); + got = 0; + WinHttpReadData(hReq, &body[off], avail, &got); + body.resize(off + got); + } + WinHttpCloseHandle(hReq); WinHttpCloseHandle(hConn); WinHttpCloseHandle(hSession); + + // Check for public stats indicators + return body.find("<playerstats>") != std::string::npos && + body.find("<privacyState>public</privacyState>") != std::string::npos; +} @@ -5147,23 +5561,59 @@ static bool TryHandleSchemaResponse(const uint8_t* data, uint32_t size) { schemaF->wireType == PB::LengthDelimited && schemaF->dataLen > 0); if (!hasSchema) { // This owner doesn't own the game (or sent no schema) -- another owner's - // reply may still land it. + // reply may still land it. Once every queued owner has replied with no + // schema, the app has none reachable: skip-list it so the next session's + // sweep doesn't re-flood the CM with the same dead fetches. + bool exhausted = false; + { + std::lock_guard<std::mutex> lock(g_schemaFetchMutex); + auto it = g_schemaFetchStates.find(appId); + if (it != g_schemaFetchStates.end() && !it->second.resolved) { + if (it->second.responsesReceived < it->second.requestsSent) + it->second.responsesReceived++; + // All queued owners replied without a schema: exhausted. Mark + // resolved so a late duplicate 819 can't re-trigger skip-listing. + exhausted = it->second.requestsSent > 0 && + it->second.responsesReceived >= it->second.requestsSent; + if (exhausted) it->second.resolved = true; + } + } + if (exhausted) AddSchemaSkip(appId); return true; } std::string schemaPath = g_steamPath + "appcache\\stats\\UserGameStatsSchema_" + std::to_string(appId) + ".bin"; - // Don't overwrite if a valid schema already arrived from a faster owner. - if (GetFileAttributesA(schemaPath.c_str()) != INVALID_FILE_ATTRIBUTES) return true; + // Skip if a file already exists with the same size (no new achievements). + // If size differs, overwrite -- developer added/removed achievements. + WIN32_FILE_ATTRIBUTE_DATA fad; + if (GetFileAttributesExA(schemaPath.c_str(), GetFileExInfoStandard, &fad)) { + if (fad.nFileSizeLow == schemaF->dataLen && fad.nFileSizeHigh == 0) return true; + LOG("[Schema] app %u: schema changed (%u -> %u bytes), updating", + appId, fad.nFileSizeLow, schemaF->dataLen); + } HANDLE h = CreateFileA(schemaPath.c_str(), GENERIC_WRITE, 0, nullptr, - CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (h != INVALID_HANDLE_VALUE) { DWORD written = 0; WriteFile(h, schemaF->data, schemaF->dataLen, &written, nullptr); CloseHandle(h); LOG("[Schema] app %u: wrote schema (%u bytes) from server response", appId, schemaF->dataLen); + // Mark resolved so the exhaustion path can't skip-list this app. + { + std::lock_guard<std::mutex> lock(g_schemaFetchMutex); + auto it = g_schemaFetchStates.find(appId); + if (it != g_schemaFetchStates.end()) it->second.resolved = true; + } + // A schema landed: evict any stale skip entry (in memory and on disk) so + // a prior exhaustion record can't suppress future progress syncs. + { + std::lock_guard<std::mutex> lock(g_schemaSkipMutex); + if (g_schemaSkip.erase(appId)) RewriteSchemaSkipFileLocked(); + } + // Steam also needs a per-user stats file (UserGameStats_<accountid>_<appid>.bin) // to load this app's stats -- without it the schema loads but stats reading @@ -5197,40 +5647,83 @@ static bool TryHandleSchemaResponse(const uint8_t* data, uint32_t size) { return true; } -// Fetch the schema for one app by fanning requests across owner ids. Most owners -// don't own the game (the server then replies minimally, often with no game_id), -// so we DON'T block waiting per owner -- we fire all owners with gentle pacing and -// let whichever owning account's 819 land the .bin (handled async in the recv -// hook). The pacing (not blocking) is what keeps Steam's CM connection safe: 20 -// small messages spread over a few seconds instead of an instant burst. -void RequestSchemaForApp(uint32_t appId) { +// Fetch one app's schema. Must run on a background thread (does HTTP for owner +// discovery); sends are queued for the net thread. forceRefresh re-fetches even if +// the .bin exists, to detect newly-added achievements. +void RequestSchemaForApp(uint32_t appId, bool forceRefresh) { #if !SCHEMA_FETCH_ENABLED - (void)appId; return; // kill-switch: see SCHEMA_FETCH_ENABLED + (void)appId; (void)forceRefresh; return; #endif if (!MetadataSync::SchemaFetchEnabled()) return; if (appId == 0) return; - if (g_liveConnHandle.load(std::memory_order_relaxed) == 0) return; // no conn yet + if (g_liveConnHandle.load(std::memory_order_relaxed) == 0) return; { std::lock_guard<std::mutex> lock(g_schemaFetchMutex); - if (!g_schemaFetchAttempted.insert(appId).second) return; // already tried + if (!g_schemaFetchAttempted.insert(appId).second) return; // once per session per app } std::string schemaPath = g_steamPath + "appcache\\stats\\UserGameStatsSchema_" + std::to_string(appId) + ".bin"; - if (GetFileAttributesA(schemaPath.c_str()) != INVALID_FILE_ATTRIBUTES) return; + if (!forceRefresh) { + WIN32_FILE_ATTRIBUTE_DATA fad{}; + bool gotAttr = GetFileAttributesExA(schemaPath.c_str(), GetFileExInfoStandard, &fad); + uint64_t fsize = gotAttr ? ((uint64_t)fad.nFileSizeHigh << 32 | fad.nFileSizeLow) : 0; + if (gotAttr && fsize > 0) { + LOG("[Schema] RequestSchemaForApp %u: already on disk (%llu bytes), skipping", + appId, (unsigned long long)fsize); + return; + } + LOG("[Schema] RequestSchemaForApp %u: needs fetch (exists=%d size=%llu)", + appId, (int)gotAttr, (unsigned long long)fsize); + } + + // NOTE: the Store API category-22 check was removed because newly released + // games can have achievements before Steam updates the store metadata. + + // Phase 1: discover owners from recent reviews + verify public stats + std::vector<uint64_t> owners = FetchReviewOwnerIds(appId); + std::vector<uint64_t> verified; + for (uint64_t sid : owners) { + if (g_shuttingDown.load(std::memory_order_acquire)) return; + if (HasPublicStats(appId, sid)) { + verified.push_back(sid); + if (verified.size() >= 3) break; // 3 verified owners is plenty + } + } + + // Phase 2: if review discovery found nothing, append fallback owners + bool usingFallback = verified.empty(); + if (usingFallback) { + for (uint64_t id : kFallbackOwnerIds) + verified.push_back(id); + } + + if (verified.empty()) return; - constexpr size_t kNumOwners = sizeof(kSchemaOwnerIds) / sizeof(kSchemaOwnerIds[0]); - // Enqueue one send per owner. The actual BAsyncSend happens on the network - // thread in DrainSchemaQueueOnNetThread (2 per tick), which both guarantees - // valid pipe/coroutine TLS and paces the traffic. Whichever owning account's - // 819 lands the .bin (handled async in the recv hook). + // Store fetch state for response tracking + { + std::lock_guard<std::mutex> lock(g_schemaFetchMutex); + auto& state = g_schemaFetchStates[appId]; + state.owners = verified; + state.nextOwnerIdx = 0; + state.requestsSent = (uint32_t)verified.size(); + state.responsesReceived = 0; + } + + // Enqueue sends for discovered owners. Cap the queue: a sweep that finds many + // schema-less apps must never build an unbounded 818 backlog that saturates + // the shared CM conn and starves Steam's own stats RPCs. + constexpr size_t kSchemaSendQueueMax = 256; { std::lock_guard<std::mutex> lock(g_schemaSendMutex); - for (uint64_t owner : kSchemaOwnerIds) + for (uint64_t owner : verified) { + if (g_schemaSendQueue.size() >= kSchemaSendQueueMax) break; g_schemaSendQueue.push({appId, owner}); + } } - LOG("[Schema] app %u: queued %zu schema request(s)", appId, kNumOwners); + LOG("[Schema] app %u: queued %zu request(s) via %s", + appId, verified.size(), usingFallback ? "fallback" : "review-owner discovery"); } // One-time-per-session proactive sweep: request schemas for ALL namespace apps @@ -5257,28 +5750,55 @@ static void SweepNamespaceSchemas() { LOG("[Schema] Proactive sweep: checking %zu namespace app(s) for missing schemas", apps.size()); std::thread([apps] { - // Hard cap on how many apps we enqueue schema requests for per session, - // bounding the send queue (cap * owners). Sends are paced by the net-thread - // drain (2/tick), so this just limits total queued work. - constexpr int kMaxAppsPerSweep = 48; - int requested = 0; + LoadSchemaSkipList(); + // Filter to apps that actually need fetching (no .bin on disk, or zero-byte). + std::vector<uint32_t> needed; for (uint32_t appId : apps) { - if (g_shuttingDown.load(std::memory_order_acquire)) break; - - // Skip apps already cached on disk without counting against the cap. std::string schemaPath = g_steamPath + "appcache\\stats\\UserGameStatsSchema_" + std::to_string(appId) + ".bin"; - if (GetFileAttributesA(schemaPath.c_str()) != INVALID_FILE_ATTRIBUTES) continue; - - if (requested >= kMaxAppsPerSweep) { - LOG("[Schema] Sweep cap reached (%d apps); remaining deferred to next session", - kMaxAppsPerSweep); - break; + WIN32_FILE_ATTRIBUTE_DATA fad{}; + bool gotAttr = GetFileAttributesExA(schemaPath.c_str(), GetFileExInfoStandard, &fad); + uint64_t fsize = gotAttr ? ((uint64_t)fad.nFileSizeHigh << 32 | fad.nFileSizeLow) : 0; + if (gotAttr && fsize > 0) { + LOG("[Schema] Sweep: app %u schema present (%llu bytes), skipping", + appId, (unsigned long long)fsize); + continue; } - RequestSchemaForApp(appId); // enqueues only; net thread does the sends - ++requested; + // Skip apps already known to have no fetchable schema (CM ground + // truth), but re-probe occasionally for games that add achievements. + if (IsSchemaSkipped(appId) && !SchemaSkipDueForRetry(appId)) continue; + LOG("[Schema] Sweep: app %u schema missing/empty (exists=%d size=%llu)", + appId, (int)gotAttr, (unsigned long long)fsize); + needed.push_back(appId); + } + if (needed.empty()) { + LOG("[Schema] Proactive sweep: all schemas present on disk"); + return; + } + LOG("[Schema] Proactive sweep: %zu app(s) need schemas, fetching with %d workers", + needed.size(), 4); + + // Fan out across worker threads. Each app's RequestSchemaForApp does + // HTTP (achievement check + review-owner + stats probe) so parallelism + // gives near-linear speedup on the I/O-bound work. + constexpr int kWorkers = 4; + std::atomic<size_t> idx{0}; + std::atomic<int> totalRequested{0}; + std::vector<std::thread> workers; + for (int w = 0; w < kWorkers; ++w) { + workers.emplace_back([&needed, &idx, &totalRequested] { + while (true) { + if (g_shuttingDown.load(std::memory_order_acquire)) break; + size_t i = idx.fetch_add(1, std::memory_order_relaxed); + if (i >= needed.size()) break; + RequestSchemaForApp(needed[i]); + totalRequested.fetch_add(1, std::memory_order_relaxed); + } + }); } - LOG("[Schema] Proactive sweep complete: enqueued schemas for %d app(s)", requested); + for (auto& t : workers) t.join(); + LOG("[Schema] Proactive sweep complete: enqueued schemas for %d app(s)", + totalRequested.load(std::memory_order_relaxed)); }).detach(); } diff --git a/src/platform/win/http_server.cpp b/src/platform/win/http_server.cpp index f944f4b4..fd65ed83 100644 --- a/src/platform/win/http_server.cpp +++ b/src/platform/win/http_server.cpp @@ -11,6 +11,7 @@ #include <iphlpapi.h> #include <tcpmib.h> #include <thread> +#include <chrono> #include <atomic> #include <mutex> #include <memory> @@ -69,6 +70,11 @@ struct ClientSlot { std::shared_ptr<std::atomic<bool>> done; }; static std::vector<ClientSlot> g_clientSlots; +// High-file-count manifests (e.g. Everwind, 827 files) burst-request downloads; 16 +// starved the pool and rejected connections, surfacing as a Steam cloud error. +static constexpr size_t kMaxClientThreads = 64; +// At the cap, wait this long for a slot before rejecting (backpressure). +static constexpr int kAcceptBackpressureMaxMs = 5000; // Decompress Steam's cloud-compression ZIP. Returns false (and leaves out untouched) on non-ZIP or failure. static bool TryDecompressZip(const std::vector<uint8_t>& data, std::vector<uint8_t>& out) { @@ -495,7 +501,7 @@ static void AcceptLoop() { continue; } - // SO_SNDTIMEO matters too: a stalled final send() can wedge a slot and saturate the 16-thread cap. + // SO_SNDTIMEO matters too: a stalled final send() can wedge a slot and saturate the thread cap. DWORD rcvTimeout = 30000; // 30s DWORD sndTimeout = 30000; // 30s setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, (const char*)&rcvTimeout, sizeof(rcvTimeout)); @@ -506,9 +512,18 @@ static void AcceptLoop() { std::lock_guard<std::mutex> lk(g_clientMtx); PruneClientThreads(); - // Cap active client threads to prevent resource exhaustion - if (g_clientSlots.size() >= 16) { - LOG("[HTTP] Max client threads reached (16), rejecting connection"); + // At the cap: backpressure instead of rejecting. A closed socket surfaces + // as a Steam cloud error; waiting for a slot lets large bursts drain. + int waitMs = 0; + while (g_clientSlots.size() >= kMaxClientThreads && g_running.load() && + waitMs < kAcceptBackpressureMaxMs) { + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + waitMs += 20; + PruneClientThreads(); + } + if (g_clientSlots.size() >= kMaxClientThreads) { + LOG("[HTTP] Thread cap (%zu) still full after %dms, rejecting connection", + kMaxClientThreads, waitMs); closesocket(client); continue; } diff --git a/src/platform/win/http_transport_win.cpp b/src/platform/win/http_transport_win.cpp index 91c374c9..f0e73265 100644 --- a/src/platform/win/http_transport_win.cpp +++ b/src/platform/win/http_transport_win.cpp @@ -31,6 +31,15 @@ class WinHttpTransport : public IHttpTransport { return true; } + // Scale send/receive timeouts with body size so multi-MB PUTs survive a slow link + // without making small requests wait on the 10s session default. + static void TuneTimeoutsForBody(HINTERNET hReq, size_t bodyBytes) { + if (bodyBytes <= 256 * 1024) return; // small requests keep the fast default + int sendMs = 60000; // allow a multi-MB body to finish uploading + int receiveMs = 30000; // allow Drive to commit and reply + WinHttpSetTimeouts(hReq, 5000, 5000, sendMs, receiveMs); + } + void Shutdown() override { if (m_session) { WinHttpCloseHandle(m_session); @@ -65,6 +74,7 @@ class WinHttpTransport : public IHttpTransport { WINHTTP_ADDREQ_FLAG_ADD); } + TuneTimeoutsForBody(hReq, body.size()); BOOL ok = WinHttpSendRequest(hReq, WINHTTP_NO_ADDITIONAL_HEADERS, 0, body.empty() ? nullptr : (void*)body.data(), (DWORD)body.size(), (DWORD)body.size(), 0); @@ -131,6 +141,7 @@ class WinHttpTransport : public IHttpTransport { } HttpResp resp; + TuneTimeoutsForBody(hReq, body.size()); BOOL ok = WinHttpSendRequest(hReq, WINHTTP_NO_ADDITIONAL_HEADERS, 0, body.empty() ? nullptr : (void*)body.data(), (DWORD)body.size(), (DWORD)body.size(), 0); diff --git a/src/platform/win/log.cpp b/src/platform/win/log.cpp index 32504f82..c907851e 100644 --- a/src/platform/win/log.cpp +++ b/src/platform/win/log.cpp @@ -71,7 +71,7 @@ void Init(const char* path) { localtime_s(<, &t); char buf[128]; int n = snprintf(buf, sizeof(buf), - "\n=== CloudRedirect loaded at %04d-%02d-%02d %02d:%02d:%02d ===\n", + "\n=== CloudRedirect loaded at %04d-%02d-%02d %02d:%02d:%02d [BUILD:" CR_RELEASE_VERSION "] ===\n", lt.tm_year + 1900, lt.tm_mon + 1, lt.tm_mday, lt.tm_hour, lt.tm_min, lt.tm_sec); if (n > 0) WriteRecord(buf, (size_t)n); diff --git a/src/providers/google_drive.cpp b/src/providers/google_drive.cpp index e9eed92b..5fdb35c7 100644 --- a/src/providers/google_drive.cpp +++ b/src/providers/google_drive.cpp @@ -160,6 +160,16 @@ GoogleDriveProvider::LookupStatus GoogleDriveProvider::FindDriveFolderStatus( InvalidateFolderChild(parentId, name); return LookupStatus::Missing; } + if (files.size() > 1) { + LOG("[GDrive] FindDriveFolderStatus '%s' parent=%s: found %zu results (T%zu)", + name.c_str(), parentId.c_str(), files.size(), + std::hash<std::thread::id>{}(std::this_thread::get_id()) % 10000); + for (size_t i = 0; i < files.size(); ++i) { + LOG("[GDrive] [%zu] id=%s created=%s", i, + files[i]["id"].str().c_str(), + files[i]["createdTime"].str().c_str()); + } + } // Keep the oldest folder (first by createdTime ascending) std::string keepId = files[(size_t)0]["id"].str(); // clean up duplicate folders (can happen from eventual consistency) @@ -286,12 +296,17 @@ std::string GoogleDriveProvider::CreateDriveFolder(const std::string& name, arr.arrVal.push_back(Json::String(parentId)); meta.objVal["parents"] = std::move(arr); } + LOG("[GDrive] CreateFolder '%s' under parent=%s (T%zu)", + name.c_str(), parentId.empty() ? "root" : parentId.c_str(), + std::hash<std::thread::id>{}(std::this_thread::get_id()) % 10000); auto r = ApiRequest("POST", "/drive/v3/files?fields=id", Json::Stringify(meta)); if (r.status < 200 || r.status >= 300) { LOG("[GDrive] CreateFolder '%s' failed: HTTP %d", name.c_str(), r.status); return {}; } - return Json::Parse(r.body)["id"].str(); + auto id = Json::Parse(r.body)["id"].str(); + LOG("[GDrive] CreateFolder '%s' -> %s (HTTP %d)", name.c_str(), id.c_str(), r.status); + return id; } std::string GoogleDriveProvider::GetRootFolder() { @@ -398,9 +413,8 @@ GoogleDriveProvider::LookupStatus GoogleDriveProvider::ResolveSubfolders( return LookupStatus::Exists; } - // Only hold the creation mutex during CreateDriveFolder calls. - // FindDriveFolder (network I/O) runs unlocked so other threads - // can look up already-existing folders concurrently. + // Hold the creation mutex only during CreateDriveFolder; FindDriveFolder (network + // I/O) runs unlocked so threads can resolve existing folders concurrently. std::unique_lock<std::recursive_mutex> createLock(m_folderCreateMtx, std::defer_lock); std::string current = parentId; @@ -425,9 +439,8 @@ GoogleDriveProvider::LookupStatus GoogleDriveProvider::ResolveSubfolders( std::string id = FindDriveFolder(seg, current); if (id.empty()) { if (!create) return LookupStatus::Missing; - // Only hold creation mutex for the actual folder creation if (!createLock.owns_lock()) createLock.lock(); - // Double-check cache after acquiring the creation lock + // Re-check cache after acquiring the creation lock. { std::lock_guard<std::recursive_mutex> lock(m_folderMtx); auto it = m_folders.find(cacheKey); @@ -773,18 +786,27 @@ GoogleDriveProvider::UploadStatus GoogleDriveProvider::UploadOrUpdate( std::string("Content-Type: multipart/related; boundary=") + boundary}; HttpResp r; - for (int attempt = 0; attempt < 3; ++attempt) { + static thread_local std::mt19937 rng{std::random_device{}()}; + for (int attempt = 0; attempt < 5; ++attempt) { if (attempt > 0) { - std::this_thread::sleep_for(std::chrono::seconds(attempt)); + int baseMs = 1000 * (1 << (attempt - 1)); // 1s, 2s, 4s, 8s + int jitter = std::uniform_int_distribution<int>(0, baseMs / 2)(rng); + int delayMs = baseMs + jitter; + LOG("[GDrive] Upload backoff attempt %d, waiting %dms", attempt + 1, delayMs); + std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); token = GetAccessToken(); if (token.empty()) return UploadStatus::Error; uploadHdrs[0] = "Authorization: Bearer " + token; } ThrottleApiCall(); r = Request(method, "www.googleapis.com", path, body, uploadHdrs); - if (!IsRateLimited(r.status, r.body)) break; - LOG("[GDrive] Rate limited (upload attempt %d), backing off %ds", - attempt + 1, attempt + 1); + bool rateLimited = IsRateLimited(r.status, r.body); + bool timedOut = (r.status == 0); + if (!rateLimited && !timedOut) break; + if (rateLimited) + g_rateLimitHits.fetch_add(1, std::memory_order_relaxed); + LOG("[GDrive] Upload %s (attempt %d, HTTP %d)", + rateLimited ? "rate limited" : "timeout", attempt + 1, r.status); } if (r.status == 404 && !existingId.empty()) { @@ -806,6 +828,10 @@ GoogleDriveProvider::UploadStatus GoogleDriveProvider::UploadOrUpdate( bool GoogleDriveProvider::DeleteById(const std::string& fileId) { auto r = ApiRequest("DELETE", "/drive/v3/files/" + fileId, "", ""); + if (r.status < 200 || r.status >= 300) { + LOG("[GDrive] DeleteById %s: HTTP %d body=%s", + fileId.c_str(), r.status, r.body.substr(0, 200).c_str()); + } return r.status >= 200 && r.status < 300; } @@ -930,7 +956,11 @@ bool GoogleDriveProvider::UploadBatch(const std::vector<UploadItem>& items) { uint64_t rlBefore = g_rateLimitHits.load(std::memory_order_relaxed); static constexpr size_t kMaxParallel = 10; // native @nClientCloudMaxNumParallelUploads - static constexpr uint64_t kMaxBytesInFlight = 64ull << 20; // native @nClientCloudMaxMBParallelUploads (64 MB) + // Lower than native's 64MB: Drive throttles per connection, so capping in-flight + // bytes keeps each blob above the request receive timeout on a home uplink. + // Runtime-configurable (config.json "upload_inflight_mb"); default 24 MB. + const uint64_t kMaxBytesInFlight = + g_uploadInFlightCapBytes.load(std::memory_order_relaxed); std::atomic<size_t> next{0}; std::atomic<bool> failed{false}; diff --git a/ui-linux/CMakeLists.txt b/ui-linux/CMakeLists.txt index 05dcdc51..52f8bf1e 100644 --- a/ui-linux/CMakeLists.txt +++ b/ui-linux/CMakeLists.txt @@ -14,6 +14,8 @@ if(NOT CR_RELEASE_VERSION) file(READ "${_vp}" VERSION_PROPS_CONTENT) string(REGEX MATCH "<ReleaseVersion>([^<]+)</ReleaseVersion>" _ "${VERSION_PROPS_CONTENT}") set(CR_RELEASE_VERSION "${CMAKE_MATCH_1}") + string(REGEX MATCH "<ReleasePrerelease>([^<]*)</ReleasePrerelease>" _ "${VERSION_PROPS_CONTENT}") + set(CR_PRERELEASE "${CMAKE_MATCH_1}") break() endif() endforeach() @@ -25,7 +27,7 @@ endif() # ── Generate version string ───────────────────────────────────────────── if(CR_VERSION_OVERRIDE) - set(CR_VERSION "${CR_RELEASE_VERSION}+${CR_VERSION_OVERRIDE}") + set(CR_VERSION "${CR_RELEASE_VERSION}${CR_PRERELEASE}+${CR_VERSION_OVERRIDE}") else() execute_process( COMMAND git rev-parse --short=7 HEAD @@ -36,9 +38,9 @@ else() RESULT_VARIABLE GIT_RESULT ) if(GIT_RESULT EQUAL 0) - set(CR_VERSION "${CR_RELEASE_VERSION}+${GIT_SHA}") + set(CR_VERSION "${CR_RELEASE_VERSION}${CR_PRERELEASE}+${GIT_SHA}") else() - set(CR_VERSION "${CR_RELEASE_VERSION}") + set(CR_VERSION "${CR_RELEASE_VERSION}${CR_PRERELEASE}") endif() endif() message(STATUS "CloudRedirect UI version: ${CR_VERSION}") diff --git a/ui-linux/src/backend.cpp b/ui-linux/src/backend.cpp index 5361316e..c81ba5cf 100644 --- a/ui-linux/src/backend.cpp +++ b/ui-linux/src/backend.cpp @@ -1563,10 +1563,41 @@ void Backend::checkForFlatpakUpdate() if (!remotes.contains("cloudredirect")) return; - QString updates = runFlatpakHostCommand({"remote-ls", "--user", "--updates", "--app", "cloudredirect"}); - if (updates.contains("org.cloudredirect.CloudRedirect")) { - emit flatpakUpdateAvailable(); + // Get remote version and compare against running version + QString info = runFlatpakHostCommand({"remote-info", "--user", "cloudredirect", "org.cloudredirect.CloudRedirect"}); + QString remoteVersion; + for (const QString &line : info.split('\n')) { + if (line.trimmed().startsWith("Version:")) { + remoteVersion = line.mid(line.indexOf(':') + 1).trimmed(); + break; + } + } + + if (remoteVersion.isEmpty()) + return; + + // Compare versions: only notify if remote is strictly newer + QString current = QCoreApplication::applicationVersion(); + auto parseVer = [](const QString &v) -> QList<int> { + // Strip prerelease suffix (e.g. "-TEST4") for comparison + QString base = v.section('-', 0, 0); + QList<int> parts; + for (const QString &p : base.split('.')) + parts.append(p.toInt()); + while (parts.size() < 3) parts.append(0); + return parts; + }; + + QList<int> rv = parseVer(remoteVersion); + QList<int> cv = parseVer(current); + bool remoteNewer = false; + for (int i = 0; i < 3; ++i) { + if (rv[i] > cv[i]) { remoteNewer = true; break; } + if (rv[i] < cv[i]) break; } + + if (remoteNewer) + emit flatpakUpdateAvailable(); } void Backend::applyFlatpakUpdate() diff --git a/ui/MainWindow.xaml b/ui/MainWindow.xaml index 3f4847b0..5318037b 100644 --- a/ui/MainWindow.xaml +++ b/ui/MainWindow.xaml @@ -109,14 +109,14 @@ TargetPageType="{x:Type pages:CleanupPage}" /> <ui:NavigationViewItem x:Name="NavCloud760" Icon="{ui:SymbolIcon CloudDismiss24}" - Content="760 Cleanup" + Content="760 Clean Up" TargetPageType="{x:Type pages:Cloud760Page}" /> - <ui:NavigationViewItem Content="{res:Loc Nav_ManifestPinning}" - Icon="{ui:SymbolIcon Pin24}" - TargetPageType="{x:Type pages:ManifestPinningPage}" /> <ui:NavigationViewItem Content="{res:Loc Nav_Stats}" Icon="{ui:SymbolIcon Trophy24}" TargetPageType="{x:Type pages:StatsPage}" /> + <ui:NavigationViewItem Content="{res:Loc Nav_ManifestPinning}" + Icon="{ui:SymbolIcon Pin24}" + TargetPageType="{x:Type pages:ManifestPinningPage}" /> </ui:NavigationView.MenuItems> <ui:NavigationView.FooterMenuItems> diff --git a/ui/MainWindow.xaml.cs b/ui/MainWindow.xaml.cs index 1e315e99..01c89f69 100644 --- a/ui/MainWindow.xaml.cs +++ b/ui/MainWindow.xaml.cs @@ -52,7 +52,8 @@ public void ApplyMode(string? mode) NavCleanup.Visibility = vis; NavCloud760.Visibility = vis; - // Hide the mode chooser once fully committed to cloud_redirect + // In cloud_redirect the mode chooser is hidden from the sidebar; the + // switch-back lives under Settings. In STFixer it stays visible. NavChoiceMode.Visibility = cloudOnly ? Visibility.Collapsed : Visibility.Visible; RootNavigation.UpdateLayout(); diff --git a/ui/Pages/ChoiceModePage.xaml b/ui/Pages/ChoiceModePage.xaml index dec7e340..672e6c2f 100644 --- a/ui/Pages/ChoiceModePage.xaml +++ b/ui/Pages/ChoiceModePage.xaml @@ -91,7 +91,7 @@ </Grid.ColumnDefinitions> <ui:SymbolIcon Symbol="CloudArrowUp24" FontSize="36" - Foreground="#CC2222" + Foreground="{DynamicResource SystemAccentColorPrimaryBrush}" VerticalAlignment="Top" Margin="0,0,16,0" /> diff --git a/ui/Pages/ChoiceModePage.xaml.cs b/ui/Pages/ChoiceModePage.xaml.cs index ccdb2205..1dddfcb5 100644 --- a/ui/Pages/ChoiceModePage.xaml.cs +++ b/ui/Pages/ChoiceModePage.xaml.cs @@ -1,6 +1,4 @@ using System; -using System.IO; -using System.Text.Json; using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; @@ -47,7 +45,7 @@ private void ApplyMode(string? mode) { CurrentModeText.Text = S.Get("Choice_CurrentMode_CloudRedirect"); CurrentModeDescription.Text = S.Get("Choice_CurrentMode_CloudRedirect_Desc"); - STFixerCard.Visibility = Visibility.Collapsed; + STFixerCard.Visibility = Visibility.Visible; CloudRedirectCard.Visibility = Visibility.Collapsed; } else @@ -68,8 +66,6 @@ private void ApplyMode(string? mode) private async void STFixerCard_Click(object sender, MouseButtonEventArgs e) { - if (_currentMode == "cloud_redirect") return; - if (!await TryPersistModeAsync("stfixer", cloudRedirectEnabled: false)) return; @@ -80,15 +76,14 @@ private async void STFixerCard_Click(object sender, MouseButtonEventArgs e) private async void CloudRedirectCard_Click(object sender, MouseButtonEventArgs e) { - var disclaimer = new DisclaimerWindow + // One-time consent gate; skipped once accepted. + if (!ModeService.HasAcceptedDisclaimer()) { - Owner = Window.GetWindow(this) - }; - - var result = disclaimer.ShowDialog(); - - if (result != true || !disclaimer.Accepted) - return; + var disclaimer = new DisclaimerWindow { Owner = Window.GetWindow(this) }; + if (disclaimer.ShowDialog() != true || !disclaimer.Accepted) + return; + ModeService.MarkDisclaimerAccepted(); + } if (!await TryPersistModeAsync("cloud_redirect", cloudRedirectEnabled: true)) return; @@ -98,39 +93,14 @@ private async void CloudRedirectCard_Click(object sender, MouseButtonEventArgs e mw?.RootNavigation.Navigate(typeof(SetupPage)); } - // Persists both settings.json (mode) and the pin config (cloud_redirect). - // Surfaces failure to the user so a silent disk/permissions error doesn't - // leave the UI looking like the choice was saved when it wasn't. - // - // The two writes are not atomic: if SaveModeSetting succeeds and - // SetDllCloudRedirect fails, settings.json would advertise a mode the - // DLL never agreed to, and the next launch's banner would lie. Snapshot - // settings.json before the first write and restore it on failure so the - // file system view stays consistent with whatever the DLL is doing. + // Persists both settings.json (mode) and the pin config (cloud_redirect) + // via ModeService. Surfaces failure so a silent disk/permissions error + // doesn't leave the UI looking like the choice was saved when it wasn't. private static async Task<bool> TryPersistModeAsync(string mode, bool cloudRedirectEnabled) { - var settingsPath = GetSettingsPath(); - byte[]? settingsBackup = null; - if (File.Exists(settingsPath)) - { - try { settingsBackup = File.ReadAllBytes(settingsPath); } - catch { /* unreadable; rollback won't be possible, but the - initial write below will likely fail for the same - reason and rollback won't be needed */ } - } - try { - SaveModeSetting(mode); - try - { - SetDllCloudRedirect(cloudRedirectEnabled); - } - catch - { - RestoreSettingsBackup(settingsPath, settingsBackup); - throw; - } + ModeService.PersistMode(mode, cloudRedirectEnabled); return true; } catch (Exception ex) @@ -141,113 +111,4 @@ await Dialog.ShowErrorAsync( return false; } } - - private static void RestoreSettingsBackup(string path, byte[]? backup) - { - try - { - if (backup != null) - FileUtils.AtomicWriteAllBytes(path, backup); - else if (File.Exists(path)) - File.Delete(path); // No prior file → undo our creation. - } - catch { /* best-effort; the user's already seeing an error */ } - } - - private static void SaveModeSetting(string mode) - { - var path = GetSettingsPath(); - var dir = Path.GetDirectoryName(path)!; - if (!Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - // A corrupt existing file is treated as empty and rewritten; any - // other failure (I/O, permissions) propagates so the caller can - // surface it instead of silently dropping the user's choice. - JsonElement existing = default; - if (File.Exists(path)) - { - try - { - var oldJson = File.ReadAllText(path); - using var oldDoc = JsonDocument.Parse(oldJson); - existing = oldDoc.RootElement.Clone(); - } - catch { } - } - - using var ms = new MemoryStream(); - using (var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true })) - { - writer.WriteStartObject(); - writer.WriteString("mode", mode); - - if (existing.ValueKind == JsonValueKind.Object) - { - foreach (var prop in existing.EnumerateObject()) - { - if (prop.Name == "mode") continue; - prop.WriteTo(writer); - } - } - - writer.WriteEndObject(); - } - - var newJson = System.Text.Encoding.UTF8.GetString(ms.ToArray()); - FileUtils.AtomicWriteAllText(path, newJson); - } - - private static void SetDllCloudRedirect(bool enabled) - { - var path = SteamDetector.GetPinConfigPath(); - if (path == null) return; - - // Same policy as SaveModeSetting: corrupt-old-file is best-effort, - // but real write failures must surface instead of being swallowed. - JsonElement existing = default; - if (File.Exists(path)) - { - try - { - var oldJson = File.ReadAllText(path); - using var oldDoc = JsonDocument.Parse(oldJson, new JsonDocumentOptions - { - CommentHandling = JsonCommentHandling.Skip - }); - existing = oldDoc.RootElement.Clone(); - } - catch { } - } - - using var ms = new MemoryStream(); - using (var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true })) - { - writer.WriteStartObject(); - writer.WriteBoolean("cloud_redirect", enabled); - - if (existing.ValueKind == JsonValueKind.Object) - { - foreach (var prop in existing.EnumerateObject()) - { - if (prop.Name == "cloud_redirect") continue; - prop.WriteTo(writer); - } - } - - writer.WriteEndObject(); - } - - var dir = Path.GetDirectoryName(path)!; - if (!Directory.Exists(dir)) - Directory.CreateDirectory(dir); - - var newJson = System.Text.Encoding.UTF8.GetString(ms.ToArray()); - FileUtils.AtomicWriteAllText(path, newJson); - } - - private static string GetSettingsPath() - { - return Path.Combine(SteamDetector.GetConfigDir(), "settings.json"); - } } diff --git a/ui/Pages/Cloud760Page.xaml b/ui/Pages/Cloud760Page.xaml index e1cdeea1..cd11a7aa 100644 --- a/ui/Pages/Cloud760Page.xaml +++ b/ui/Pages/Cloud760Page.xaml @@ -7,12 +7,12 @@ <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="24"> <StackPanel MaxWidth="800"> - <TextBlock Text="760 Cleanup" + <TextBlock Text="760 Clean Up" FontSize="28" FontWeight="SemiBold" Foreground="{DynamicResource TextFillColorPrimaryBrush}" Margin="0,0,0,8" /> - <TextBlock Text="WIP: get rid of the BS SteamTools put in your 760." + <TextBlock Text="View and delete the saves SteamTools wrote to 760 (Steam Screenshots). This will stop SteamTools from redownloading those saves when run without CloudRedirect patches, contaminating your userdata folder for each injected game." Foreground="{DynamicResource TextFillColorSecondaryBrush}" TextWrapping="Wrap" Margin="0,0,0,20" /> diff --git a/ui/Pages/Cloud760Page.xaml.cs b/ui/Pages/Cloud760Page.xaml.cs index 31abfb4e..ad05c341 100644 --- a/ui/Pages/Cloud760Page.xaml.cs +++ b/ui/Pages/Cloud760Page.xaml.cs @@ -169,7 +169,6 @@ private async void ConnectButton_Click(object sender, RoutedEventArgs e) _cloud = null; QuotaText.Text = ""; StatusText.Text = "Error: " + ex.Message; - await Dialog.ShowErrorAsync("Steam Cloud", ex.Message); } finally { @@ -200,7 +199,6 @@ private async void RefreshButton_Click(object sender, RoutedEventArgs e) catch (Exception ex) { StatusText.Text = "Error: " + ex.Message; - await Dialog.ShowErrorAsync("Steam Cloud", ex.Message); } finally { @@ -259,7 +257,6 @@ private async Task DeleteFiles(List<string> names, string? confirmMessage) catch (Exception ex) { StatusText.Text = "Error: " + ex.Message; - await Dialog.ShowErrorAsync("Steam Cloud", ex.Message); } finally { diff --git a/ui/Pages/CloudProviderPage.xaml b/ui/Pages/CloudProviderPage.xaml index 9c3b918d..1f9aed14 100644 --- a/ui/Pages/CloudProviderPage.xaml +++ b/ui/Pages/CloudProviderPage.xaml @@ -30,7 +30,6 @@ <ComboBoxItem Content="{res:Loc CloudProvider_GoogleDrive}" Tag="gdrive" /> <ComboBoxItem Content="{res:Loc CloudProvider_OneDrive}" Tag="onedrive" /> <ComboBoxItem Content="{res:Loc CloudProvider_FolderMappedDrive}" Tag="folder" /> - <ComboBoxItem Content="{res:Loc CloudProvider_LocalOnly}" Tag="local" /> </ComboBox> <TextBlock x:Name="PathLabel" @@ -39,14 +38,15 @@ Foreground="{DynamicResource TextFillColorPrimaryBrush}" Margin="0,0,0,8" /> - <Grid Margin="0,0,0,4"> + <Grid Margin="0,0,0,24"> <Grid.ColumnDefinitions> <ColumnDefinition Width="*" /> <ColumnDefinition Width="Auto" /> </Grid.ColumnDefinitions> <ui:TextBox x:Name="TokenPathBox" Grid.Column="0" - PlaceholderText="{res:Loc CloudProvider_TokenPlaceholder}" /> + PlaceholderText="{res:Loc CloudProvider_TokenPlaceholder}" + LostFocus="TokenPathBox_LostFocus" /> <ui:Button x:Name="BrowseButton" Grid.Column="1" Content="{res:Loc CloudProvider_Browse}" @@ -59,7 +59,8 @@ FontSize="11" Foreground="{DynamicResource TextFillColorTertiaryBrush}" TextWrapping="Wrap" - Margin="0,0,0,24" /> + Visibility="Collapsed" + Margin="0,-16,0,24" /> <TextBlock Text="{res:Loc CloudProvider_Authentication}" FontWeight="SemiBold" @@ -114,10 +115,67 @@ </ScrollViewer> </Border> - <ui:Button Content="{res:Loc CloudProvider_SaveConfiguration}" - Appearance="Primary" - Icon="{ui:SymbolIcon Save24}" - Click="SaveConfig_Click" /> + <StackPanel x:Name="UploadInFlightSection" Margin="0,0,0,24" Visibility="Collapsed"> + <TextBlock Text="{res:Loc CloudProvider_UploadInFlight}" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,0,0,8" /> + <TextBlock Text="{res:Loc CloudProvider_UploadInFlightHint}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" + Margin="0,0,0,12" /> + + <Grid> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="*" /> + <ColumnDefinition Width="Auto" /> + </Grid.ColumnDefinitions> + + <!-- Slider + its tick labels share column 0 so the labels + track the slider width exactly. Ticks every 5 MB + (24..64); 24/44/64 fall on ticks at 0% / 50% / 100%. + Two equal label columns split at 50%; Safe + left-anchored, Balanced straddles the centre, + Aggressive right-anchored. --> + <StackPanel Grid.Column="0"> + <Slider x:Name="UploadInFlightSlider" + Minimum="24" Maximum="64" + TickFrequency="5" IsSnapToTickEnabled="True" + TickPlacement="BottomRight" + ValueChanged="UploadInFlightSlider_Changed" /> + <Grid Margin="0,4,0,0"> + <Grid.ColumnDefinitions> + <ColumnDefinition Width="1*" /> + <ColumnDefinition Width="1*" /> + </Grid.ColumnDefinitions> + <TextBlock Grid.Column="0" + HorizontalAlignment="Left" + Text="{res:Loc CloudProvider_UploadInFlightConservative}" + FontSize="11" + Foreground="{DynamicResource TextFillColorTertiaryBrush}" /> + <TextBlock Grid.Column="0" Grid.ColumnSpan="2" + HorizontalAlignment="Center" + Text="{res:Loc CloudProvider_UploadInFlightBalanced}" + FontSize="11" + Foreground="{DynamicResource TextFillColorTertiaryBrush}" /> + <TextBlock Grid.Column="1" + HorizontalAlignment="Right" + Text="{res:Loc CloudProvider_UploadInFlightAggressive}" + FontSize="11" + Foreground="{DynamicResource TextFillColorTertiaryBrush}" /> + </Grid> + </StackPanel> + + <TextBlock x:Name="UploadInFlightValue" + Grid.Column="1" + MinWidth="64" + TextAlignment="Right" + VerticalAlignment="Top" + Margin="12,0,0,0" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + </Grid> + </StackPanel> </StackPanel> </ScrollViewer> </Page> diff --git a/ui/Pages/CloudProviderPage.xaml.cs b/ui/Pages/CloudProviderPage.xaml.cs index 92a6762e..c2c5d00a 100644 --- a/ui/Pages/CloudProviderPage.xaml.cs +++ b/ui/Pages/CloudProviderPage.xaml.cs @@ -16,6 +16,13 @@ public partial class CloudProviderPage : Page private bool _loading; private readonly StringBuilder _logBuffer = new(); + // Upload in-flight cap (MB). Only shown/saved for Google Drive. + private const int InFlightDefaultMb = 24; + private const int InFlightMinMb = 24; + private const int InFlightMaxMb = 64; + // Suppresses the slider ValueChanged handler during programmatic load. + private bool _inFlightLoading; + public CloudProviderPage() { InitializeComponent(); @@ -50,7 +57,8 @@ private sealed record LoadedConfigSnapshot( Services.CloudConfig? Config, string DefaultLocalPath, string PathTextOverride, - Services.TokenStatus? TokenStatus); + Services.TokenStatus? TokenStatus, + int UploadInFlightMb); // M14: Move SteamDetector.ReadConfig + FindSteamPath + OAuth token // status check off the UI thread. Loaded used to call them @@ -89,7 +97,7 @@ private async Task LoadCurrentConfigAsync() if (config?.TokenPath != null) tokenStatus = Services.OAuthService.CheckTokenStatus(config.TokenPath); - return new LoadedConfigSnapshot(config, defaultLocal, pathOverride, tokenStatus); + return new LoadedConfigSnapshot(config, defaultLocal, pathOverride, tokenStatus, ReadUploadInFlightMb()); }); ApplyLoadedSnapshot(snapshot); @@ -106,12 +114,15 @@ private async Task LoadCurrentConfigAsync() private void ApplyLoadedSnapshot(LoadedConfigSnapshot snap) { + ApplyUploadInFlight(snap.UploadInFlightMb); + if (snap.Config == null) { AuthStatus.Text = S.Get("CloudProvider_NoConfigFound"); - ProviderCombo.SelectedIndex = 3; // Local only + ProviderCombo.SelectedIndex = 2; // Folder / Mapped Drive (default local path) if (!string.IsNullOrEmpty(snap.DefaultLocalPath)) TokenPathBox.Text = snap.DefaultLocalPath; + UpdateProviderUI(); return; } @@ -172,13 +183,23 @@ private void ProviderCombo_SelectionChanged(object sender, SelectionChangedEvent Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "CloudRedirect", "onedrive_tokens.json"); } - else if (tag is "local" or "folder") + else if (tag == "folder") { SetDefaultLocalPath(); } } UpdateAuthStatus(); + // Persist the provider switch (and the path it just set). + _ = SaveConfigSilent(); + } + + // Auto-save manual path edits when the field loses focus, rather than on + // every keystroke. + private void TokenPathBox_LostFocus(object sender, RoutedEventArgs e) + { + if (_loading) return; + _ = SaveConfigSilent(); } /// <summary> @@ -191,12 +212,13 @@ private void UpdateProviderUI() var tag = item.Tag as string; bool needsTokens = tag is "gdrive" or "onedrive"; bool isFolder = tag == "folder"; - bool isLocal = tag == "local"; bool needsPath = needsTokens || isFolder; TokenPathBox.IsEnabled = needsPath; BrowseButton.IsEnabled = needsPath; SignInButton.Visibility = needsTokens ? Visibility.Visible : Visibility.Collapsed; + // Upload in-flight cap is a Google Drive-only throttle. + UploadInFlightSection.Visibility = tag == "gdrive" ? Visibility.Visible : Visibility.Collapsed; // Update labels based on provider type if (isFolder) @@ -205,14 +227,6 @@ private void UpdateProviderUI() TokenPathBox.PlaceholderText = S.Get("CloudProvider_SyncFolderPlaceholder"); PathHint.Text = S.Get("CloudProvider_SyncFolderHint"); } - else if (isLocal) - { - PathLabel.Text = S.Get("CloudProvider_LocalStoragePath"); - TokenPathBox.PlaceholderText = ""; - PathHint.Text = S.Get("CloudProvider_LocalStorageHint"); - TokenPathBox.IsEnabled = false; - BrowseButton.IsEnabled = false; - } else if (needsTokens) { PathLabel.Text = S.Get("CloudProvider_TokenFilePath"); @@ -225,6 +239,10 @@ private void UpdateProviderUI() TokenPathBox.PlaceholderText = ""; PathHint.Text = ""; } + + // Only reserve space for the hint when it actually has text. + PathHint.Visibility = string.IsNullOrEmpty(PathHint.Text) + ? Visibility.Collapsed : Visibility.Visible; } private void BrowseToken_Click(object sender, RoutedEventArgs e) @@ -246,6 +264,7 @@ private void BrowseToken_Click(object sender, RoutedEventArgs e) { TokenPathBox.Text = dialog.FolderName; UpdateAuthStatus(); + _ = SaveConfigSilent(); } } else @@ -261,6 +280,7 @@ private void BrowseToken_Click(object sender, RoutedEventArgs e) { TokenPathBox.Text = dialog.FileName; UpdateAuthStatus(); + _ = SaveConfigSilent(); } } } @@ -270,7 +290,7 @@ private async void SignIn_Click(object sender, RoutedEventArgs e) if (_isAuthenticating) return; var provider = GetSelectedProvider(); - if (provider is "local" or "folder") return; + if (provider == "folder") return; var tokenPath = TokenPathBox.Text?.Trim(); if (string.IsNullOrEmpty(tokenPath)) @@ -337,14 +357,6 @@ private void CancelAuth_Click(object sender, RoutedEventArgs e) // after the async operation observes cancellation. } - private async void SaveConfig_Click(object sender, RoutedEventArgs e) - { - if (await SaveConfigSilent()) - { - await Services.Dialog.ShowInfoAsync(S.Get("CloudProvider_Saved"), S.Get("CloudProvider_SavedMessage")); - } - } - /// <summary> /// Writes config.json without showing a dialog. Returns true on success. /// </summary> @@ -357,12 +369,6 @@ private async Task<bool> SaveConfigSilent() var provider = GetSelectedProvider(); var tokenPath = TokenPathBox.Text?.Trim() ?? ""; - // "local" in the UI maps to "folder" provider in the DLL with the - // default localcloud path, so the DLL has a concrete storage location. - var configProvider = provider; - if (provider == "local") - configProvider = "folder"; - var configPath = Path.Combine(configDir, "config.json"); try @@ -371,10 +377,10 @@ private async Task<bool> SaveConfigSilent() new[] { "provider", "sync_path", "token_path" }, writer => { - writer.WriteString("provider", configProvider); - if (configProvider == "folder") + writer.WriteString("provider", provider); + if (provider == "folder") writer.WriteString("sync_path", tokenPath); - else if (configProvider is not "local") + else writer.WriteString("token_path", tokenPath); }); return true; @@ -389,8 +395,8 @@ private async Task<bool> SaveConfigSilent() private string GetSelectedProvider() { if (ProviderCombo.SelectedItem is ComboBoxItem item) - return item.Tag as string ?? "local"; - return "local"; + return item.Tag as string ?? "folder"; + return "folder"; } private void UpdateAuthStatus(Services.TokenStatus? preCheckedStatus = null) @@ -399,17 +405,6 @@ private void UpdateAuthStatus(Services.TokenStatus? preCheckedStatus = null) var tag = item.Tag as string; - if (tag == "local") - { - var localPath = TokenPathBox.Text?.Trim(); - if (!string.IsNullOrEmpty(localPath)) - AuthStatus.Text = S.Format("CloudProvider_LocalModeStored", localPath); - else - AuthStatus.Text = S.Get("CloudProvider_LocalModeNoSync"); - AuthIcon.Symbol = Wpf.Ui.Controls.SymbolRegular.ShieldCheckmark24; - return; - } - if (tag == "folder") { var folderPath = TokenPathBox.Text?.Trim(); @@ -452,6 +447,56 @@ private void UpdateAuthStatus(Services.TokenStatus? preCheckedStatus = null) : Wpf.Ui.Controls.SymbolRegular.ShieldKeyhole24; } + /// <summary>Reads upload_inflight_mb from config.json, clamped 24..64. + /// Absent/invalid -> the 24 MB default. Off the UI thread.</summary> + private static int ReadUploadInFlightMb() + { + try + { + var path = Services.SteamDetector.GetConfigFilePath(); + if (!File.Exists(path)) return InFlightDefaultMb; + + using var doc = JsonDocument.Parse(File.ReadAllText(path)); + if (doc.RootElement.TryGetProperty("upload_inflight_mb", out var inf) && inf.TryGetInt32(out var mb)) + return Math.Clamp(mb, InFlightMinMb, InFlightMaxMb); + } + catch { } + return InFlightDefaultMb; + } + + private void ApplyUploadInFlight(int mb) + { + _inFlightLoading = true; + try + { + UploadInFlightSlider.Value = Math.Clamp(mb, InFlightMinMb, InFlightMaxMb); + UpdateUploadInFlightValueLabel(); + } + finally { _inFlightLoading = false; } + } + + private void UploadInFlightSlider_Changed(object sender, RoutedPropertyChangedEventArgs<double> e) + { + UpdateUploadInFlightValueLabel(); + if (_inFlightLoading) return; + SaveUploadInFlight(); + } + + private void UpdateUploadInFlightValueLabel() + { + if (UploadInFlightValue != null) + UploadInFlightValue.Text = S.Format("CloudProvider_UploadInFlightValue", (int)UploadInFlightSlider.Value); + } + + /// <summary>Persists upload_inflight_mb (clamped 24..64) into config.json.</summary> + private void SaveUploadInFlight() + { + int mb = Math.Clamp((int)Math.Round(UploadInFlightSlider.Value), InFlightMinMb, InFlightMaxMb); + Services.ConfigHelper.SaveConfig(Services.SteamDetector.GetConfigFilePath(), + new[] { "upload_inflight_mb" }, + writer => writer.WriteNumber("upload_inflight_mb", mb)); + } + private void AppendLog(string message) { if (_logBuffer.Length > 0) diff --git a/ui/Pages/SettingsPage.xaml b/ui/Pages/SettingsPage.xaml index 656444eb..6c5ed53b 100644 --- a/ui/Pages/SettingsPage.xaml +++ b/ui/Pages/SettingsPage.xaml @@ -43,65 +43,73 @@ </StackPanel> </ui:CardControl> - <TextBlock Text="{res:Loc Settings_Updates}" - FontSize="20" FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" - Margin="0,0,0,12" /> + <!-- Quality of Life: visible in BOTH modes so "Make Achievements Work" + is always reachable. The cloud_redirect-only cards inside collapse + in STFixer mode (see code-behind). --> + <StackPanel x:Name="ExtraSection" Margin="0,0,0,24"> + <TextBlock Text="{res:Loc Settings_Extra}" + FontSize="20" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,0,0,12" /> + <StackPanel> + <ui:CardControl Margin="0,0,0,8"> + <ui:CardControl.Header> + <StackPanel> + <TextBlock Text="Auto Update" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <TextBlock Text="Automatically download latest version of the Cloud Redirect DLL on Steam startup" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" /> + </StackPanel> + </ui:CardControl.Header> + <ui:ToggleSwitch x:Name="AutoUpdateDllToggle" + Checked="SyncToggle_Changed" + Unchecked="SyncToggle_Changed" /> + </ui:CardControl> - <ui:CardControl Margin="0,0,0,8"> - <ui:CardControl.Header> - <StackPanel> - <TextBlock x:Name="UpdateHeaderText" - Text="{res:Loc Settings_CheckForUpdates}" - FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - <TextBlock x:Name="UpdateStatusText" - Text="{res:Loc Settings_CheckForUpdatesHint}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" /> - </StackPanel> - </ui:CardControl.Header> - <StackPanel Orientation="Horizontal"> - <ui:Button x:Name="UpdateButton" - Content="{res:Loc Settings_Check}" - Icon="{ui:SymbolIcon ArrowSync24}" - Click="CheckForUpdates_Click" /> - <ui:Button x:Name="DownloadButton" - Content="{res:Loc Settings_Download}" - Appearance="Primary" - Icon="{ui:SymbolIcon ArrowDownload24}" - Click="DownloadUpdate_Click" - Visibility="Collapsed" - Margin="8,0,0,0" /> - </StackPanel> - </ui:CardControl> + <!-- "Make Achievements Work": exposed in both modes. --> + <ui:CardControl x:Name="AchievementsSection" Margin="0,0,0,8"> + <ui:CardControl.Header> + <StackPanel> + <TextBlock Text="{res:Loc Settings_GetAchievementData}" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <TextBlock Text="{res:Loc Settings_GetAchievementDataHint}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" /> + </StackPanel> + </ui:CardControl.Header> + <ui:ToggleSwitch x:Name="GetAchievementDataToggle" + Checked="SyncToggle_Changed" + Unchecked="SyncToggle_Changed" /> + </ui:CardControl> - <ui:CardControl Margin="0,0,0,32"> - <ui:CardControl.Header> - <StackPanel> - <TextBlock Text="{res:Loc Settings_AutoUpdateDll}" - FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - <TextBlock Text="{res:Loc Settings_AutoUpdateDllHint}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" /> - </StackPanel> - </ui:CardControl.Header> - <ui:ToggleSwitch x:Name="AutoUpdateDllToggle" - Checked="SyncToggle_Changed" - Unchecked="SyncToggle_Changed" /> - </ui:CardControl> + <!-- cloud_redirect-only QoL card. --> + <ui:CardControl x:Name="ShowNonSteamGameCard" Margin="0,0,0,0"> + <ui:CardControl.Header> + <StackPanel> + <TextBlock Text="{res:Loc Settings_ShowNonSteamGame}" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <TextBlock Text="{res:Loc Settings_ShowNonSteamGameHint}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" /> + </StackPanel> + </ui:CardControl.Header> + <ui:ToggleSwitch x:Name="ShowNonSteamGameToggle" + Checked="SyncToggle_Changed" + Unchecked="SyncToggle_Changed" /> + </ui:CardControl> + </StackPanel> + </StackPanel> - <StackPanel x:Name="ExperimentalSection"> + <StackPanel x:Name="ExperimentalSection" Margin="0,0,0,24"> <TextBlock Text="{res:Loc Settings_Experimental}" FontSize="20" FontWeight="SemiBold" Foreground="{DynamicResource TextFillColorPrimaryBrush}" - Margin="0,0,0,4" /> - - <TextBlock Text="{res:Loc Settings_ExperimentalHint}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" Margin="0,0,0,12" /> + <StackPanel> <ui:CardControl Margin="0,0,0,8"> <ui:CardControl.Header> @@ -136,38 +144,6 @@ </ui:CardControl> <ui:CardControl Margin="0,0,0,8"> - <ui:CardControl.Header> - <StackPanel> - <TextBlock Text="{res:Loc Settings_GetAchievementData}" - FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - <TextBlock Text="{res:Loc Settings_GetAchievementDataHint}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" /> - </StackPanel> - </ui:CardControl.Header> - <ui:ToggleSwitch x:Name="GetAchievementDataToggle" - Checked="SyncToggle_Changed" - Unchecked="SyncToggle_Changed" /> - </ui:CardControl> - - <ui:CardControl Margin="0,0,0,8"> - <ui:CardControl.Header> - <StackPanel> - <TextBlock Text="UNSUPPORTED WIP OVERRIDE NON-ST CLIENT GATE" - FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - <TextBlock Text="BLAH BLAH TEST ONLY" - Foreground="{DynamicResource SystemFillColorAttentionBrush}" - TextWrapping="Wrap" /> - </StackPanel> - </ui:CardControl.Header> - <ui:ToggleSwitch x:Name="OverrideNonStGateToggle" - Checked="SyncToggle_Changed" - Unchecked="SyncToggle_Changed" /> - </ui:CardControl> - - <ui:CardControl Margin="0,0,0,32"> <ui:CardControl.Header> <StackPanel> <TextBlock Text="{res:Loc Settings_SyncLuas}" @@ -182,46 +158,15 @@ Checked="SyncToggle_Changed" Unchecked="SyncToggle_Changed" /> </ui:CardControl> + </StackPanel> </StackPanel> - <StackPanel x:Name="ExtraSection"> - <TextBlock Text="{res:Loc Settings_Extra}" - FontSize="20" FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" - Margin="0,0,0,4" /> - - <TextBlock Text="{res:Loc Settings_ExtraHint}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" - Margin="0,0,0,12" /> - - <ui:CardControl Margin="0,0,0,32"> - <ui:CardControl.Header> - <StackPanel> - <TextBlock Text="{res:Loc Settings_ShowNonSteamGame}" - FontWeight="SemiBold" - Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> - <TextBlock Text="{res:Loc Settings_ShowNonSteamGameHint}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" /> - </StackPanel> - </ui:CardControl.Header> - <ui:ToggleSwitch x:Name="ShowNonSteamGameToggle" - Checked="SyncToggle_Changed" - Unchecked="SyncToggle_Changed" /> - </ui:CardControl> - </StackPanel> - - <StackPanel x:Name="ParentalSection"> + <StackPanel x:Name="ParentalSection" Margin="0,0,0,24"> <TextBlock Text="{res:Loc Settings_Parental}" FontSize="20" FontWeight="SemiBold" Foreground="{DynamicResource TextFillColorPrimaryBrush}" - Margin="0,0,0,4" /> - - <TextBlock Text="{res:Loc Settings_ParentalHint}" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" Margin="0,0,0,12" /> + <StackPanel> <ui:CardControl Margin="0,0,0,8"> <ui:CardControl.Header> @@ -255,14 +200,15 @@ Unchecked="SyncToggle_Changed" /> </ui:CardControl> + </StackPanel> </StackPanel> <TextBlock Text="{res:Loc Settings_DangerZone}" FontSize="20" FontWeight="SemiBold" - Foreground="#E74C3C" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" Margin="0,0,0,12" /> - <ui:CardControl Margin="0,0,0,32"> + <ui:CardControl Margin="0,0,0,16"> <ui:CardControl.Header> <StackPanel> <TextBlock Text="{res:Loc Settings_ResetCacheTitle}" FontWeight="SemiBold" @@ -277,6 +223,30 @@ Click="ResetData_Click" /> </ui:CardControl> + <TextBlock Text="{res:Loc Settings_SwitchMode}" + FontSize="20" FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,0,0,12" /> + + <ui:CardControl Margin="0,0,0,32"> + <ui:CardControl.Header> + <StackPanel> + <TextBlock Text="{res:Loc Settings_SwitchModeTitle}" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" /> + <TextBlock Text="{res:Loc Settings_SwitchModeHint}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" /> + </StackPanel> + </ui:CardControl.Header> + <ComboBox x:Name="ModeComboBox" + Width="200" + SelectionChanged="ModeComboBox_SelectionChanged"> + <ComboBoxItem Content="{res:Loc Settings_ModeStFixer}" Tag="stfixer" /> + <ComboBoxItem Content="{res:Loc Settings_ModeCloudRedirect}" Tag="cloud_redirect" /> + </ComboBox> + </ui:CardControl> + <TextBlock Text="{res:Loc Settings_About}" FontSize="20" FontWeight="SemiBold" Foreground="{DynamicResource TextFillColorPrimaryBrush}" diff --git a/ui/Pages/SettingsPage.xaml.cs b/ui/Pages/SettingsPage.xaml.cs index 88823472..541edc03 100644 --- a/ui/Pages/SettingsPage.xaml.cs +++ b/ui/Pages/SettingsPage.xaml.cs @@ -15,9 +15,12 @@ public partial class SettingsPage : Page { private const string ReleasesUrl = "https://github.com/Selectively11/CloudRedirect/releases"; - private string? _latestDownloadUrl; private bool _languageLoading; private bool _syncLoading; + private bool _modeLoading; + /// <summary>Current app mode ("cloud_redirect" or "stfixer"); controls + /// which toggles are visible and how saves are scoped.</summary> + private string? _mode; /// <summary> /// Index of the last LanguageOptions entry that was successfully /// persisted to settings.json. Used to roll back the combo if a @@ -81,6 +84,10 @@ private async Task LoadSettingsAsync() bool? a = null, p = null, l = null, u = null, nsg = null, pip = null, pbp = null, sf = null, ovr = null; if (mode == "cloud_redirect") ReadSyncTogglesInto(ref a, ref p, ref l, ref u, ref nsg, ref pip, ref pbp, ref sf, ref ovr); + else + // STFixer mode exposes achievements, "Show Lua Game in Status" and + // the Steam Family toggles; leave the cloud-only toggles untouched. + ReadStFixerTogglesInto(ref sf, ref nsg, ref pip, ref pbp); return new SettingsSnapshot(lang, mode, a, p, l, u, nsg, pip, pbp, sf, ovr); }); @@ -91,12 +98,20 @@ private async Task LoadSettingsAsync() private void ApplySettingsSnapshot(SettingsSnapshot snap) { ApplyLanguageSelector(snap.Language); - + _mode = snap.Mode; + ApplyModeSelector(snap.Mode); + + // Quality of Life holds "Make Achievements Work" and "Show Lua Game in + // Status", so it stays visible in both modes. + ExtraSection.Visibility = Visibility.Visible; + AchievementsSection.Visibility = Visibility.Visible; + ShowNonSteamGameCard.Visibility = Visibility.Visible; + // Steam Family (parental) toggles work in both modes; the DLL gates them on + // cloudSaveOnly, not on app mode. ParentalSection.Visibility = Visibility.Visible; if (snap.Mode == "cloud_redirect") { ExperimentalSection.Visibility = Visibility.Visible; - ExtraSection.Visibility = Visibility.Visible; ApplySyncToggles(snap.SyncAchievements, snap.SyncPlaytime, snap.SyncLuas, snap.AutoUpdateDll, snap.ShowNonSteamGame, snap.ParentalIgnorePlaytime, snap.ParentalBypassPlaytime, snap.SchemaFetch, snap.OverrideNonStGate); @@ -104,11 +119,10 @@ private void ApplySettingsSnapshot(SettingsSnapshot snap) else { ExperimentalSection.Visibility = Visibility.Collapsed; - ExtraSection.Visibility = Visibility.Collapsed; - // Auto-update DLL lives in the always-visible Updates section, so it - // reflects the real setting even when the sync/extra sections are hidden. - ApplySyncToggles(false, false, false, snap.AutoUpdateDll, false, - snap.ParentalIgnorePlaytime, snap.ParentalBypassPlaytime, false, false); + // Auto-update, schema_fetch, show_non_steam_game and the parental toggles + // are always-visible, so keep them in sync even when cloud-only sections hide. + ApplySyncToggles(false, false, false, snap.AutoUpdateDll, snap.ShowNonSteamGame, + snap.ParentalIgnorePlaytime, snap.ParentalBypassPlaytime, snap.SchemaFetch, false); } } @@ -137,6 +151,69 @@ private void ApplyLanguageSelector(string saved) } } + private void ApplyModeSelector(string? mode) + { + _modeLoading = true; + try + { + // Default to ST Fixer when no mode is set yet. + var target = mode == "cloud_redirect" ? "cloud_redirect" : "stfixer"; + for (int i = 0; i < ModeComboBox.Items.Count; i++) + { + if (ModeComboBox.Items[i] is ComboBoxItem item && item.Tag as string == target) + { + ModeComboBox.SelectedIndex = i; + break; + } + } + } + finally + { + _modeLoading = false; + } + } + + private async void ModeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + if (_modeLoading) return; + if (ModeComboBox.SelectedItem is not ComboBoxItem item) return; + + var target = item.Tag as string ?? "stfixer"; + if (target == _mode) return; + + // Switching into Cloud Redirect shows the consent dialog once, ever. + if (target == "cloud_redirect" && !Services.ModeService.HasAcceptedDisclaimer()) + { + var disclaimer = new Windows.DisclaimerWindow { Owner = Window.GetWindow(this) }; + if (disclaimer.ShowDialog() != true || !disclaimer.Accepted) + { + ApplyModeSelector(_mode); // revert combo + return; + } + Services.ModeService.MarkDisclaimerAccepted(); + } + + bool cloudEnabled = target == "cloud_redirect"; + try + { + Services.ModeService.PersistMode(target, cloudEnabled); + } + catch (Exception ex) + { + ApplyModeSelector(_mode); // revert combo on failure + await Services.Dialog.ShowErrorAsync( + S.Get("Common_Error"), + S.Format("Choice_FailedSaveMode", ex.Message)); + return; + } + + _mode = target; + var mw = Window.GetWindow(this) as MainWindow; + mw?.ApplyMode(target); + // Re-read settings so this page's sections reflect the new mode. + try { await LoadSettingsAsync(); } catch { } + } + private void ApplySyncToggles(bool? achievements, bool? playtime, bool? luas, bool? autoUpdateDll, bool? showNonSteamGame, bool? parentalIgnorePlaytime, bool? parentalBypassPlaytime, bool? schemaFetch, bool? overrideNonStGate) @@ -152,7 +229,7 @@ private void ApplySyncToggles(bool? achievements, bool? playtime, bool? luas, bo if (parentalIgnorePlaytime == true) ParentalIgnorePlaytimeToggle.IsChecked = true; if (parentalBypassPlaytime == true) ParentalBypassPlaytimeToggle.IsChecked = true; if (schemaFetch == true) GetAchievementDataToggle.IsChecked = true; - if (overrideNonStGate == true) OverrideNonStGateToggle.IsChecked = true; + // override_non_st_client_gate has no UI; config value is preserved on write. } finally { @@ -196,8 +273,12 @@ private static void ReadSyncTogglesInto(ref bool? achievements, ref bool? playti parentalIgnorePlaytime = true; if (root.TryGetProperty("parental_bypass_playtime", out var pbp) && pbp.ValueKind == JsonValueKind.True) parentalBypassPlaytime = true; - // Experimental schema fetch: default off when key absent. - if (root.TryGetProperty("experimental_schema_fetch", out var sf) && sf.ValueKind == JsonValueKind.True) + // Schema fetch: default ON when key absent (matches DLL default). + if (root.TryGetProperty("schema_fetch", out var sf2) && sf2.ValueKind == JsonValueKind.False) + schemaFetch = false; + else if (root.TryGetProperty("experimental_schema_fetch", out var sf) && sf.ValueKind == JsonValueKind.False) + schemaFetch = false; + else schemaFetch = true; // UNSUPPORTED WIP OVERRIDE NON-ST CLIENT GATE: default off when absent. if (root.TryGetProperty("override_non_st_client_gate", out var ovr) && ovr.ValueKind == JsonValueKind.True) @@ -206,6 +287,42 @@ private static void ReadSyncTogglesInto(ref bool? achievements, ref bool? playti catch { } } + /// <summary>Reads the STFixer-mode toggles visible in both modes (schema_fetch, + /// show_non_steam_game, and the two parental toggles) from config.json. + /// schema_fetch/show_non_steam_game default ON when absent (DLL defaults); + /// parental toggles default OFF.</summary> + private static void ReadStFixerTogglesInto(ref bool? schemaFetch, ref bool? showNonSteamGame, + ref bool? parentalIgnorePlaytime, ref bool? parentalBypassPlaytime) + { + try + { + var path = GetConfigPath(); + if (!File.Exists(path)) { schemaFetch = true; showNonSteamGame = true; return; } + + var json = File.ReadAllText(path); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + + if (root.TryGetProperty("schema_fetch", out var sf2) && sf2.ValueKind == JsonValueKind.False) + schemaFetch = false; + else if (root.TryGetProperty("experimental_schema_fetch", out var sf) && sf.ValueKind == JsonValueKind.False) + schemaFetch = false; + else + schemaFetch = true; + + if (root.TryGetProperty("show_non_steam_game", out var nsg)) + showNonSteamGame = nsg.ValueKind == JsonValueKind.True; + else + showNonSteamGame = true; // default on when key absent + + if (root.TryGetProperty("parental_ignore_playtime", out var pip) && pip.ValueKind == JsonValueKind.True) + parentalIgnorePlaytime = true; + if (root.TryGetProperty("parental_bypass_playtime", out var pbp) && pbp.ValueKind == JsonValueKind.True) + parentalBypassPlaytime = true; + } + catch { } + } + private void LoadAbout() { // Prefer the informational version (carries any pre-release suffix like @@ -377,10 +494,29 @@ await Services.Dialog.ShowErrorAsync( private void SaveSyncToggles() { var path = GetConfigPath(); + + // Scope the save to keys owned by visible controls; persisting hidden toggles + // would clobber the user's cloud_redirect-mode settings. + if (_mode != "cloud_redirect") + { + Services.ConfigHelper.SaveConfig(path, + new[] { "auto_update_dll", "schema_fetch", "experimental_schema_fetch", "show_non_steam_game", + "parental_ignore_playtime", "parental_bypass_playtime" }, + writer => + { + writer.WriteBoolean("auto_update_dll", AutoUpdateDllToggle.IsChecked == true); + writer.WriteBoolean("schema_fetch", GetAchievementDataToggle.IsChecked == true); + writer.WriteBoolean("show_non_steam_game", ShowNonSteamGameToggle.IsChecked == true); + writer.WriteBoolean("parental_ignore_playtime", ParentalIgnorePlaytimeToggle.IsChecked == true); + writer.WriteBoolean("parental_bypass_playtime", ParentalBypassPlaytimeToggle.IsChecked == true); + }); + return; + } + Services.ConfigHelper.SaveConfig(path, new[] { "sync_achievements", "sync_playtime", "sync_luas", "auto_update_dll", "show_non_steam_game", "parental_ignore_playtime", "parental_bypass_playtime", - "experimental_schema_fetch", "override_non_st_client_gate" }, + "schema_fetch", "experimental_schema_fetch" }, writer => { writer.WriteBoolean("sync_achievements", SyncAchievementsToggle.IsChecked == true); @@ -390,66 +526,11 @@ private void SaveSyncToggles() writer.WriteBoolean("show_non_steam_game", ShowNonSteamGameToggle.IsChecked == true); writer.WriteBoolean("parental_ignore_playtime", ParentalIgnorePlaytimeToggle.IsChecked == true); writer.WriteBoolean("parental_bypass_playtime", ParentalBypassPlaytimeToggle.IsChecked == true); - writer.WriteBoolean("experimental_schema_fetch", GetAchievementDataToggle.IsChecked == true); - writer.WriteBoolean("override_non_st_client_gate", OverrideNonStGateToggle.IsChecked == true); + writer.WriteBoolean("schema_fetch", GetAchievementDataToggle.IsChecked == true); + // override_non_st_client_gate has no UI; existing config value is preserved. }); } - private async void CheckForUpdates_Click(object sender, RoutedEventArgs e) - { - UpdateButton.IsEnabled = false; - UpdateButton.Content = S.Get("Settings_Checking"); - UpdateStatusText.Text = S.Get("Settings_ContactingGitHub"); - DownloadButton.Visibility = Visibility.Collapsed; - _latestDownloadUrl = null; - - try - { - var result = await AppUpdater.CheckAsync(); - - if (result == null) - { - UpdateHeaderText.Text = S.Get("Settings_CheckForUpdates"); - UpdateStatusText.Text = S.Format("Settings_FailedToCheck", "no response from GitHub"); - return; - } - - var localVersion = Assembly.GetExecutingAssembly().GetName().Version; - var local3 = localVersion != null - ? new Version(localVersion.Major, localVersion.Minor, localVersion.Build) - : new Version(0, 0, 0); - - if (result.UpdateAvailable) - { - UpdateHeaderText.Text = S.Format("Settings_UpdateAvailableFormat", result.TagName ?? ""); - UpdateStatusText.Text = S.Format("Settings_NewerVersionAvailable", local3); - _latestDownloadUrl = ReleasesUrl; - DownloadButton.Visibility = Visibility.Visible; - } - else - { - UpdateHeaderText.Text = S.Get("Settings_UpToDate"); - UpdateStatusText.Text = S.Format("Settings_LatestVersionFormat", local3); - } - } - catch (Exception ex) - { - UpdateHeaderText.Text = S.Get("Settings_CheckForUpdates"); - UpdateStatusText.Text = S.Format("Settings_FailedToCheck", ex.Message); - } - finally - { - UpdateButton.IsEnabled = true; - UpdateButton.Content = S.Get("Settings_Check"); - } - } - - private void DownloadUpdate_Click(object sender, RoutedEventArgs e) - { - var url = _latestDownloadUrl ?? ReleasesUrl; - Process.Start(new ProcessStartInfo(url) { UseShellExecute = true })?.Dispose(); - } - private async void ResetData_Click(object sender, RoutedEventArgs e) { var confirmed = await Services.Dialog.ConfirmDangerAsync(S.Get("Settings_ConfirmResetTitle"), diff --git a/ui/Pages/StatsPage.xaml b/ui/Pages/StatsPage.xaml index cf0a2b12..603567a4 100644 --- a/ui/Pages/StatsPage.xaml +++ b/ui/Pages/StatsPage.xaml @@ -10,6 +10,7 @@ <conv:UrlToImageSourceConverter x:Key="UrlToImageSource" /> </Page.Resources> + <Grid> <ScrollViewer VerticalScrollBarVisibility="Auto" Padding="24"> <StackPanel MaxWidth="800"> <Grid Margin="0,0,0,4"> @@ -58,13 +59,6 @@ Margin="0,0,0,8" Visibility="Collapsed" /> - <!-- Transient status (loading / errors) --> - <TextBlock x:Name="StatusText" - Foreground="{DynamicResource TextFillColorSecondaryBrush}" - TextWrapping="Wrap" - Margin="0,0,0,8" - Visibility="Collapsed" /> - <ItemsControl x:Name="AppList"> <ItemsControl.GroupStyle> <GroupStyle> @@ -243,4 +237,14 @@ </ItemsControl> </StackPanel> </ScrollViewer> + + <!-- Transient status (loading / errors), centered over the view --> + <TextBlock x:Name="StatusText" + HorizontalAlignment="Center" + VerticalAlignment="Center" + TextAlignment="Center" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + TextWrapping="Wrap" + Visibility="Collapsed" /> + </Grid> </Page> diff --git a/ui/Resources/Strings.es.resx b/ui/Resources/Strings.es.resx index cf1132d0..34b96315 100644 --- a/ui/Resources/Strings.es.resx +++ b/ui/Resources/Strings.es.resx @@ -1,4 +1,4 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <root> <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> <xsd:element name="root" msdata:IsDataSet="true"> @@ -640,9 +640,6 @@ Si omites esto, las partidas se almacenarán localmente en tu carpeta de Steam.< <data name="CloudProvider_Cancel" xml:space="preserve"> <value>Cancelar</value> </data> - <data name="CloudProvider_SaveConfiguration" xml:space="preserve"> - <value>Guardar configuración</value> - </data> <data name="CloudProvider_NoConfigFound" xml:space="preserve"> <value>No se encontró config.json -- usando valores predeterminados</value> @@ -677,12 +674,6 @@ Si omites esto, las partidas se almacenarán localmente en tu carpeta de Steam.< <data name="CloudProvider_MissingPathMessage" xml:space="preserve"> <value>Establece primero una ruta para el archivo de token.</value> </data> - <data name="CloudProvider_Saved" xml:space="preserve"> - <value>Guardado</value> - </data> - <data name="CloudProvider_SavedMessage" xml:space="preserve"> - <value>Configuración guardada correctamente.</value> - </data> <data name="CloudProvider_FailedSaveConfig" xml:space="preserve"> <value>Error al guardar la configuración: {0}</value> </data> @@ -979,7 +970,7 @@ La copia contiene: {1} archivo(s) ({2}) Apps: {3} IMPORTANTE: Antes de restaurar, debes desactivar Steam Cloud para este juego: - Steam > clic derecho en el juego > Propiedades > General > + Steam > clic derecho en el juego > Propiedades > General > desmarca "Mantener partidas guardadas en Steam Cloud" Si Steam Cloud está activado, Steam puede sobrescribir los archivos restaurados. @@ -1335,45 +1326,6 @@ Esto puede causar pérdida de datos en algunos casos. <data name="Disclaimer_WindowTitle" xml:space="preserve"> <value>ADVERTENCIA</value> </data> - <data name="Disclaimer_BuildBanner" xml:space="preserve"> - <value>CloudRedirect v2.0.3</value> - </data> - <data name="Disclaimer_ExperimentalBanner" xml:space="preserve"> - <value>! EXPERIMENTAL - RIESGO DE PÉRDIDA DE DATOS !</value> - </data> - <data name="Disclaimer_WarningBold" xml:space="preserve"> - <value>La redirección de partidas guardadas en la nube es EXPERIMENTAL y está en desarrollo activo.</value> - </data> - <data name="Disclaimer_ItMay" xml:space="preserve"> - <value>Puede:</value> - </data> - <data name="Disclaimer_Corrupt" xml:space="preserve"> - <value>CORROMPER</value> - </data> - <data name="Disclaimer_CorruptSuffix" xml:space="preserve"> - <value> tus archivos de partidas guardadas</value> - </data> - <data name="Disclaimer_Lose" xml:space="preserve"> - <value>PERDER</value> - </data> - <data name="Disclaimer_LoseSuffix" xml:space="preserve"> - <value> tus datos de partidas guardadas</value> - </data> - <data name="Disclaimer_Overwrite" xml:space="preserve"> - <value>SOBRESCRIBIR</value> - </data> - <data name="Disclaimer_OverwriteSuffix" xml:space="preserve"> - <value> partidas buenas con malas</value> - </data> - <data name="Disclaimer_BackUpBold" xml:space="preserve"> - <value>HAZ UNA COPIA DE SEGURIDAD DE TUS PARTIDAS antes de usar esto.</value> - </data> - <data name="Disclaimer_ExplanationText" xml:space="preserve"> - <value>Si algo sale mal, tus PARTIDAS LOCALES podrían corromperse o perderse. Asegúrate de tener copias de seguridad de cualquier partida que te importe antes de continuar.</value> - </data> - <data name="Disclaimer_PleaseReadCarefully" xml:space="preserve"> - <value>Por favor lee con cuidado...</value> - </data> <data name="Disclaimer_Cancel" xml:space="preserve"> <value>Cancelar</value> </data> @@ -1381,13 +1333,6 @@ Esto puede causar pérdida de datos en algunos casos. <value>Entiendo los riesgos</value> </data> - <data name="Disclaimer_CountdownFormat" xml:space="preserve"> - <value>Por favor lee con cuidado... ({0}s)</value> - </data> - <data name="Disclaimer_Warned" xml:space="preserve"> - <value>Has sido advertido.</value> - </data> - <data name="Dialog_OK" xml:space="preserve"> <value>OK</value> </data> @@ -1550,6 +1495,24 @@ Esto puede causar pérdida de datos en algunos casos. <data name="Settings_SyncPlaytimeHint" xml:space="preserve"> <value>Subir y fusionar los datos de tiempo de juego de las apps lua entre PCs.</value> </data> + <data name="CloudProvider_UploadInFlight" xml:space="preserve"> + <value>Tamaño de subida simultánea</value> + </data> + <data name="CloudProvider_UploadInFlightHint" xml:space="preserve"> + <value>Controla los megabytes de archivos que se suben a la vez. Aumentarlo NO acelera las cargas, pero puede hacer que varias partidas grandes fallen al subirse. No se recomienda cambiarlo.</value> + </data> + <data name="CloudProvider_UploadInFlightValue" xml:space="preserve"> + <value>{0} MB</value> + </data> + <data name="CloudProvider_UploadInFlightConservative" xml:space="preserve"> + <value>Seguro (24)</value> + </data> + <data name="CloudProvider_UploadInFlightBalanced" xml:space="preserve"> + <value>Equilibrado (44)</value> + </data> + <data name="CloudProvider_UploadInFlightAggressive" xml:space="preserve"> + <value>Agresivo (64)</value> + </data> <data name="Settings_SyncLuas" xml:space="preserve"> <value>Sincronizar archivos lua</value> </data> @@ -1631,4 +1594,160 @@ Esto puede causar pérdida de datos en algunos casos. <value>Actualizar ahora</value> </data> + <data name="Cleanup_OstBanner_Description" xml:space="preserve"> + <value>OpenSteamTool no enruta los guardados en la nube a través de la app 760. Esta página sirve para limpiar archivos dejados por una instalación previa de SteamTools.</value> + </data> + <data name="Cleanup_OstBanner_Title" xml:space="preserve"> + <value>OpenSteamTool detectado</value> + </data> + <data name="Disclaimer_ChoiceBackup" xml:space="preserve"> + <value>Si tu mayor miedo es reinstalar Windows sin copia de seguridad, un fallo de hardware o un juego que corrompe el guardado, Cloud Redirect puede ayudarte.</value> + </data> + <data name="Disclaimer_ChoiceHeading" xml:space="preserve"> + <value>Te toca decidir.</value> + </data> + <data name="Disclaimer_ChoiceIntegrity" xml:space="preserve"> + <value>Si lo que más te preocupa es la integridad de tus guardados locales, quizá esta no sea la herramienta para ti.</value> + </data> + <data name="Disclaimer_ChoiceMultiPC" xml:space="preserve"> + <value>Si quieres jugar a un juego en dos o más PC, Cloud Redirect es la forma más fiable de hacerlo.</value> + </data> + <data name="Disclaimer_Closing" xml:space="preserve"> + <value>Tú decides. Sin juicios.</value> + </data> + <data name="Disclaimer_Heading" xml:space="preserve"> + <value>La vida son decisiones.</value> + </data> + <data name="Disclaimer_HowItWorks" xml:space="preserve"> + <value>Cloud Redirect redirige las operaciones de Steam Cloud al proveedor que elijas. Con Google Drive, además, puedes acceder al historial de versiones y recuperar guardados antiguos.</value> + </data> + <data name="Disclaimer_Intro" xml:space="preserve"> + <value>Antes aquí había una pantalla intimidante. Ya no está.</value> + </data> + <data name="Disclaimer_TrackRecord" xml:space="preserve"> + <value>En la práctica, el riesgo es muy bajo. En toda la historia del proyecto solo se han reportado un par de incidentes de pérdida de datos, casi todos recuperables.</value> + </data> + <data name="Nav_Stats" xml:space="preserve"> + <value>Logros y tiempo de juego</value> + </data> + <data name="Settings_Experimental" xml:space="preserve"> + <value>Funciones experimentales</value> + </data> + <data name="Settings_ExperimentalHint" xml:space="preserve"> + <value>PLACEHOLDER LOREM IPSUM</value> + </data> + <data name="Settings_Extra" xml:space="preserve"> + <value>Calidad de vida</value> + </data> + <data name="Settings_ExtraHint" xml:space="preserve"> + <value>Extras opcionales que no forman parte de la sincronización en la nube.</value> + </data> + <data name="Settings_GetAchievementData" xml:space="preserve"> + <value>Hacer que los logros funcionen</value> + </data> + <data name="Settings_GetAchievementDataHint" xml:space="preserve"> + <value>Obtiene datos de logros de Steam; hace que aparezcan logros en juegos para los que ST no consigue datos por sí mismo.</value> + </data> + <data name="Settings_ModeCloudRedirect" xml:space="preserve"> + <value>Cloud Redirect</value> + </data> + <data name="Settings_ModeStFixer" xml:space="preserve"> + <value>ST Fixer</value> + </data> + <data name="Settings_Parental" xml:space="preserve"> + <value>Steam Familia</value> + </data> + <data name="Settings_ParentalBypassPlaytime" xml:space="preserve"> + <value>Desactivar restricciones familiares</value> + </data> + <data name="Settings_ParentalBypassPlaytimeHint" xml:space="preserve"> + <value>No se aplica ninguna restricción familiar del lado del cliente.</value> + </data> + <data name="Settings_ParentalHint" xml:space="preserve"> + <value>Desactivar las restricciones de Steam Familia</value> + </data> + <data name="Settings_ParentalIgnorePlaytime" xml:space="preserve"> + <value>Ignorar restricciones de tiempo de juego</value> + </data> + <data name="Settings_ParentalIgnorePlaytimeHint" xml:space="preserve"> + <value>Desactiva las restricciones de tiempo de juego. Informa del tiempo hasta el límite establecido, pero no más allá.</value> + </data> + <data name="Settings_ShowNonSteamGame" xml:space="preserve"> + <value>Mostrar juego Lua en el estado</value> + </data> + <data name="Settings_ShowNonSteamGameHint" xml:space="preserve"> + <value>Al jugar a un juego desbloqueado por Lua, lo muestra como tu estado de Steam en lugar de aparecer conectado pero sin jugar.</value> + </data> + <data name="Settings_SwitchMode" xml:space="preserve"> + <value>Cambiar modo</value> + </data> + <data name="Settings_SwitchModeHint" xml:space="preserve"> + <value>Elige entre ST Fixer (solo correcciones básicas) y Cloud Redirect (añade redirección de guardados en la nube).</value> + </data> + <data name="Settings_SwitchModeTitle" xml:space="preserve"> + <value>Modo de la app</value> + </data> + <data name="Stats_AccountHeader" xml:space="preserve"> + <value>Cuenta {0}</value> + </data> + <data name="Stats_AchievementBit" xml:space="preserve"> + <value>Estadística {0} - bit {1}</value> + </data> + <data name="Stats_AchievementsHeader" xml:space="preserve"> + <value>Logros</value> + </data> + <data name="Stats_AppFallbackName" xml:space="preserve"> + <value>App {0}</value> + </data> + <data name="Stats_Hint" xml:space="preserve"> + <value>Consulta los datos de logros y tiempo de juego en la nube de tus apps inyectadas.</value> + </data> + <data name="Stats_Hours" xml:space="preserve"> + <value>{0} h</value> + </data> + <data name="Stats_LastPlayed" xml:space="preserve"> + <value>ÚLTIMA VEZ JUGADO</value> + </data> + <data name="Stats_Locked" xml:space="preserve"> + <value>Bloqueado</value> + </data> + <data name="Stats_Minutes" xml:space="preserve"> + <value>{0} min</value> + </data> + <data name="Stats_Never" xml:space="preserve"> + <value>Nunca</value> + </data> + <data name="Stats_NoCloud" xml:space="preserve"> + <value>No hay ningún proveedor de nube configurado. Configura Google Drive, OneDrive o una carpeta en los ajustes de Proveedor de nube para ver las estadísticas.</value> + </data> + <data name="Stats_NoData" xml:space="preserve"> + <value>Aún no hay estadísticas sincronizadas en la nube. Inicia un juego desbloqueado con Lua con CloudRedirect activo, deja que se sincronice y actualiza.</value> + </data> + <data name="Stats_NoPlaytime" xml:space="preserve"> + <value>Ninguno</value> + </data> + <data name="Stats_Playtime2Weeks" xml:space="preserve"> + <value>ÚLTIMAS 2 SEMANAS</value> + </data> + <data name="Stats_PlaytimeForever" xml:space="preserve"> + <value>TIEMPO DE JUEGO (TOTAL)</value> + </data> + <data name="Stats_Refresh" xml:space="preserve"> + <value>Actualizar</value> + </data> + <data name="Stats_StatId" xml:space="preserve"> + <value>Estadística {0}</value> + </data> + <data name="Stats_StatsHeader" xml:space="preserve"> + <value>Estadísticas</value> + </data> + <data name="Stats_SummaryAchievements" xml:space="preserve"> + <value>{0} logros</value> + </data> + <data name="Stats_SummaryPlaytime" xml:space="preserve"> + <value>{0} jugado</value> + </data> + <data name="Stats_SummaryStats" xml:space="preserve"> + <value>{0} estadísticas</value> + </data> </root> diff --git a/ui/Resources/Strings.pt-BR.resx b/ui/Resources/Strings.pt-BR.resx index 15b50aa6..5f856ee0 100644 --- a/ui/Resources/Strings.pt-BR.resx +++ b/ui/Resources/Strings.pt-BR.resx @@ -1,4 +1,4 @@ -<?xml version="1.0" encoding="utf-8"?> +<?xml version="1.0" encoding="utf-8"?> <root> <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> <xsd:element name="root" msdata:IsDataSet="true"> @@ -640,9 +640,6 @@ Forçar encerramento?</value> <data name="CloudProvider_Cancel" xml:space="preserve"> <value>Cancelar</value> </data> - <data name="CloudProvider_SaveConfiguration" xml:space="preserve"> - <value>Salvar Configuração</value> - </data> <data name="CloudProvider_NoConfigFound" xml:space="preserve"> <value>config.json não encontrado -- usando padrões</value> @@ -677,12 +674,6 @@ Forçar encerramento?</value> <data name="CloudProvider_MissingPathMessage" xml:space="preserve"> <value>Defina um caminho para o arquivo de token primeiro.</value> </data> - <data name="CloudProvider_Saved" xml:space="preserve"> - <value>Salvo</value> - </data> - <data name="CloudProvider_SavedMessage" xml:space="preserve"> - <value>Configuração salva com sucesso.</value> - </data> <data name="CloudProvider_FailedSaveConfig" xml:space="preserve"> <value>Falha ao salvar configuração: {0}</value> </data> @@ -979,7 +970,7 @@ O backup contém: {1} arquivo(s) ({2}) Apps: {3} IMPORTANTE: Antes de restaurar, você deve desativar o Steam Cloud para este jogo: - Steam > clique com o botão direito no jogo > Propriedades > Geral > + Steam > clique com o botão direito no jogo > Propriedades > Geral > desmarque "Manter saves do jogo no Steam Cloud" Se o Steam Cloud estiver ativado, o Steam pode sobrescrever os arquivos restaurados. @@ -1335,45 +1326,6 @@ Tem certeza?</value> <data name="Disclaimer_WindowTitle" xml:space="preserve"> <value>AVISO</value> </data> - <data name="Disclaimer_BuildBanner" xml:space="preserve"> - <value>CloudRedirect v2.0.3</value> - </data> - <data name="Disclaimer_ExperimentalBanner" xml:space="preserve"> - <value>! EXPERIMENTAL - RISCO DE PERDA DE DADOS !</value> - </data> - <data name="Disclaimer_WarningBold" xml:space="preserve"> - <value>O Redirecionamento de Saves na Nuvem é EXPERIMENTAL e está em desenvolvimento ativo.</value> - </data> - <data name="Disclaimer_ItMay" xml:space="preserve"> - <value>Ele pode:</value> - </data> - <data name="Disclaimer_Corrupt" xml:space="preserve"> - <value>CORROMPER</value> - </data> - <data name="Disclaimer_CorruptSuffix" xml:space="preserve"> - <value> seus arquivos de save</value> - </data> - <data name="Disclaimer_Lose" xml:space="preserve"> - <value>PERDER</value> - </data> - <data name="Disclaimer_LoseSuffix" xml:space="preserve"> - <value> seus dados de save</value> - </data> - <data name="Disclaimer_Overwrite" xml:space="preserve"> - <value>SOBRESCREVER</value> - </data> - <data name="Disclaimer_OverwriteSuffix" xml:space="preserve"> - <value> saves bons com ruins</value> - </data> - <data name="Disclaimer_BackUpBold" xml:space="preserve"> - <value>FAÇA BACKUP DOS SEUS SAVES antes de usar isso.</value> - </data> - <data name="Disclaimer_ExplanationText" xml:space="preserve"> - <value>Se algo der errado, seus SAVES LOCAIS podem ser corrompidos ou perdidos. Certifique-se de ter backups de quaisquer saves que você valorize antes de continuar.</value> - </data> - <data name="Disclaimer_PleaseReadCarefully" xml:space="preserve"> - <value>Por favor leia com atenção...</value> - </data> <data name="Disclaimer_Cancel" xml:space="preserve"> <value>Cancelar</value> </data> @@ -1381,13 +1333,6 @@ Tem certeza?</value> <value>Eu Entendo os Riscos</value> </data> - <data name="Disclaimer_CountdownFormat" xml:space="preserve"> - <value>Por favor leia com atenção... ({0}s)</value> - </data> - <data name="Disclaimer_Warned" xml:space="preserve"> - <value>Você foi avisado.</value> - </data> - <data name="Dialog_OK" xml:space="preserve"> <value>OK</value> </data> @@ -1550,6 +1495,24 @@ Tem certeza?</value> <data name="Settings_SyncPlaytimeHint" xml:space="preserve"> <value>Enviar e mesclar os dados de tempo de jogo dos apps lua entre PCs.</value> </data> + <data name="CloudProvider_UploadInFlight" xml:space="preserve"> + <value>Tamanho de envio simultâneo</value> + </data> + <data name="CloudProvider_UploadInFlightHint" xml:space="preserve"> + <value>Controla os megabytes de arquivos enviados de uma vez. Aumentar NÃO acelera os envios, mas pode fazer vários saves grandes falharem ao enviar. Alterações não são recomendadas.</value> + </data> + <data name="CloudProvider_UploadInFlightValue" xml:space="preserve"> + <value>{0} MB</value> + </data> + <data name="CloudProvider_UploadInFlightConservative" xml:space="preserve"> + <value>Seguro (24)</value> + </data> + <data name="CloudProvider_UploadInFlightBalanced" xml:space="preserve"> + <value>Equilibrado (44)</value> + </data> + <data name="CloudProvider_UploadInFlightAggressive" xml:space="preserve"> + <value>Agressivo (64)</value> + </data> <data name="Settings_SyncLuas" xml:space="preserve"> <value>Sincronizar arquivos lua</value> </data> @@ -1631,4 +1594,160 @@ Tem certeza?</value> <value>Atualizar agora</value> </data> + <data name="Cleanup_OstBanner_Description" xml:space="preserve"> + <value>O OpenSteamTool não roteia os saves na nuvem pelo app 760. Esta página serve para limpar arquivos deixados por uma instalação anterior do SteamTools.</value> + </data> + <data name="Cleanup_OstBanner_Title" xml:space="preserve"> + <value>OpenSteamTool detectado</value> + </data> + <data name="Disclaimer_ChoiceBackup" xml:space="preserve"> + <value>Se o seu medo é reinstalar o Windows sem backup, uma falha de hardware ou um jogo que corrompe o save, o Cloud Redirect pode ajudar.</value> + </data> + <data name="Disclaimer_ChoiceHeading" xml:space="preserve"> + <value>Hora de escolher.</value> + </data> + <data name="Disclaimer_ChoiceIntegrity" xml:space="preserve"> + <value>Se a sua maior preocupação é a integridade dos saves locais, talvez esta não seja a ferramenta para você.</value> + </data> + <data name="Disclaimer_ChoiceMultiPC" xml:space="preserve"> + <value>Se você quer jogar um jogo em dois ou mais PCs, o Cloud Redirect é a forma mais confiável de fazer isso.</value> + </data> + <data name="Disclaimer_Closing" xml:space="preserve"> + <value>A escolha é sua. Sem julgamentos.</value> + </data> + <data name="Disclaimer_Heading" xml:space="preserve"> + <value>A vida é feita de escolhas.</value> + </data> + <data name="Disclaimer_HowItWorks" xml:space="preserve"> + <value>O Cloud Redirect redireciona as operações do Steam Cloud para o provedor que você escolher. Com o Google Drive, você ainda acessa o histórico de versões e recupera saves antigos.</value> + </data> + <data name="Disclaimer_Intro" xml:space="preserve"> + <value>Antes havia uma tela assustadora aqui. Ela se foi.</value> + </data> + <data name="Disclaimer_TrackRecord" xml:space="preserve"> + <value>Na prática, o risco é muito baixo. Em toda a história do projeto houve apenas alguns incidentes relatados de perda de dados, quase todos recuperáveis.</value> + </data> + <data name="Nav_Stats" xml:space="preserve"> + <value>Conquistas e tempo de jogo</value> + </data> + <data name="Settings_Experimental" xml:space="preserve"> + <value>Recursos experimentais</value> + </data> + <data name="Settings_ExperimentalHint" xml:space="preserve"> + <value>PLACEHOLDER LOREM IPSUM</value> + </data> + <data name="Settings_Extra" xml:space="preserve"> + <value>Qualidade de vida</value> + </data> + <data name="Settings_ExtraHint" xml:space="preserve"> + <value>Extras opcionais que não fazem parte da sincronização na nuvem.</value> + </data> + <data name="Settings_GetAchievementData" xml:space="preserve"> + <value>Fazer as conquistas funcionarem</value> + </data> + <data name="Settings_GetAchievementDataHint" xml:space="preserve"> + <value>Obtém dados de conquistas do Steam; faz as conquistas aparecerem em jogos para os quais o ST não consegue obter os dados sozinho.</value> + </data> + <data name="Settings_ModeCloudRedirect" xml:space="preserve"> + <value>Cloud Redirect</value> + </data> + <data name="Settings_ModeStFixer" xml:space="preserve"> + <value>ST Fixer</value> + </data> + <data name="Settings_Parental" xml:space="preserve"> + <value>Steam Família</value> + </data> + <data name="Settings_ParentalBypassPlaytime" xml:space="preserve"> + <value>Desativar restrições familiares</value> + </data> + <data name="Settings_ParentalBypassPlaytimeHint" xml:space="preserve"> + <value>Nenhuma restrição familiar do lado do cliente é aplicada.</value> + </data> + <data name="Settings_ParentalHint" xml:space="preserve"> + <value>Desativar as restrições do Steam Família</value> + </data> + <data name="Settings_ParentalIgnorePlaytime" xml:space="preserve"> + <value>Ignorar restrições de tempo de jogo</value> + </data> + <data name="Settings_ParentalIgnorePlaytimeHint" xml:space="preserve"> + <value>Desativa as restrições de tempo de jogo. Informa o tempo até o limite definido, mas não além dele.</value> + </data> + <data name="Settings_ShowNonSteamGame" xml:space="preserve"> + <value>Mostrar jogo Lua no status</value> + </data> + <data name="Settings_ShowNonSteamGameHint" xml:space="preserve"> + <value>Ao jogar um jogo desbloqueado por Lua, mostra-o como seu status do Steam em vez de aparecer online mas sem jogar.</value> + </data> + <data name="Settings_SwitchMode" xml:space="preserve"> + <value>Mudar modo</value> + </data> + <data name="Settings_SwitchModeHint" xml:space="preserve"> + <value>Escolha entre ST Fixer (apenas correções básicas) e Cloud Redirect (adiciona o redirecionamento de saves na nuvem).</value> + </data> + <data name="Settings_SwitchModeTitle" xml:space="preserve"> + <value>Modo do app</value> + </data> + <data name="Stats_AccountHeader" xml:space="preserve"> + <value>Conta {0}</value> + </data> + <data name="Stats_AchievementBit" xml:space="preserve"> + <value>Estatística {0} - bit {1}</value> + </data> + <data name="Stats_AchievementsHeader" xml:space="preserve"> + <value>Conquistas</value> + </data> + <data name="Stats_AppFallbackName" xml:space="preserve"> + <value>App {0}</value> + </data> + <data name="Stats_Hint" xml:space="preserve"> + <value>Veja os dados de conquistas e tempo de jogo na nuvem dos seus apps injetados.</value> + </data> + <data name="Stats_Hours" xml:space="preserve"> + <value>{0} h</value> + </data> + <data name="Stats_LastPlayed" xml:space="preserve"> + <value>JOGADO PELA ÚLTIMA VEZ</value> + </data> + <data name="Stats_Locked" xml:space="preserve"> + <value>Bloqueado</value> + </data> + <data name="Stats_Minutes" xml:space="preserve"> + <value>{0} min</value> + </data> + <data name="Stats_Never" xml:space="preserve"> + <value>Nunca</value> + </data> + <data name="Stats_NoCloud" xml:space="preserve"> + <value>Nenhum provedor de nuvem configurado. Configure o Google Drive, o OneDrive ou uma pasta nas configurações de Provedor de nuvem para ver as estatísticas.</value> + </data> + <data name="Stats_NoData" xml:space="preserve"> + <value>Ainda não há estatísticas sincronizadas na nuvem. Inicie um jogo desbloqueado por Lua com o CloudRedirect ativo, deixe sincronizar e atualize.</value> + </data> + <data name="Stats_NoPlaytime" xml:space="preserve"> + <value>Nenhum</value> + </data> + <data name="Stats_Playtime2Weeks" xml:space="preserve"> + <value>ÚLTIMAS 2 SEMANAS</value> + </data> + <data name="Stats_PlaytimeForever" xml:space="preserve"> + <value>TEMPO DE JOGO (TOTAL)</value> + </data> + <data name="Stats_Refresh" xml:space="preserve"> + <value>Atualizar</value> + </data> + <data name="Stats_StatId" xml:space="preserve"> + <value>Estatística {0}</value> + </data> + <data name="Stats_StatsHeader" xml:space="preserve"> + <value>Estatísticas</value> + </data> + <data name="Stats_SummaryAchievements" xml:space="preserve"> + <value>{0} conquistas</value> + </data> + <data name="Stats_SummaryPlaytime" xml:space="preserve"> + <value>{0} jogado</value> + </data> + <data name="Stats_SummaryStats" xml:space="preserve"> + <value>{0} estatísticas</value> + </data> </root> diff --git a/ui/Resources/Strings.resx b/ui/Resources/Strings.resx index 6e98a862..a3035c4e 100644 --- a/ui/Resources/Strings.resx +++ b/ui/Resources/Strings.resx @@ -62,16 +62,16 @@ <value>Apps</value> </data> <data name="Nav_Cleanup" xml:space="preserve"> - <value>Cleanup</value> + <value>Clean Up</value> </data> <data name="Nav_ManifestPinning" xml:space="preserve"> <value>Manifest Pinning</value> </data> <data name="Nav_Stats" xml:space="preserve"> - <value>Stats & Playtime</value> + <value>Achievements & Playtime</value> </data> <data name="Stats_Hint" xml:space="preserve"> - <value>WIP WIP BLAH BLAH BLAH LOREM IPSUM</value> + <value>View current cloud achievement/playtime data for your injected apps.</value> </data> <data name="Stats_Refresh" xml:space="preserve"> <value>Refresh</value> @@ -150,13 +150,13 @@ <value>Choose Your Mode</value> </data> <data name="Choice_Description" xml:space="preserve"> - <value>Pick how you want to use CloudRedirect. You can always upgrade from STFixer to Cloud Redirect later.</value> + <value>Pick how you want to use CloudRedirect. You can switch between ST Fixer and Cloud Redirect anytime.</value> </data> <data name="Choice_STFixer_Title" xml:space="preserve"> - <value>STFixer Mode</value> + <value>ST Fixer Mode</value> </data> <data name="Choice_STFixer_Description" xml:space="preserve"> - <value>Core fixes. Makes Capcom games save, enables SteamTools activation when some SteamTools servers are down, enables manifest pinning. Does not enable Cloud Saves.</value> + <value>Just the core fixes. Solves saving issues with (not just) Capcom titles, fixes 'No Internet Connection' when downloading, enables manifest pinning, makes achievements work for every game that has them. Does not enable Cloud functionality.</value> </data> <data name="Choice_STFixer_Features" xml:space="preserve"> <value>Includes: Capcom save fix, SteamTools offline patch, DLL deploy, manifest version pinning</value> @@ -168,13 +168,13 @@ <value>Everything in ST Fixer, plus Cloud Save redirection. Redirects Steam Cloud for lua games to Google Drive/OneDrive/a local folder.</value> </data> <data name="Choice_CloudRedirect_Features" xml:space="preserve"> - <value>Includes: All STFixer features + cloud save redirect, cloud provider sync, app management, contamination cleanup</value> + <value>All ST Fixer features + Cloud Saves to your provider of choice + Achievement and Playtime Sync</value> </data> <data name="Choice_CurrentMode_STFixer" xml:space="preserve"> - <value>Current mode: STFixer</value> + <value>Current mode: ST Fixer</value> </data> <data name="Choice_CurrentMode_STFixer_Desc" xml:space="preserve"> - <value>You can upgrade to Cloud Redirect mode by selecting the option below. This is one-way -- Cloud Redirect includes everything in STFixer plus cloud features.</value> + <value>You can switch to Cloud Redirect mode by selecting the option below. Cloud Redirect includes everything in ST Fixer plus cloud features. You can switch back anytime.</value> </data> <data name="Choice_CurrentMode_CloudRedirect" xml:space="preserve"> <value>Current mode: Cloud Redirect</value> @@ -578,6 +578,7 @@ If you skip this, saves will be stored locally in your Steam folder.</value> <data name="Dashboard_QuickActions" xml:space="preserve"> <value>Quick Actions</value> </data> + <data name="Dashboard_OpenLogFile" xml:space="preserve"> <value>Show Log in Folder</value> </data> @@ -723,9 +724,6 @@ Force-kill it?</value> <data name="CloudProvider_Cancel" xml:space="preserve"> <value>Cancel</value> </data> - <data name="CloudProvider_SaveConfiguration" xml:space="preserve"> - <value>Save Configuration</value> - </data> <!-- CloudProviderPage code-behind --> <data name="CloudProvider_NoConfigFound" xml:space="preserve"> @@ -761,12 +759,6 @@ Force-kill it?</value> <data name="CloudProvider_MissingPathMessage" xml:space="preserve"> <value>Please set a token file path first.</value> </data> - <data name="CloudProvider_Saved" xml:space="preserve"> - <value>Saved</value> - </data> - <data name="CloudProvider_SavedMessage" xml:space="preserve"> - <value>Configuration saved successfully.</value> - </data> <data name="CloudProvider_FailedSaveConfig" xml:space="preserve"> <value>Failed to save config: {0}</value> </data> @@ -1111,10 +1103,10 @@ Skipped {0} file(s) (already exist at original location).</value> <!-- CleanupPage XAML --> <!-- ═══════════════════════════════════════════════════════════════════ --> <data name="Cleanup_Title" xml:space="preserve"> - <value>Cleanup</value> + <value>Clean Up</value> </data> <data name="Cleanup_Description" xml:space="preserve"> - <value>SteamTools redirected all lua apps' cloud saves through app 760, polluting every game's remote/ folder with every other game's files. Scan to see which apps are affected, then select files to remove.</value> + <value>SteamTools redirected the cloud saves of all Lua apps through app 760, polluting each Lua app's /<appid>/remote/ folder with all the files that SteamTools synced to 760. Scan to see which apps are affected, then select files to remove.</value> </data> <data name="Cleanup_OstBanner_Title" xml:space="preserve"> <value>OpenSteamTool detected</value> @@ -1153,7 +1145,7 @@ Skipped {0} file(s) (already exist at original location).</value> <value>Refresh Backups</value> </data> <data name="Cleanup_RestoreDescription" xml:space="preserve"> - <value>Restore files from a previous cleanup backup. Each cleanup run creates an immutable, timestamped backup. Restoring copies files back without modifying the backup.</value> + <value>Restore files from a previous clean up backup. Each clean up run creates an immutable, timestamped backup. Restoring copies files back without modifying the backup.</value> </data> <data name="Cleanup_LoadingBackups" xml:space="preserve"> <value>Loading backups...</value> @@ -1191,14 +1183,14 @@ Everything can be restored from the Restore tab. Are you sure?</value> </data> <data name="Cleanup_CleanupCompleteTitle" xml:space="preserve"> - <value>Cleanup Complete</value> + <value>Clean Up Complete</value> </data> <data name="Cleanup_CleanupCompleteMessage" xml:space="preserve"> <value>Moved {0} file(s) to backup. You can restore them from the Restore tab if needed.</value> </data> <data name="Cleanup_CleanupFailedTitle" xml:space="preserve"> - <value>Cleanup Failed</value> + <value>Clean Up Failed</value> </data> <data name="Cleanup_CleanedBannerFormat" xml:space="preserve"> <value>Cleaned {0} file(s) to backup.</value> @@ -1256,7 +1248,7 @@ Skipped {0} file(s) (already exist at original location).</value> <value>Steam not found.</value> </data> <data name="Cleanup_UndoNoBackupFound" xml:space="preserve"> - <value>Could not find the backup from the last cleanup. Use Restore from Backup to find it manually.</value> + <value>Could not find the backup from the last clean up. Use Restore from Backup to find it manually.</value> </data> <data name="Cleanup_UndoCompleteTitle" xml:space="preserve"> <value>Undo Complete</value> @@ -1329,7 +1321,7 @@ Skipped {0} file(s) (already exist at original location).</value> <value>Select files to remove by checking the boxes.</value> </data> <data name="Cleanup_ConfirmCleanupTitle" xml:space="preserve"> - <value>Confirm Cleanup</value> + <value>Confirm Clean Up</value> </data> <data name="Cleanup_ConfirmCleanupMessage" xml:space="preserve"> <value>This will move {0} file(s) ({1}) to the backup folder. @@ -1372,6 +1364,21 @@ Files can be restored from the Restore tab. Continue?</value> <data name="Settings_Reset" xml:space="preserve"> <value>Reset</value> </data> + <data name="Settings_SwitchMode" xml:space="preserve"> + <value>Switch Mode</value> + </data> + <data name="Settings_SwitchModeTitle" xml:space="preserve"> + <value>App Mode</value> + </data> + <data name="Settings_SwitchModeHint" xml:space="preserve"> + <value>Choose between ST Fixer (core fixes only) and Cloud Redirect (adds cloud save redirection).</value> + </data> + <data name="Settings_ModeStFixer" xml:space="preserve"> + <value>ST Fixer</value> + </data> + <data name="Settings_ModeCloudRedirect" xml:space="preserve"> + <value>Cloud Redirect</value> + </data> <data name="Settings_About" xml:space="preserve"> <value>About</value> </data> @@ -1438,60 +1445,40 @@ Are you sure?</value> <!-- DisclaimerWindow XAML --> <!-- ═══════════════════════════════════════════════════════════════════ --> <data name="Disclaimer_WindowTitle" xml:space="preserve"> - <value>WARNING</value> - </data> - <data name="Disclaimer_BuildBanner" xml:space="preserve"> - <value>CloudRedirect v2.0.3</value> - </data> - <data name="Disclaimer_ExperimentalBanner" xml:space="preserve"> - <value>! EXPERIMENTAL - DATA LOSS RISK !</value> - </data> - <data name="Disclaimer_WarningBold" xml:space="preserve"> - <value>Cloud Save Redirection is EXPERIMENTAL and under active development.</value> + <value>Change Mode?</value> </data> - <data name="Disclaimer_ItMay" xml:space="preserve"> - <value>It may:</value> + <data name="Disclaimer_Heading" xml:space="preserve"> + <value>Life is about choices.</value> </data> - <data name="Disclaimer_Corrupt" xml:space="preserve"> - <value>CORRUPT</value> + <data name="Disclaimer_Intro" xml:space="preserve"> + <value>There used to be a scary screen here. That screen is gone now. Life is about choices. I mean, life is really about other people but that's a bit heavy for a warning screen. A former warning screen, even.</value> </data> - <data name="Disclaimer_CorruptSuffix" xml:space="preserve"> - <value> your save files</value> + <data name="Disclaimer_HowItWorks" xml:space="preserve"> + <value>Cloud Redirect does very silly things that results in Steam Cloud operations being redirected towards a provider of your choice. If that provider is Google Drive specifically, you have access to Google Drive file versioning. You can pull down older versions of saves.</value> </data> - <data name="Disclaimer_Lose" xml:space="preserve"> - <value>LOSE</value> + <data name="Disclaimer_TrackRecord" xml:space="preserve"> + <value>In practice, this makes Cloud Redirect mode very low risk. I've had maybe...three? reported incidents of data loss in a release build ever. Two were in extremely early versions and I was able to recover the data for the users. One was more recent, caused by a component that does not exist anymore and a network failure at the worst possible time & was not reported to me until days later, resulted in an hour of lost progress. I would have been able to recover the data, but the user progressed past that point before I was sent logs.</value> </data> - <data name="Disclaimer_LoseSuffix" xml:space="preserve"> - <value> your save data</value> + <data name="Disclaimer_ChoiceHeading" xml:space="preserve"> + <value>Time to make a choice.</value> </data> - <data name="Disclaimer_Overwrite" xml:space="preserve"> - <value>OVERWRITE</value> + <data name="Disclaimer_ChoiceIntegrity" xml:space="preserve"> + <value>If you are worried about the integrity of your on disk saves most of all, this may not be the tool for you. It's been fine for me and for many others, but it may not suit you.</value> </data> - <data name="Disclaimer_OverwriteSuffix" xml:space="preserve"> - <value> good saves with bad ones</value> + <data name="Disclaimer_ChoiceBackup" xml:space="preserve"> + <value>If your failure scenario is 'reinstalling Windows and forgetting to backup all my saves/hardware failure/game crashes and corrupts save', CR can help.</value> </data> - <data name="Disclaimer_BackUpBold" xml:space="preserve"> - <value>BACK UP YOUR SAVES before using this.</value> + <data name="Disclaimer_ChoiceMultiPC" xml:space="preserve"> + <value>If you want to play a game across two (or more) different PCs, CR is the best way to do that. It's more reliable than anything else.</value> </data> - <data name="Disclaimer_ExplanationText" xml:space="preserve"> - <value>If something goes wrong, your LOCAL SAVES could be corrupted or lost. Make sure you have backups of any saves you care about before proceeding.</value> - </data> - <data name="Disclaimer_PleaseReadCarefully" xml:space="preserve"> - <value>Please read carefully...</value> + <data name="Disclaimer_Closing" xml:space="preserve"> + <value>Make your choice. No judgement.</value> </data> <data name="Disclaimer_Cancel" xml:space="preserve"> - <value>Cancel</value> + <value>Not for me</value> </data> <data name="Disclaimer_AcceptButton" xml:space="preserve"> - <value>I Understand the Risks</value> - </data> - - <!-- DisclaimerWindow code-behind --> - <data name="Disclaimer_CountdownFormat" xml:space="preserve"> - <value>Please read carefully... ({0}s)</value> - </data> - <data name="Disclaimer_Warned" xml:space="preserve"> - <value>You have been warned.</value> + <value>Enable Cloud Redirect Mode?</value> </data> <!-- ═══════════════════════════════════════════════════════════════════ --> @@ -1677,6 +1664,24 @@ Are you sure?</value> <data name="Settings_SyncPlaytimeHint" xml:space="preserve"> <value>Upload and merge playtime data for lua apps across PCs.</value> </data> + <data name="CloudProvider_UploadInFlight" xml:space="preserve"> + <value>Concurrent Upload Size</value> + </data> + <data name="CloudProvider_UploadInFlightHint" xml:space="preserve"> + <value>Controls megabytes of files uploading at once. Increasing WILL NOT speed up loads, but increasing it can make multiple large saves fail to upload. Changes are not recommended.</value> + </data> + <data name="CloudProvider_UploadInFlightValue" xml:space="preserve"> + <value>{0} MB</value> + </data> + <data name="CloudProvider_UploadInFlightConservative" xml:space="preserve"> + <value>Safe (24)</value> + </data> + <data name="CloudProvider_UploadInFlightBalanced" xml:space="preserve"> + <value>Balanced (44)</value> + </data> + <data name="CloudProvider_UploadInFlightAggressive" xml:space="preserve"> + <value>Aggressive (64)</value> + </data> <data name="Settings_SyncLuas" xml:space="preserve"> <value>Sync Lua Files</value> </data> @@ -1690,7 +1695,7 @@ Are you sure?</value> <value>Automatically check for and install newer versions of the CloudRedirect DLL when Steam launches. The update takes effect on the next Steam restart.</value> </data> <data name="Settings_Extra" xml:space="preserve"> - <value>Quality of Life Placeholder</value> + <value>Quality of Life</value> </data> <data name="Settings_ExtraHint" xml:space="preserve"> <value>Optional extras that aren't part of cloud sync.</value> diff --git a/ui/Services/ModeService.cs b/ui/Services/ModeService.cs new file mode 100644 index 00000000..2f655821 --- /dev/null +++ b/ui/Services/ModeService.cs @@ -0,0 +1,165 @@ +using System; +using System.IO; +using System.Text; +using System.Text.Json; + +namespace CloudRedirect.Services; + +/// <summary> +/// Persists the app mode ("cloud_redirect" / "stfixer"): writes the mode into +/// settings.json and the matching cloud_redirect flag into the DLL pin config. +/// Shared by ChoiceModePage and SettingsPage. +/// </summary> +public static class ModeService +{ + /// <summary> + /// Writes both files. The two writes are not atomic, so settings.json is + /// snapshotted and restored if the pin-config write fails, keeping the + /// file-system view consistent with whatever the DLL agreed to. Throws on + /// real I/O failure so callers can surface it. + /// </summary> + public static void PersistMode(string mode, bool cloudRedirectEnabled) + { + var settingsPath = GetSettingsPath(); + byte[]? settingsBackup = File.Exists(settingsPath) + ? TryReadAllBytes(settingsPath) + : null; + + SaveModeSetting(mode); + try + { + SetDllCloudRedirect(cloudRedirectEnabled); + } + catch + { + RestoreSettingsBackup(settingsPath, settingsBackup); + throw; + } + } + + /// <summary> + /// True once the user has accepted the Change Mode dialog. The dialog is a + /// one-time consent gate, not shown again on later mode switches. + /// </summary> + public static bool HasAcceptedDisclaimer() + { + var existing = ReadObjectOrDefault(GetSettingsPath(), skipComments: false); + return existing.ValueKind == JsonValueKind.Object + && existing.TryGetProperty("disclaimer_accepted", out var p) + && p.ValueKind == JsonValueKind.True; + } + + public static void MarkDisclaimerAccepted() + { + var path = GetSettingsPath(); + var dir = Path.GetDirectoryName(path)!; + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + JsonElement existing = ReadObjectOrDefault(path, skipComments: false); + + using var ms = new MemoryStream(); + using (var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + writer.WriteBoolean("disclaimer_accepted", true); + CopyExcept(existing, "disclaimer_accepted", writer); + writer.WriteEndObject(); + } + + FileUtils.AtomicWriteAllText(path, Encoding.UTF8.GetString(ms.ToArray())); + } + + private static byte[]? TryReadAllBytes(string path) + { + try { return File.ReadAllBytes(path); } + catch { return null; } + } + + private static void RestoreSettingsBackup(string path, byte[]? backup) + { + try + { + if (backup != null) + FileUtils.AtomicWriteAllBytes(path, backup); + else if (File.Exists(path)) + File.Delete(path); // No prior file -> undo our creation. + } + catch { /* best-effort; caller is already surfacing an error */ } + } + + private static void SaveModeSetting(string mode) + { + var path = GetSettingsPath(); + var dir = Path.GetDirectoryName(path)!; + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + // Corrupt existing file is treated as empty; other failures propagate. + JsonElement existing = ReadObjectOrDefault(path, skipComments: false); + + using var ms = new MemoryStream(); + using (var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + writer.WriteString("mode", mode); + CopyExcept(existing, "mode", writer); + writer.WriteEndObject(); + } + + FileUtils.AtomicWriteAllText(path, Encoding.UTF8.GetString(ms.ToArray())); + } + + private static void SetDllCloudRedirect(bool enabled) + { + var path = SteamDetector.GetPinConfigPath(); + if (path == null) return; + + JsonElement existing = ReadObjectOrDefault(path, skipComments: true); + + using var ms = new MemoryStream(); + using (var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + writer.WriteBoolean("cloud_redirect", enabled); + CopyExcept(existing, "cloud_redirect", writer); + writer.WriteEndObject(); + } + + var dir = Path.GetDirectoryName(path)!; + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + FileUtils.AtomicWriteAllText(path, Encoding.UTF8.GetString(ms.ToArray())); + } + + private static JsonElement ReadObjectOrDefault(string path, bool skipComments) + { + if (!File.Exists(path)) return default; + try + { + var json = File.ReadAllText(path); + var opts = skipComments + ? new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip } + : default; + using var doc = JsonDocument.Parse(json, opts); + return doc.RootElement.Clone(); + } + catch { return default; } + } + + private static void CopyExcept(JsonElement obj, string skipKey, Utf8JsonWriter writer) + { + if (obj.ValueKind != JsonValueKind.Object) return; + foreach (var prop in obj.EnumerateObject()) + { + if (prop.Name == skipKey) continue; + prop.WriteTo(writer); + } + } + + private static string GetSettingsPath() + { + return Path.Combine(SteamDetector.GetConfigDir(), "settings.json"); + } +} diff --git a/ui/Services/Steam760Cloud.cs b/ui/Services/Steam760Cloud.cs index 5734d549..c07a2b91 100644 --- a/ui/Services/Steam760Cloud.cs +++ b/ui/Services/Steam760Cloud.cs @@ -109,6 +109,11 @@ private static InvalidOperationException ToolError(ToolResult r, string fallback if (t.Length == 0) continue; if (t.StartsWith("Setting breakpad", StringComparison.OrdinalIgnoreCase)) continue; if (t.StartsWith("Steam_SetMinidump", StringComparison.OrdinalIgnoreCase)) continue; + // The tool prefixes its own messages with "Error:"; strip it so callers + // that add their own "Error: " prefix don't produce "Error: Error: ...". + if (t.StartsWith("Error:", StringComparison.OrdinalIgnoreCase)) + t = t.Substring("Error:".Length).TrimStart(); + if (t.Length == 0) continue; meaningful.Add(t); } if (meaningful.Count > 0) diff --git a/ui/Services/SteamDetector.cs b/ui/Services/SteamDetector.cs index 633d70eb..b1cf02c7 100644 --- a/ui/Services/SteamDetector.cs +++ b/ui/Services/SteamDetector.cs @@ -38,14 +38,21 @@ public static bool IsSupportedSteamVersion(long version) { if (_cachedPath != null) return _cachedPath; + } + + // Resolve outside the lock: registry/known-path lookups and the steam.exe + // walk-up touch the filesystem (incl. possibly-slow UNC paths) and must not + // stall other callers holding _cacheLock. + var resolved = NormalizeToSteamRoot(TryRegistry()) + ?? NormalizeToSteamRoot(TryKnownPaths()); - // Try registry (most reliable on Windows) - _cachedPath = TryRegistry(); + lock (_cacheLock) + { + // Another thread may have resolved while we were outside the lock; + // prefer the already-cached value to keep a single stable result. if (_cachedPath != null) return _cachedPath; - - // Fallback: well-known paths - _cachedPath = TryKnownPaths(); + _cachedPath = resolved; return _cachedPath; } } @@ -58,7 +65,10 @@ public static bool SetSteamPath(string path) { if (string.IsNullOrWhiteSpace(path) || !Directory.Exists(path)) return false; - lock (_cacheLock) { _cachedPath = path; } + var root = NormalizeToSteamRoot(path); + if (root == null) + return false; + lock (_cacheLock) { _cachedPath = root; } return true; } @@ -105,6 +115,35 @@ public static bool SetSteamPath(string path) return null; } + /// <summary> + /// Given any path that may be a Steam root or a subfolder of one (e.g. a game's + /// install dir under steamapps\common), returns the Steam root identified by the + /// presence of steam.exe. Checks the path itself first, then walks up parents. + /// Returns null if no steam.exe is found anywhere up the chain. + /// </summary> + private static string? NormalizeToSteamRoot(string? path) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + + try + { + var dir = new DirectoryInfo(path); + while (dir != null) + { + if (File.Exists(Path.Combine(dir.FullName, "steam.exe"))) + return dir.FullName; + dir = dir.Parent; + } + } + catch + { + // Malformed path -- fall through to null + } + + return null; + } + private static string? TryRegistry() { try diff --git a/ui/Windows/DisclaimerWindow.xaml b/ui/Windows/DisclaimerWindow.xaml index 05b04af5..c6ae2546 100644 --- a/ui/Windows/DisclaimerWindow.xaml +++ b/ui/Windows/DisclaimerWindow.xaml @@ -4,8 +4,9 @@ xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml" xmlns:res="clr-namespace:CloudRedirect.Resources" Title="{res:Loc Disclaimer_WindowTitle}" - Height="720" Width="580" - MinHeight="720" MinWidth="520" + Width="600" + MinWidth="520" + SizeToContent="Height" ResizeMode="NoResize" WindowStartupLocation="CenterOwner" ExtendsContentIntoTitleBar="True"> @@ -18,127 +19,100 @@ <ui:TitleBar Grid.Row="0" Title="{res:Loc Disclaimer_WindowTitle}" ShowMinimize="False" ShowMaximize="False" /> - <Border Grid.Row="1" - BorderBrush="#CC2222" - BorderThickness="2" - Margin="16,0,16,16" - CornerRadius="8" - Padding="0"> - - <Grid> - <Grid.RowDefinitions> - <RowDefinition Height="Auto" /> - <RowDefinition Height="*" /> - <RowDefinition Height="Auto" /> - </Grid.RowDefinitions> - - <Border Grid.Row="0" - Background="#CC2222" - CornerRadius="6,6,0,0" - Padding="16,12"> - <StackPanel HorizontalAlignment="Center"> - <TextBlock Text="{res:Loc Disclaimer_BuildBanner}" - FontFamily="Cascadia Code,Consolas,Courier New" - FontSize="16" - FontWeight="Bold" - Foreground="White" - HorizontalAlignment="Center" - Margin="0,0,0,4" /> - <TextBlock Text="{res:Loc Disclaimer_ExperimentalBanner}" - FontFamily="Cascadia Code,Consolas,Courier New" - FontSize="14" - FontWeight="Bold" - Foreground="#FFCCCC" - HorizontalAlignment="Center" /> - </StackPanel> - </Border> - - <ScrollViewer Grid.Row="1" - VerticalScrollBarVisibility="Auto" - Padding="24,16,24,8"> - <StackPanel> - - <TextBlock x:Name="AsciiArt" - FontFamily="Cascadia Code,Consolas,Courier New" - FontSize="13" - Foreground="#CC2222" - HorizontalAlignment="Center" - Margin="0,0,0,16" - Opacity="0" - Text=" XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XXXX XXXX XXXX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX" /> - - <Border Background="#1A0000" - BorderBrush="#CC2222" - BorderThickness="1" - CornerRadius="4" - Padding="20,16" - Margin="0,0,0,16"> - <StackPanel> - <TextBlock FontSize="13" - Foreground="#FF4444" - TextWrapping="Wrap" - LineHeight="22"> - <Run FontWeight="Bold" Text="{res:Loc Disclaimer_WarningBold}" /> - <LineBreak /><LineBreak /> - <Run Text="{res:Loc Disclaimer_ItMay}" /> - <LineBreak /><LineBreak /> - <Run Text=" " /><Run FontWeight="Bold" Text="{res:Loc Disclaimer_Corrupt}" /><Run Text="{res:Loc Disclaimer_CorruptSuffix}" /> - <LineBreak /> - <Run Text=" " /><Run FontWeight="Bold" Text="{res:Loc Disclaimer_Lose}" /><Run Text="{res:Loc Disclaimer_LoseSuffix}" /> - <LineBreak /> - <Run Text=" " /><Run FontWeight="Bold" Text="{res:Loc Disclaimer_Overwrite}" /><Run Text="{res:Loc Disclaimer_OverwriteSuffix}" /> - <LineBreak /><LineBreak /> - <Run FontWeight="Bold" FontSize="13" Text="{res:Loc Disclaimer_BackUpBold}" /> - </TextBlock> - </StackPanel> - </Border> - - <TextBlock Foreground="{DynamicResource TextFillColorSecondaryBrush}" - FontSize="12.5" - TextWrapping="Wrap" - LineHeight="20" - Margin="0,0,0,8"> - <Run Text="{res:Loc Disclaimer_ExplanationText}" /> - </TextBlock> - - </StackPanel> - </ScrollViewer> - - <Border Grid.Row="2" - Background="{DynamicResource ControlFillColorDefaultBrush}" - CornerRadius="0,0,6,6" - Padding="24,16"> - <Grid> - <Grid.ColumnDefinitions> - <ColumnDefinition Width="*" /> - <ColumnDefinition Width="Auto" /> - <ColumnDefinition Width="Auto" /> - </Grid.ColumnDefinitions> - - <TextBlock x:Name="CountdownText" - Grid.Column="0" - Text="{res:Loc Disclaimer_PleaseReadCarefully}" - Foreground="#FF4444" - FontSize="12" - FontWeight="SemiBold" - VerticalAlignment="Center" /> - - <ui:Button x:Name="CancelButton" - Grid.Column="1" - Content="{res:Loc Disclaimer_Cancel}" - Margin="0,0,8,0" - Click="Cancel_Click" /> - - <ui:Button x:Name="AcceptButton" - Grid.Column="2" - Content="{res:Loc Disclaimer_AcceptButton}" - Appearance="Danger" - IsEnabled="False" - Click="Accept_Click" /> - </Grid> - </Border> - - </Grid> - </Border> + <Grid Grid.Row="1" Margin="16,0,16,16"> + <Grid.RowDefinitions> + <RowDefinition Height="*" /> + <RowDefinition Height="Auto" /> + <RowDefinition Height="Auto" /> + </Grid.RowDefinitions> + + <ScrollViewer Grid.Row="0" + VerticalScrollBarVisibility="Auto" + Padding="8,8,22,8"> + <StackPanel> + + <TextBlock Text="{res:Loc Disclaimer_Heading}" + FontSize="17" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,0,0,14" /> + + <TextBlock Text="{res:Loc Disclaimer_Intro}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + FontSize="14" + TextWrapping="Wrap" + LineHeight="23" + Margin="0,0,0,14" /> + + <TextBlock Text="{res:Loc Disclaimer_HowItWorks}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + FontSize="14" + TextWrapping="Wrap" + LineHeight="23" + Margin="0,0,0,14" /> + + <TextBlock Text="{res:Loc Disclaimer_TrackRecord}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + FontSize="14" + TextWrapping="Wrap" + LineHeight="23" + Margin="0,0,0,18" /> + + <TextBlock Text="{res:Loc Disclaimer_ChoiceHeading}" + FontSize="15" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,0,0,12" /> + + <TextBlock Text="{res:Loc Disclaimer_ChoiceIntegrity}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + FontSize="14" + TextWrapping="Wrap" + LineHeight="23" + Margin="0,0,0,12" /> + + <TextBlock Text="{res:Loc Disclaimer_ChoiceBackup}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + FontSize="14" + TextWrapping="Wrap" + LineHeight="23" + Margin="0,0,0,12" /> + + <TextBlock Text="{res:Loc Disclaimer_ChoiceMultiPC}" + Foreground="{DynamicResource TextFillColorSecondaryBrush}" + FontSize="14" + TextWrapping="Wrap" + LineHeight="23" + Margin="0,0,0,18" /> + + <TextBlock Text="{res:Loc Disclaimer_Closing}" + FontSize="14" + FontWeight="SemiBold" + Foreground="{DynamicResource TextFillColorPrimaryBrush}" + Margin="0,0,0,4" /> + + </StackPanel> + </ScrollViewer> + + <Border Grid.Row="1" + Height="1" + Background="{DynamicResource ControlStrokeColorDefaultBrush}" + Margin="0,12,0,0" /> + + <StackPanel Grid.Row="2" + Orientation="Horizontal" + HorizontalAlignment="Right" + Margin="0,16,8,0"> + <ui:Button x:Name="CancelButton" + Content="{res:Loc Disclaimer_Cancel}" + Margin="0,0,8,0" + Click="Cancel_Click" /> + + <ui:Button x:Name="AcceptButton" + Content="{res:Loc Disclaimer_AcceptButton}" + Click="Accept_Click" /> + </StackPanel> + + </Grid> </Grid> </ui:FluentWindow> diff --git a/ui/Windows/DisclaimerWindow.xaml.cs b/ui/Windows/DisclaimerWindow.xaml.cs index 7c4da70b..3d4dcc48 100644 --- a/ui/Windows/DisclaimerWindow.xaml.cs +++ b/ui/Windows/DisclaimerWindow.xaml.cs @@ -1,67 +1,20 @@ using System; using System.Windows; -using System.Windows.Media; -using System.Windows.Media.Animation; -using System.Windows.Threading; -using CloudRedirect.Resources; using Wpf.Ui.Controls; namespace CloudRedirect.Windows; public partial class DisclaimerWindow : FluentWindow { - private const int CountdownSeconds = 5; - private int _remaining; - private DispatcherTimer? _timer; - - /// <summary> - /// True if the user clicked "I Understand the Risks". - /// </summary> + /// <summary>True if the user chose to enable Cloud Redirect.</summary> public bool Accepted { get; private set; } public DisclaimerWindow() { InitializeComponent(); - Loaded += OnLoaded; - } - - private void OnLoaded(object sender, RoutedEventArgs e) - { - // Fade-in the ASCII art - var fadeIn = new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(800)) - { - EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } - }; - AsciiArt.BeginAnimation(OpacityProperty, fadeIn); - - // Start countdown - _remaining = CountdownSeconds; - UpdateCountdownText(); - - _timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) }; - _timer.Tick += OnTimerTick; - _timer.Start(); - } - - private void OnTimerTick(object? sender, EventArgs e) - { - _remaining--; - - if (_remaining <= 0) - { - _timer?.Stop(); - AcceptButton.IsEnabled = true; - CountdownText.Text = S.Get("Disclaimer_Warned"); - } - else - { - UpdateCountdownText(); - } - } - - private void UpdateCountdownText() - { - CountdownText.Text = S.Format("Disclaimer_CountdownFormat", _remaining); + // SizeToContent grows the window to fit the text; clamp to the screen so it + // never overflows on small displays (the ScrollViewer takes over there). + MaxHeight = SystemParameters.WorkArea.Height - 48; } private void Accept_Click(object sender, RoutedEventArgs e) @@ -77,11 +30,4 @@ private void Cancel_Click(object sender, RoutedEventArgs e) DialogResult = false; Close(); } - - protected override void OnClosed(EventArgs e) - { - _timer?.Stop(); - _timer = null; - base.OnClosed(e); - } } From f0233631a3798476271547315de7e6d399955c10 Mon Sep 17 00:00:00 2001 From: MohandL3G <mohandl3g@gmail.com> Date: Tue, 23 Jun 2026 00:46:53 +0200 Subject: [PATCH 23/24] feature/git-action: CI workflow, build fixes, and master branch updates --- .github/workflows/build.yml | 26 + cli-rust/.gitignore | 3 + cli-rust/Cargo.toml | 30 + cli-rust/src/crypto.rs | 233 ++++++ cli-rust/src/embedded.rs | 110 +++ cli-rust/src/file_util.rs | 59 ++ cli-rust/src/main.rs | 186 +++++ cli-rust/src/patcher.rs | 697 +++++++++++++++++ cli-rust/src/pe.rs | 189 +++++ cli-rust/src/signatures.rs | 722 ++++++++++++++++++ cli-rust/src/steam_detector.rs | 106 +++ ...g.cloudredirect.CloudRedirect.metainfo.xml | 3 + src/common/autocloud_scan.h | 1 + src/common/stats_store.cpp | 3 + ui/CloudRedirect.csproj | 14 +- 15 files changed, 2378 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 cli-rust/.gitignore create mode 100644 cli-rust/Cargo.toml create mode 100644 cli-rust/src/crypto.rs create mode 100644 cli-rust/src/embedded.rs create mode 100644 cli-rust/src/file_util.rs create mode 100644 cli-rust/src/main.rs create mode 100644 cli-rust/src/patcher.rs create mode 100644 cli-rust/src/pe.rs create mode 100644 cli-rust/src/signatures.rs create mode 100644 cli-rust/src/steam_detector.rs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..11cd5fd1 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,26 @@ +name: Build + +on: [push, pull_request, workflow_dispatch] + +jobs: + build-windows: + runs-on: windows-2022 + steps: + - uses: actions/checkout@v4 + + - uses: microsoft/setup-msbuild@v2 + + - name: Configure CMake + run: cmake -B build -G "Visual Studio 17 2022" -A x64 + + - name: Build + run: cmake --build build --config Release + + - uses: actions/upload-artifact@v4 + with: + name: cloudredirect-windows + path: | + build/Release/cloud_redirect.dll + build/Release/cloud_redirect_cli.exe + build/Release/cloud760_tool.exe + ui/bin/publish/CloudRedirect.exe diff --git a/cli-rust/.gitignore b/cli-rust/.gitignore new file mode 100644 index 00000000..1d58d1c4 --- /dev/null +++ b/cli-rust/.gitignore @@ -0,0 +1,3 @@ +/target +/embedded +Cargo.lock diff --git a/cli-rust/Cargo.toml b/cli-rust/Cargo.toml new file mode 100644 index 00000000..f6454bab --- /dev/null +++ b/cli-rust/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "cloudredirect-cli" +version = "2.1.8" +edition = "2021" +description = "CloudRedirect CLI (STFixer) — Rust port" + +[[bin]] +name = "CloudRedirectCLI" +path = "src/main.rs" + +[dependencies] +# Registry access for Steam path detection +winreg = "0.52" +# AES-256 for SteamTools payload crypto +aes = "0.8" +cbc = { version = "0.1", features = ["alloc"] } +# Hashing (fingerprint / payload validation) +sha2 = "0.10" +md-5 = "0.10" +# zlib inflate for payload validation +flate2 = "1" +# Minimal HTTP for core-DLL download (only stdlib + a tiny client) +ureq = { version = "2", default-features = false, features = ["tls"] } + +[profile.release] +opt-level = "z" # optimize for size +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/cli-rust/src/crypto.rs b/cli-rust/src/crypto.rs new file mode 100644 index 00000000..560d105e --- /dev/null +++ b/cli-rust/src/crypto.rs @@ -0,0 +1,233 @@ +// SteamTools-compatible crypto and payload-cache fingerprinting. + +use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +/// AES-256 key SteamTools uses to encrypt/decrypt the payload cache. +pub const AES_KEY: [u8; 32] = [ + 0x31, 0x4C, 0x20, 0x86, 0x15, 0x05, 0x74, 0xE1, 0x5C, 0xF1, 0x1D, 0x1B, 0xC1, 0x71, 0x25, 0x1A, + 0x47, 0x08, 0x6C, 0x00, 0x26, 0x93, 0x55, 0xCD, 0x51, 0xC9, 0x3A, 0x42, 0x3C, 0x14, 0x02, 0x94, +]; + +type Aes256CbcDec = cbc::Decryptor<aes::Aes256>; +type Aes256CbcEnc = cbc::Encryptor<aes::Aes256>; + +/// AES-256-CBC decrypt with PKCS7 padding. Returns None on bad padding/length. +pub fn aes_cbc_decrypt(ct: &[u8], key: &[u8; 32], iv: &[u8; 16]) -> Option<Vec<u8>> { + let dec = Aes256CbcDec::new(key.into(), iv.into()); + let mut buf = ct.to_vec(); + dec.decrypt_padded_mut::<Pkcs7>(&mut buf) + .ok() + .map(|s| s.to_vec()) +} + +/// AES-256-CBC encrypt with PKCS7 padding. +pub fn aes_cbc_encrypt(pt: &[u8], key: &[u8; 32], iv: &[u8; 16]) -> Vec<u8> { + let enc = Aes256CbcEnc::new(key.into(), iv.into()); + let mut buf = vec![0u8; pt.len() + 16]; + let n = enc + .encrypt_padded_b2b_mut::<Pkcs7>(pt, &mut buf) + .expect("encrypt buffer large enough") + .len(); + buf.truncate(n); + buf +} + +/// Compute the SteamTools payload-cache fingerprint. +pub fn compute_fingerprint() -> String { + // CPUID leaf 0 -> vendor string (EBX, EDX, ECX order). + let l0 = core::arch::x86_64::__cpuid(0); + let mut vendor_bytes = [0u8; 12]; + vendor_bytes[0..4].copy_from_slice(&l0.ebx.to_le_bytes()); + vendor_bytes[4..8].copy_from_slice(&l0.edx.to_le_bytes()); + vendor_bytes[8..12].copy_from_slice(&l0.ecx.to_le_bytes()); + let vendor = String::from_utf8_lossy(&vendor_bytes).into_owned(); + + // CPUID leaf 1 -> base family/model nibbles (not extended). + let l1 = core::arch::x86_64::__cpuid(1); + let family = (l1.eax >> 8) & 0xF; + let model = (l1.eax >> 4) & 0xF; + let nproc = (num_cpus() as u32) & 0xFF; + + let tag = format!("V{}_F{:X}_M{:X}_C{:X}", vendor, family, model, nproc); + let tag = tag.as_bytes(); + + // XOR with "version" (same as Core.dll). + let xor_key = b"version"; + let xored: Vec<u8> = tag + .iter() + .enumerate() + .map(|(i, b)| b ^ xor_key[i % 7]) + .collect(); + + // MD5 -> lowercase hex ASCII bytes. + use md5::{Digest, Md5}; + let digest = Md5::digest(&xored); + let md5_hex = hex_lower(&digest); + let md5_hex_bytes = md5_hex.as_bytes(); + + // Non-standard CRC-64: poly 0x85E1C3D753D46D27, XOR-before-shift. + // This is SteamTools-specific; do NOT "fix" the bit ordering. + let mut crc: u64 = 0xFFFF_FFFF_FFFF_FFFF; + for &b in md5_hex_bytes { + crc ^= b as u64; + for _ in 0..8 { + if crc & 1 != 0 { + crc ^= 0x85E1_C3D7_53D4_6D27; + } + crc >>= 1; + } + } + format!("{:016X}", crc ^ 0xFFFF_FFFF_FFFF_FFFF) +} + +/// Locate the encrypted payload cache file under appcache\httpcache\3b. +pub fn find_cache_path(steam_path: &Path, log: &mut dyn FnMut(&str)) -> Option<PathBuf> { + let cache_dir = steam_path + .join("appcache") + .join("httpcache") + .join("3b"); + if !cache_dir.is_dir() { + return None; + } + + // Try the computed fingerprint slot first. + let fp = compute_fingerprint(); + let fp_path = cache_dir.join(&fp); + if fp_path.is_file() { + if validate_payload_cache(&fp_path) { + log(&format!("Cache: {}", fp_path.display())); + return Some(fp_path); + } + log(&format!( + "Cache at {} failed validation, scanning..", + fp_path.display() + )); + } else { + log(&format!( + "Fingerprint {} computed but no cache file there", + fp + )); + } + + // Fall back to scanning for a 16-char-named file of plausible size. + let entries = std::fs::read_dir(&cache_dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + let name = entry.file_name(); + let name = name.to_string_lossy(); + let len = entry.metadata().map(|m| m.len()).unwrap_or(0); + if name.len() == 16 && len > 500_000 && len < 5_000_000 { + if !validate_payload_cache(&path) { + log(&format!( + "Cache candidate {} failed validation, skipping", + name + )); + continue; + } + log(&format!("Cache (found by scan): {}", path.display())); + return Some(path); + } + } + None +} + +pub fn get_expected_cache_path(steam_path: &Path) -> PathBuf { + steam_path + .join("appcache") + .join("httpcache") + .join("3b") + .join(compute_fingerprint()) +} + +/// Validate a candidate cache file: AES-CBC decrypt -> skip 4-byte prefix -> +/// zlib inflate -> must start with an MZ header. +pub fn validate_payload_cache(path: &Path) -> bool { + let raw = match std::fs::read(path) { + Ok(r) => r, + Err(_) => return false, + }; + if raw.len() < 32 { + return false; + } + let iv: [u8; 16] = match raw[0..16].try_into() { + Ok(v) => v, + Err(_) => return false, + }; + let ct = &raw[16..]; + let plain = match aes_cbc_decrypt(ct, &AES_KEY, &iv) { + Some(p) => p, + None => return false, + }; + if plain.len() < 6 { + return false; + } + // Payload format: 4-byte prefix then zlib data. + let mut z = flate2::read::ZlibDecoder::new(&plain[4..]); + let mut header = [0u8; 2]; + match z.read_exact(&mut header) { + Ok(()) => header[0] == 0x4D && header[1] == 0x5A, // 'M','Z' + Err(_) => false, + } +} + +fn hex_lower(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{:02x}", b)); + } + s +} + +fn num_cpus() -> usize { + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn aes_roundtrip() { + let iv = [7u8; 16]; + let pt = b"hello steamtools payload check"; + let ct = aes_cbc_encrypt(pt, &AES_KEY, &iv); + let back = aes_cbc_decrypt(&ct, &AES_KEY, &iv).unwrap(); + assert_eq!(&back, pt); + } + + #[test] + fn fingerprint_is_16_hex() { + let fp = compute_fingerprint(); + assert_eq!(fp.len(), 16); + assert!(fp.chars().all(|c| c.is_ascii_hexdigit())); + eprintln!("fingerprint = {}", fp); + } + + // The computed fingerprint must locate and validate the real payload cache. + #[test] + fn finds_live_payload_cache() { + let candidates = [r"C:\Games\Steam", r"C:\Program Files (x86)\Steam"]; + let steam = candidates + .iter() + .map(std::path::PathBuf::from) + .find(|p| p.is_dir()); + let Some(steam) = steam else { + eprintln!("Steam not found; skipping cache parity test"); + return; + }; + let fp = compute_fingerprint(); + let expected = get_expected_cache_path(&steam); + eprintln!("fingerprint={} expected={}", fp, expected.display()); + eprintln!("expected exists on disk: {}", expected.is_file()); + + let mut log = |m: &str| eprintln!(" [find] {}", m); + match find_cache_path(&steam, &mut log) { + Some(p) => eprintln!("FOUND + VALIDATED cache: {}", p.display()), + None => eprintln!("no valid cache found (payload may not be cached yet)"), + } + } +} diff --git a/cli-rust/src/embedded.rs b/cli-rust/src/embedded.rs new file mode 100644 index 00000000..13f55b9b --- /dev/null +++ b/cli-rust/src/embedded.rs @@ -0,0 +1,110 @@ +// Embedded resources: the native cloud_redirect.dll and per-Steam-build payload +// caches, baked into the binary at compile time. + +use crate::{crypto, file_util}; +use std::path::{Path, PathBuf}; + +// The native DLL deployed next to steam.exe. Built by `cmake` into +// build/Release/cloud_redirect.dll before this crate is compiled. +const CLOUD_REDIRECT_DLL: &[u8] = + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/embedded/cloud_redirect.dll")); + +// Per-build SteamTools payload caches. Each entry: (steam_build, bytes). +// The files live in cli-rust/embedded/payloads/<build>/payload (copied from +// ui/Resources/payloads by the build script). +const PAYLOADS: &[(i64, &[u8])] = &[ + ( + 1778003620, + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/embedded/payloads/1778003620/payload")), + ), + ( + 1778281814, + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/embedded/payloads/1778281814/payload")), + ), + ( + 1779486452, + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/embedded/payloads/1779486452/payload")), + ), + ( + 1779918128, + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/embedded/payloads/1779918128/payload")), + ), + ( + 1780352834, + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/embedded/payloads/1780352834/payload")), + ), + ( + 1781041600, + include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/embedded/payloads/1781041600/payload")), + ), +]; + +pub fn dll_available() -> bool { + !CLOUD_REDIRECT_DLL.is_empty() +} + +/// Deploy the embedded cloud_redirect.dll to `dest`. Returns Some(error) on failure. +pub fn deploy_dll(dest: &Path) -> Option<String> { + if CLOUD_REDIRECT_DLL.is_empty() { + return Some("cloud_redirect.dll is not embedded in this build.".to_string()); + } + match file_util::atomic_write_all_bytes(dest, CLOUD_REDIRECT_DLL) { + Ok(()) => None, + Err(e) => { + // Likely "file in use" when Steam is running. + if e.raw_os_error() == Some(32) { + Some("cloud_redirect.dll is in use -- close Steam first.".to_string()) + } else { + Some(format!("Failed to deploy cloud_redirect.dll: {}", e)) + } + } + } +} + +fn payload_for_build(steam_build: i64) -> Option<&'static [u8]> { + PAYLOADS + .iter() + .find(|(b, _)| *b == steam_build) + .map(|(_, data)| *data) +} + +/// Install the embedded payload cache for `steam_build` into the expected +/// fingerprint slot, validating it afterwards. Returns the cache path on success. +pub fn install_payload( + steam_path: &Path, + steam_build: i64, + mut log: impl FnMut(&str), +) -> Option<PathBuf> { + let data = match payload_for_build(steam_build) { + Some(d) => d, + None => { + log(&format!( + "Embedded payload: not present for build {}", + steam_build + )); + return None; + } + }; + + let dst = crypto::get_expected_cache_path(steam_path); + if let Some(dir) = dst.parent() { + let _ = std::fs::create_dir_all(dir); + } + + if let Err(e) = file_util::atomic_write_all_bytes(&dst, data) { + log(&format!("Embedded payload install failed: {}", e)); + return None; + } + + if !crypto::validate_payload_cache(&dst) { + log(&format!( + "Embedded payload for build {} failed validation after install", + steam_build + )); + let _ = std::fs::remove_file(&dst); + return None; + } + + log(&format!("Embedded payload installed to {}", dst.display())); + Some(dst) +} diff --git a/cli-rust/src/file_util.rs b/cli-rust/src/file_util.rs new file mode 100644 index 00000000..9eade880 --- /dev/null +++ b/cli-rust/src/file_util.rs @@ -0,0 +1,59 @@ +// Atomic file write/copy: write a sibling .tmp, fsync it, then rename over the +// target so a crash/power-loss never leaves a torn destination. + +use std::fs::{File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::Path; + +fn write_flush_and_publish<F>(path: &Path, writer: F) -> io::Result<()> +where + F: FnOnce(&mut File) -> io::Result<()>, +{ + let tmp = with_extension_suffix(path, ".tmp"); + let result = (|| { + { + let mut f = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&tmp)?; + writer(&mut f)?; + // Force data blocks to stable storage before publishing the rename. + f.sync_all()?; + } + // Atomic publish: rename over the target. + std::fs::rename(&tmp, path) + })(); + + if result.is_err() { + let _ = std::fs::remove_file(&tmp); + } + result +} + +pub fn atomic_write_all_bytes(path: &Path, data: &[u8]) -> io::Result<()> { + write_flush_and_publish(path, |f| f.write_all(data)) +} + +pub fn atomic_copy(source: &Path, dest: &Path) -> io::Result<()> { + write_flush_and_publish(dest, |f| { + let mut src = File::open(source)?; + let mut buf = vec![0u8; 81920]; + loop { + let n = src.read(&mut buf)?; + if n == 0 { + break; + } + f.write_all(&buf[..n])?; + } + Ok(()) + }) +} + +/// Append a literal suffix (e.g. ".tmp", ".orig", ".bak") to a path's full name. +/// Matches the C# `path + ".tmp"` behavior (not OS extension replacement). +pub fn with_extension_suffix(path: &Path, suffix: &str) -> std::path::PathBuf { + let mut s = path.as_os_str().to_os_string(); + s.push(suffix); + std::path::PathBuf::from(s) +} diff --git a/cli-rust/src/main.rs b/cli-rust/src/main.rs new file mode 100644 index 00000000..7dd70ba2 --- /dev/null +++ b/cli-rust/src/main.rs @@ -0,0 +1,186 @@ +// CloudRedirect CLI (STFixer). + +// Several PE/signature helpers (payload P1-P3, hook finders) are kept for +// completeness but aren't exercised by the offline-setup flow. +#![allow(dead_code)] + +mod crypto; +mod embedded; +mod file_util; +mod patcher; +mod pe; +mod signatures; +mod steam_detector; + +use std::path::Path; +use std::process::exit; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +fn main() { + let args: Vec<String> = std::env::args().skip(1).collect(); + if args.is_empty() { + print_help(); + exit(0); + } + + let command = args[0] + .to_lowercase() + .trim_start_matches('/') + .trim_start_matches('-') + .to_string(); + + let code = match command.as_str() { + "stfixer" => run_stfixer(), + "help" | "?" => { + print_help(); + 0 + } + _ => { + eprintln!("Unknown command: {}", args[0]); + eprintln!(); + print_help(); + 1 + } + }; + exit(code); +} + +fn print_help() { + println!("CloudRedirect v{}-CLI", VERSION); + println!(); + println!("Usage: CloudRedirectCLI.exe <command>"); + println!(); + println!("Commands:"); + println!(" /stfixer Apply STFixer patches (fixes Capcom saves, manifest downloads)"); + println!(" /help Show this help message"); +} + +fn run_stfixer() -> i32 { + println!("=== CloudRedirect STFixer ==="); + println!(); + + let steam_path = match steam_detector::find_steam_path() { + Some(p) => p, + None => { + eprintln!("ERROR: Steam installation not found."); + eprintln!("Checked registry and common install paths."); + return 1; + } + }; + println!("Steam: {}", steam_path.display()); + + match steam_detector::get_steam_version(&steam_path) { + None => println!("WARNING: Could not read Steam version -- continuing anyway."), + Some(v) if !steam_detector::is_supported_steam_version(v) => { + println!("WARNING: Steam version {} not in whitelist -- continuing anyway.", v) + } + Some(v) => println!("Steam version: {} (OK)", v), + } + + // Close Steam if running (the DLL/exe are locked while it runs). + if steam_detector::is_steam_running() { + println!("Steam is running -- shutting it down..."); + let steam_exe = steam_path.join("steam.exe"); + if steam_exe.is_file() { + let _ = std::process::Command::new(&steam_exe) + .arg("-shutdown") + .spawn(); + } + for _ in 0..30 { + std::thread::sleep(std::time::Duration::from_millis(500)); + if !steam_detector::is_steam_running() { + break; + } + } + if steam_detector::is_steam_running() { + println!("Graceful shutdown timed out, killing Steam..."); + let _ = std::process::Command::new("taskkill") + .args(["/F", "/IM", "steam.exe"]) + .output(); + std::thread::sleep(std::time::Duration::from_millis(1000)); + } + println!("Steam closed."); + } + + let p = patcher::Patcher::new(steam_path.clone()); + + // Download core DLLs if missing. + if !p.has_core_dll() { + println!(); + println!("Downloading SteamTools core DLLs..."); + let repair = p.repair_core_dlls(); + if !repair.succeeded { + eprintln!( + "FAILED: {}", + repair.error.unwrap_or_else(|| "Could not download core DLLs".into()) + ); + return 1; + } + println!("OK"); + } + + // Apply STFixer patches. + println!(); + println!("Applying STFixer patches..."); + let result = p.apply_offline_setup(); + if !result.succeeded { + eprintln!("FAILED: {}", result.error.unwrap_or_default()); + return 1; + } + println!("OK"); + + // Patch SteamTools.exe so it doesn't overwrite Core.dll or prompt on startup. + println!(); + println!("Patching SteamTools.exe..."); + match p.patch_steamtools_exe() { + 1 => println!("OK"), + 0 => println!("Skipped (SteamTools.exe not installed)."), + _ => println!( + "WARNING: SteamTools.exe patch failed -- see messages above. \ + STFixer patches still applied; you may be prompted by SteamTools on startup." + ), + } + + // Deploy DLL. + println!(); + println!("Deploying cloud_redirect.dll..."); + if !embedded::dll_available() { + eprintln!("ERROR: cloud_redirect.dll is not embedded in this build."); + return 1; + } + let dll_dest = steam_path.join("cloud_redirect.dll"); + if let Some(err) = embedded::deploy_dll(&dll_dest) { + eprintln!("FAILED: {}", err); + return 1; + } + println!("Deployed to {}", dll_dest.display()); + + // Enable auto-update in config.json so the DLL stays current. + enable_auto_update(&steam_path); + + println!(); + println!("All patches applied. Start Steam to use STFixer."); + 0 +} + +fn enable_auto_update(steam_path: &Path) { + let config_dir = steam_path.join("cloud_redirect"); + let config_path = config_dir.join("config.json"); + if std::fs::create_dir_all(&config_dir).is_err() { + return; + } + let json = if let Ok(existing) = std::fs::read_to_string(&config_path) { + if existing.contains("auto_update_dll") { + existing + } else { + let trimmed = existing.trim_end().trim_end_matches('}'); + format!("{},\n \"auto_update_dll\": true\n}}", trimmed) + } + } else { + "{\n \"auto_update_dll\": true\n}".to_string() + }; + if std::fs::write(&config_path, json).is_ok() { + println!("DLL auto-update enabled."); + } +} diff --git a/cli-rust/src/patcher.rs b/cli-rust/src/patcher.rs new file mode 100644 index 00000000..982f8136 --- /dev/null +++ b/cli-rust/src/patcher.rs @@ -0,0 +1,697 @@ +// SteamTools patcher. Implements the STFixer flow: repair (download) core DLLs, +// apply the offline-setup patches to the core DLL + payload cache, and patch +// SteamTools.exe so it stops redeploying Core.dll. + +use crate::crypto::{self, AES_KEY}; +use crate::file_util; +use crate::pe::PeSection; +use crate::signatures::{self, PatchEntry}; +use crate::{embedded, steam_detector}; + +use sha2::{Digest, Sha256}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +const HIJACK_CANDIDATES: [&str; 2] = ["xinput1_4.dll", "dwmapi.dll"]; + +// Core DLL download sources (primary catbox + HTTPS fallback; SHA-256 verified). +const XINPUT_URL: &str = "https://files.catbox.moe/heom44.dll"; +const DWMAPI_URL: &str = "https://files.catbox.moe/32p6f9.dll"; +const XINPUT_FALLBACK_URL: &str = "https://update.aaasn.com/update"; +const DWMAPI_FALLBACK_URL: &str = "https://update.aaasn.com/dwmapi"; +const XINPUT_HASH: &str = "ddb1f0909c7092f06890674f90b5d4f1198724b05b4bf1e656b4063897340243"; +const DWMAPI_HASH: &str = "1ce49ed63af004ad37a4d2921a5659a17001c4c0026d6245fcc0d543e9c265d0"; + +// SteamTools.exe DeployCoreToSteamDir prologue patch (push rbp -> ret nop). +const STEXE_PATCH_OFFSET: usize = 0x282F0; +const STEXE_ORIGINAL: [u8; 2] = [0x40, 0x55]; // REX push rbp +const STEXE_PATCHED: [u8; 2] = [0xC3, 0x90]; // ret nop + +pub struct PatchOutcome { + pub succeeded: bool, + pub error: Option<String>, +} + +impl PatchOutcome { + fn ok() -> Self { + PatchOutcome { + succeeded: true, + error: None, + } + } + fn fail(msg: impl Into<String>) -> Self { + PatchOutcome { + succeeded: false, + error: Some(msg.into()), + } + } +} + +pub struct Patcher { + steam_path: PathBuf, +} + +impl Patcher { + pub fn new(steam_path: PathBuf) -> Self { + Patcher { steam_path } + } + + fn log(&self, msg: &str) { + println!("{}", msg); + } + + // Core DLL discovery + + fn find_core_dll(&self) -> Option<&'static str> { + for name in HIJACK_CANDIDATES { + let path = self.steam_path.join(name); + if !path.is_file() { + continue; + } + if let Ok(buf) = std::fs::read(&path) { + if signatures::scan_for_bytes(&buf, 0, buf.len() as i64, &AES_KEY) >= 0 { + return Some(name); + } + } + } + None + } + + pub fn has_core_dll(&self) -> bool { + self.find_core_dll().is_some() + } + + // Repair (download) core DLLs + + pub fn repair_core_dlls(&self) -> PatchOutcome { + let targets = [ + ("xinput1_4.dll", XINPUT_URL, XINPUT_FALLBACK_URL, XINPUT_HASH), + ("dwmapi.dll", DWMAPI_URL, DWMAPI_FALLBACK_URL, DWMAPI_HASH), + ]; + + for (name, url, fallback, hash) in targets { + let dest = self.steam_path.join(name); + + if dest.is_file() { + if let Ok(existing) = std::fs::read(&dest) { + if sha256_hex(&existing) == hash { + self.log(&format!(" {}: already present, hash OK", name)); + continue; + } + self.log(&format!( + " {}: present but hash mismatch, re-downloading..", + name + )); + } + } + + self.log(&format!("Downloading {}..", name)); + let mut data: Option<Vec<u8>> = None; + let mut from_fallback = false; + + match http_get(url) { + Ok(dl) if !dl.is_empty() && sha256_hex(&dl) == hash => data = Some(dl), + Ok(dl) => self.log(&format!(" Primary returned bad data (len={})", dl.len())), + Err(e) => self.log(&format!(" Primary failed: {}", e)), + } + + if data.is_none() { + self.log(" Trying fallback.."); + match http_get(fallback) { + Ok(dl) if !dl.is_empty() && sha256_hex(&dl) == hash => { + data = Some(dl); + from_fallback = true; + } + Ok(dl) => self.log(&format!(" Fallback returned bad data (len={})", dl.len())), + Err(e) => self.log(&format!(" Fallback failed: {}", e)), + } + } + + let Some(data) = data else { + return PatchOutcome::fail(format!( + "Could not download {}: no source returned a valid file", + name + )); + }; + + if let Err(e) = file_util::atomic_write_all_bytes(&dest, &data) { + return PatchOutcome::fail(format!("Could not write {}: {}", name, e)); + } + self.log(&format!( + " {}: {} bytes{}", + name, + data.len(), + if from_fallback { " (fallback)" } else { "" } + )); + } + + self.log("DLL repair complete."); + PatchOutcome::ok() + } + + // Offline setup (the main patch) + + pub fn apply_offline_setup(&self) -> PatchOutcome { + let version = match steam_detector::get_steam_version(&self.steam_path) { + Some(v) => v, + None => { + self.log(" WARNING: Could not read Steam version from manifest"); + return PatchOutcome::fail( + "Steam version could not be determined. Cannot safely patch.", + ); + } + }; + if !steam_detector::is_supported_steam_version(version) { + let supported = supported_versions_str(); + self.log(&format!(" Steam version: {} (UNSUPPORTED)", version)); + self.log(&format!(" Supported versions: {}", supported)); + return PatchOutcome::fail(format!( + "Steam version mismatch: installed {}, supported {}. \ + Patching an unsupported version risks corrupting steamclient64.dll. \ + Update CloudRedirect or downgrade Steam.", + version, supported + )); + } + self.log(&format!(" Steam version: {} (OK)", version)); + + // 1. Core DLL. + let hijack = match self.find_core_dll() { + Some(n) => n, + None => { + return PatchOutcome::fail( + "SteamTools Core DLL not found. Is SteamTools installed?", + ) + } + }; + let dll_path = self.steam_path.join(hijack); + let dll_data = match std::fs::read(&dll_path) { + Ok(d) => d, + Err(_) => return PatchOutcome::fail(format!("{} is in use - close Steam first", hijack)), + }; + + self.log(&format!("Patching {}..", hijack)); + let resolved_core = match self.resolve_core_patch_offsets(&dll_data) { + Some(r) => r, + None => { + return PatchOutcome::fail(format!( + "Could not identify patch locations in {} - unsupported version?", + hijack + )) + } + }; + let (patched_dll, dll_applied, dll_skipped, dll_errors) = + apply_patches(&dll_data, &resolved_core); + if !dll_errors.is_empty() { + for e in &dll_errors { + self.log(e); + } + return PatchOutcome::fail(format!("Byte mismatch in {} - wrong version?", hijack)); + } + + // 2. Payload cache. + let mut log = |m: &str| println!("{}", m); + let cache_path = match crypto::find_cache_path(&self.steam_path, &mut log) { + Some(p) => p, + None => { + self.log("Payload cache not found. Deploying embedded payload.."); + match self.deploy_embedded_payload(version) { + Some(p) => p, + None => return PatchOutcome::fail("Could not deploy payload cache."), + } + } + }; + + self.log("Patching payload (offline setup).."); + let (payload, iv) = match self.read_and_decrypt_payload(&cache_path) { + Ok(v) => v, + Err(e) => return PatchOutcome::fail(e), + }; + + let resolved_setup = match self.resolve_setup_patch_offsets(&payload) { + Some(r) if !r.is_empty() => r, + _ => { + return PatchOutcome::fail( + "Could not identify activation patch locations in payload - unsupported version?", + ) + } + }; + let (patched_payload, pl_applied, pl_skipped, pl_errors) = + apply_patches(&payload, &resolved_setup); + if !pl_errors.is_empty() { + for e in &pl_errors { + self.log(e); + } + return PatchOutcome::fail("Byte mismatch in payload - wrong version?".to_string()); + } + + // 3. Backup both before either write. + self.backup_both(&cache_path, &dll_path); + + if pl_applied > 0 { + if let Err(e) = self.re_encrypt_and_write(&cache_path, &patched_payload, &iv) { + return PatchOutcome::fail(format!("Could not write payload cache: {}", e)); + } + self.log(&format!( + " {} patch(es) applied to payload{}", + pl_applied, + if pl_skipped > 0 { + format!(", {} already done", pl_skipped) + } else { + String::new() + } + )); + } else { + self.log(" Payload: already patched"); + } + + if dll_applied > 0 { + if let Err(e) = file_util::atomic_write_all_bytes(&dll_path, &patched_dll) { + return PatchOutcome::fail(format!("Could not write {}: {}", hijack, e)); + } + self.log(&format!( + " {} patch(es) applied to {}{}", + dll_applied, + hijack, + if dll_skipped > 0 { + format!(", {} already done", dll_skipped) + } else { + String::new() + } + )); + } else { + self.log(&format!(" {}: already patched", hijack)); + } + + self.log("Done."); + PatchOutcome::ok() + } + + // SteamTools.exe patch + + /// Returns 1 patched, 0 skipped (not found), -1 failed. + pub fn patch_steamtools_exe(&self) -> i32 { + let exe = match find_steamtools_exe() { + Some(e) => e, + None => { + self.log(" SteamTools.exe not found -- skipping"); + return 0; + } + }; + + kill_steamtools(|m| println!("{}", m)); + + let mut data = match std::fs::read(&exe) { + Ok(d) => d, + Err(e) => { + self.log(&format!(" SteamTools.exe: {}", e)); + return -1; + } + }; + if data.len() < STEXE_PATCH_OFFSET + 2 { + self.log(" SteamTools.exe too small - unrecognized version"); + return -1; + } + + if data[STEXE_PATCH_OFFSET] == STEXE_PATCHED[0] + && data[STEXE_PATCH_OFFSET + 1] == STEXE_PATCHED[1] + { + self.log(" SteamTools.exe: already patched"); + return 1; + } + if data[STEXE_PATCH_OFFSET] != STEXE_ORIGINAL[0] + || data[STEXE_PATCH_OFFSET + 1] != STEXE_ORIGINAL[1] + { + self.log(&format!( + " SteamTools.exe: unexpected bytes at patch site ({:02X} {:02X}) - unrecognized version", + data[STEXE_PATCH_OFFSET], data[STEXE_PATCH_OFFSET + 1] + )); + return -1; + } + + self.backup(&exe); + data[STEXE_PATCH_OFFSET] = STEXE_PATCHED[0]; + data[STEXE_PATCH_OFFSET + 1] = STEXE_PATCHED[1]; + if let Err(e) = file_util::atomic_write_all_bytes(&exe, &data) { + self.log(&format!(" SteamTools.exe: {}", e)); + return -1; + } + self.log(" SteamTools.exe: patched (DLL deploy disabled)"); + 1 + } + + // Internal helpers + + fn resolve_core_patch_offsets(&self, dll: &[u8]) -> Option<Vec<PatchEntry>> { + let sections = PeSection::parse(dll); + let rdata = match PeSection::find(§ions, ".rdata") { + Some(s) => s, + None => { + self.log(" Core.dll: no .rdata section found"); + return None; + } + }; + let mut key_offset = signatures::scan_for_bytes( + dll, + rdata.raw_offset as i64, + (rdata.raw_offset + rdata.raw_size) as i64, + &AES_KEY, + ); + if key_offset < 0 { + key_offset = signatures::scan_for_bytes(dll, 0, dll.len() as i64, &AES_KEY); + } + if key_offset < 0 { + self.log(" Core.dll: AES key not found - not a recognized SteamTools version"); + return None; + } + self.log(&format!(" AES key found at 0x{:X}", key_offset)); + + let text = match PeSection::find(§ions, ".text") { + Some(s) => s, + None => { + self.log(" Core.dll: no .text section found"); + return None; + } + }; + let t_start = text.raw_offset as i64; + let t_end = (t_start + text.raw_size as i64).min(dll.len() as i64); + + let mut log = |m: &str| println!("{}", m); + let defs = signatures::core_patch_defs(); + signatures::resolve_pattern_group(dll, &defs, t_start, t_end, 0, 0, &mut log) + } + + /// Resolve payload section bounds: .text and the first non-standard ("obf") section. + fn resolve_payload_sections(&self, payload: &[u8]) -> Option<(i64, i64, i64, i64)> { + let sections = PeSection::parse(payload); + let text = PeSection::find(§ions, ".text"); + + const KNOWN: [&str; 7] = [ + ".text", ".rdata", ".data", ".pdata", ".fptable", ".rsrc", ".reloc", + ]; + let obf = sections.iter().find(|s| !KNOWN.contains(&s.name.as_str())); + + let (text, obf) = match (text, obf) { + (Some(t), Some(o)) => (t, o), + _ => { + self.log(" Payload: missing expected sections"); + return None; + } + }; + + let t_start = text.raw_offset as i64; + let t_end = (t_start + text.raw_size as i64).min(payload.len() as i64); + let g_start = obf.raw_offset as i64; + let g_end = (g_start + obf.raw_size as i64).min(payload.len() as i64); + Some((t_start, t_end, g_start, g_end)) + } + + fn resolve_setup_patch_offsets(&self, payload: &[u8]) -> Option<Vec<PatchEntry>> { + let (t_start, t_end, g_start, g_end) = self.resolve_payload_sections(payload)?; + let mut log = |m: &str| println!("{}", m); + let defs = signatures::payload_setup_defs(); + signatures::resolve_pattern_group(payload, &defs, t_start, t_end, g_start, g_end, &mut log) + } + + /// Read + AES-decrypt + zlib-inflate the payload cache. Returns (payload, iv). + fn read_and_decrypt_payload(&self, cache_path: &Path) -> Result<(Vec<u8>, [u8; 16]), String> { + let raw = std::fs::read(cache_path) + .map_err(|_| "Payload cache is in use - close Steam first".to_string())?; + if raw.len() < 32 { + return Err("Cache file too small".to_string()); + } + let iv: [u8; 16] = raw[0..16].try_into().unwrap(); + let ct = &raw[16..]; + + self.log(" Decrypting.."); + let dec = crypto::aes_cbc_decrypt(ct, &AES_KEY, &iv) + .ok_or_else(|| "Decryption failed".to_string())?; + if dec.len() < 4 { + return Err("Decrypted payload too small".to_string()); + } + + let mut z = flate2::read::ZlibDecoder::new(&dec[4..]); + let mut payload = Vec::new(); + z.read_to_end(&mut payload) + .map_err(|e| format!("Decompression failed: {}", e))?; + + self.log(&format!(" Payload: {} bytes", payload.len())); + Ok((payload, iv)) + } + + /// zlib-compress, prepend 4-byte uncompressed length, AES-encrypt with the + /// ORIGINAL iv, prepend iv, atomic write. Reusing the iv matches SteamTools. + fn re_encrypt_and_write( + &self, + cache_path: &Path, + patched_payload: &[u8], + iv: &[u8; 16], + ) -> std::io::Result<()> { + self.log(" Re-encrypting.."); + let mut enc = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::best()); + enc.write_all(patched_payload)?; + let compressed = enc.finish()?; + + let mut blob = Vec::with_capacity(4 + compressed.len()); + blob.extend_from_slice(&(patched_payload.len() as u32).to_le_bytes()); + blob.extend_from_slice(&compressed); + + let new_ct = crypto::aes_cbc_encrypt(&blob, &AES_KEY, iv); + let mut output = Vec::with_capacity(16 + new_ct.len()); + output.extend_from_slice(iv); + output.extend_from_slice(&new_ct); + file_util::atomic_write_all_bytes(cache_path, &output) + } + + fn deploy_embedded_payload(&self, version: i64) -> Option<PathBuf> { + embedded::install_payload(&self.steam_path, version, |m| println!("{}", m)) + } + + fn backup(&self, path: &Path) { + let orig = file_util::with_extension_suffix(path, ".orig"); + if !orig.exists() { + if file_util::atomic_copy(path, &orig).is_ok() { + self.log(&format!(" Original saved to {}", orig.display())); + } + } + let bak = file_util::with_extension_suffix(path, ".bak"); + if file_util::atomic_copy(path, &bak).is_ok() { + self.log(&format!(" Backed up to {}", bak.display())); + } + } + + fn backup_both(&self, first: &Path, second: &Path) { + self.backup(first); + self.backup(second); + } +} + +// Patch application (free functions) + +fn bytes_match(data: &[u8], data_offset: i64, pattern: &[u8]) -> bool { + if data_offset < 0 || data_offset as usize + pattern.len() > data.len() { + return false; + } + &data[data_offset as usize..data_offset as usize + pattern.len()] == pattern +} + +fn hex_dump(data: &[u8], offset: i64) -> String { + if offset < 0 || offset as usize >= data.len() { + return "(out of bounds)".to_string(); + } + let avail = (data.len() - offset as usize).min(16); + data[offset as usize..offset as usize + avail] + .iter() + .map(|b| format!("{:02X}", b)) + .collect::<Vec<_>>() + .join("-") +} + +/// Apply patches to a clone of `data`. Returns (patched, applied, skipped, errors). +fn apply_patches(data: &[u8], patches: &[PatchEntry]) -> (Vec<u8>, usize, usize, Vec<String>) { + let mut buf = data.to_vec(); + let mut applied = 0; + let mut skipped = 0; + let mut errors = Vec::new(); + + for p in patches { + if bytes_match(&buf, p.offset, &p.replacement) { + skipped += 1; + } else if bytes_match(&buf, p.offset, &p.original) { + let off = p.offset as usize; + buf[off..off + p.replacement.len()].copy_from_slice(&p.replacement); + applied += 1; + } else { + errors.push(format!( + " Mismatch at 0x{:X}: expected {}, got {}", + p.offset, + p.original + .iter() + .map(|b| format!("{:02X}", b)) + .collect::<Vec<_>>() + .join("-"), + hex_dump(&buf, p.offset) + )); + } + } + (buf, applied, skipped, errors) +} + +// SteamTools.exe location / process kill + +fn find_steamtools_exe() -> Option<PathBuf> { + use winreg::enums::*; + use winreg::RegKey; + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let key = hkcu.open_subkey(r"Software\Valve\Steamtools").ok()?; + let raw: String = key.get_value("SteamPath").ok()?; + let path = PathBuf::from(raw.replace('/', "\\")).join("SteamTools.exe"); + if path.is_file() { + Some(path) + } else { + None + } +} + +fn kill_steamtools(mut log: impl FnMut(&str)) { + // taskkill cleanly terminates SteamTools.exe so we can rewrite it. + let out = std::process::Command::new("tasklist") + .args(["/FI", "IMAGENAME eq SteamTools.exe", "/NH"]) + .output(); + let running = out + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .to_lowercase() + .contains("steamtools.exe") + }) + .unwrap_or(false); + if running { + log(" Killing SteamTools.exe..."); + let _ = std::process::Command::new("taskkill") + .args(["/F", "/IM", "SteamTools.exe"]) + .output(); + std::thread::sleep(std::time::Duration::from_millis(500)); + } +} + +// Utilities + +fn sha256_hex(data: &[u8]) -> String { + let digest = Sha256::digest(data); + digest.iter().map(|b| format!("{:02x}", b)).collect() +} + +fn supported_versions_str() -> String { + steam_detector::SUPPORTED_STEAM_VERSIONS + .iter() + .map(|v| v.to_string()) + .collect::<Vec<_>>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + + // Read-only: against live Steam, decrypt the payload and resolve the + // core and P4/P5/P6 setup patches. No files are written. + #[test] + fn resolve_setup_patches_live() { + let candidates = [r"C:\Games\Steam", r"C:\Program Files (x86)\Steam"]; + let steam = candidates + .iter() + .map(PathBuf::from) + .find(|p| p.is_dir()); + let Some(steam) = steam else { + eprintln!("Steam not found; skipping"); + return; + }; + let p = Patcher::new(steam.clone()); + + // Core DLL resolution. + if let Some(hijack) = p.find_core_dll() { + let dll = std::fs::read(steam.join(hijack)).unwrap(); + match p.resolve_core_patch_offsets(&dll) { + Some(entries) => { + eprintln!("Core patches ({}): {} resolved", hijack, entries.len()) + } + None => eprintln!("Core patches: NOT resolved (unexpected)"), + } + } else { + eprintln!("No SteamTools core DLL present; skipping core resolution"); + } + + // Payload resolution. + let mut log = |m: &str| eprintln!("{}", m); + let Some(cache) = crypto::find_cache_path(&steam, &mut log) else { + eprintln!("No payload cache; skipping payload resolution"); + return; + }; + let (payload, _iv) = p.read_and_decrypt_payload(&cache).expect("decrypt payload"); + eprintln!("decrypted payload: {} bytes", payload.len()); + + match p.resolve_setup_patch_offsets(&payload) { + Some(entries) => { + eprintln!("Setup patches (P4/P5/P6): {} resolved", entries.len()); + for e in &entries { + eprintln!(" @0x{:X} orig[{}] repl[{}]", e.offset, e.original.len(), e.replacement.len()); + } + assert!(!entries.is_empty(), "no setup patches resolved"); + } + None => panic!("setup patches did NOT resolve against live payload"), + } + } + + // A re-encrypted cache must decrypt and inflate to a payload whose P4/P5/P6 + // sites already hold the replacement bytes (re-apply reports all skipped). + #[test] + fn live_cache_decrypts_to_patched() { + let candidates = [r"C:\Games\Steam", r"C:\Program Files (x86)\Steam"]; + let Some(steam) = candidates + .iter() + .map(PathBuf::from) + .find(|p| p.is_dir()) + else { + eprintln!("Steam not found; skipping"); + return; + }; + let p = Patcher::new(steam.clone()); + let mut log = |_: &str| {}; + let Some(cache) = crypto::find_cache_path(&steam, &mut log) else { + eprintln!("No payload cache; skipping"); + return; + }; + // 1. The cache must pass our validator (decrypt + inflate + MZ). + assert!( + crypto::validate_payload_cache(&cache), + "live cache failed validate (decrypt/inflate/MZ)" + ); + + // 2. Decrypt + resolve + apply: if it was correctly patched, applying + // again yields 0 applied / all skipped (the replacement bytes are there). + let (payload, _iv) = p.read_and_decrypt_payload(&cache).unwrap(); + let entries = p + .resolve_setup_patch_offsets(&payload) + .expect("resolve setup patches"); + let (_buf, applied, skipped, errors) = apply_patches(&payload, &entries); + eprintln!( + "re-apply on live cache: applied={} skipped={} errors={}", + applied, skipped, errors.len() + ); + assert!(errors.is_empty(), "byte mismatch on re-apply: {:?}", errors); + assert_eq!(applied, 0, "expected all patches already applied"); + assert_eq!(skipped, entries.len(), "all patches should be present"); + } +} + +fn http_get(url: &str) -> Result<Vec<u8>, String> { + let resp = ureq::get(url) + .set("User-Agent", "Stella/1.0") + .timeout(std::time::Duration::from_secs(60)) + .call() + .map_err(|e| e.to_string())?; + let mut buf = Vec::new(); + resp.into_reader() + .read_to_end(&mut buf) + .map_err(|e| e.to_string())?; + Ok(buf) +} diff --git a/cli-rust/src/pe.rs b/cli-rust/src/pe.rs new file mode 100644 index 00000000..3ed03940 --- /dev/null +++ b/cli-rust/src/pe.rs @@ -0,0 +1,189 @@ +// PE section parsing and RVA/file-offset conversion. + +/// IMAGE_SCN_MEM_EXECUTE +const SCN_MEM_EXECUTE: u32 = 0x2000_0000; + +#[derive(Clone, Debug)] +pub struct PeSection { + pub name: String, + pub virtual_address: u32, + pub virtual_size: u32, + pub raw_offset: u32, + pub raw_size: u32, + pub characteristics: u32, +} + +impl PeSection { + pub fn is_executable(&self) -> bool { + self.characteristics & SCN_MEM_EXECUTE != 0 + } + + /// Parse the section table out of a PE image. Returns an empty vec on any + /// malformed/short input (mirrors the C# which returns Array.Empty). + pub fn parse(pe: &[u8]) -> Vec<PeSection> { + if pe.len() < 64 { + return Vec::new(); + } + let pe_off = read_i32(pe, 0x3C); + if pe_off < 0 || pe_off as usize + 24 > pe.len() { + return Vec::new(); + } + let pe_off = pe_off as usize; + if pe[pe_off] != b'P' || pe[pe_off + 1] != b'E' { + return Vec::new(); + } + + let num_sections = read_u16(pe, pe_off + 6) as usize; + if num_sections > 96 { + return Vec::new(); + } + let opt_size = read_u16(pe, pe_off + 20) as usize; + let first_section = pe_off + 24 + opt_size; + if first_section > pe.len() { + return Vec::new(); + } + + let mut result = Vec::with_capacity(num_sections); + for i in 0..num_sections { + let off = first_section + i * 40; + if off + 40 > pe.len() { + break; + } + // Section name: up to 8 bytes, NUL-terminated, ASCII. + let mut name_end = 0usize; + for j in 0..8 { + if pe[off + j] == 0 { + break; + } + name_end = j + 1; + } + let name = String::from_utf8_lossy(&pe[off..off + name_end]).into_owned(); + + result.push(PeSection { + name, + virtual_size: read_u32(pe, off + 8), + virtual_address: read_u32(pe, off + 12), + raw_size: read_u32(pe, off + 16), + raw_offset: read_u32(pe, off + 20), + characteristics: read_u32(pe, off + 36), + }); + } + result + } + + pub fn find<'a>(sections: &'a [PeSection], name: &str) -> Option<&'a PeSection> { + sections.iter().find(|s| s.name == name) + } + + /// File offset -> RVA, returns -1 if outside any section. + pub fn file_offset_to_rva(sections: &[PeSection], file_offset: i64) -> i64 { + if file_offset < 0 { + return -1; + } + let fo = file_offset as u32; + for s in sections { + if fo >= s.raw_offset && fo - s.raw_offset < s.raw_size { + return (s.virtual_address + (fo - s.raw_offset)) as i64; + } + } + -1 + } + + /// RVA -> file offset, returns -1 if in BSS or outside any section. + pub fn rva_to_file_offset(sections: &[PeSection], rva: i64) -> i64 { + if rva < 0 { + return -1; + } + let r = rva as u32; + for s in sections { + let size = s.virtual_size.max(s.raw_size); + if r >= s.virtual_address && r - s.virtual_address < size { + let offset_in_section = r - s.virtual_address; + if offset_in_section >= s.raw_size { + return -1; // BSS / zero-fill, no file backing + } + return (s.raw_offset + offset_in_section) as i64; + } + } + -1 + } + + pub fn find_by_file_offset(sections: &[PeSection], file_offset: i64) -> Option<&PeSection> { + if file_offset < 0 { + return None; + } + let fo = file_offset as u32; + sections + .iter() + .find(|s| fo >= s.raw_offset && fo < s.raw_offset + s.raw_size) + } + + /// Find the section containing an RVA (using VirtualAddress + VirtualSize). + /// Unlike rva_to_file_offset, succeeds for BSS regions with no file backing. + pub fn find_by_rva(sections: &[PeSection], rva: i64) -> Option<&PeSection> { + if rva < 0 { + return None; + } + let r = rva as u32; + sections.iter().find(|s| { + let size = s.virtual_size.max(s.raw_size); + r >= s.virtual_address && r - s.virtual_address < size + }) + } +} + +#[inline] +fn read_u16(b: &[u8], o: usize) -> u16 { + u16::from_le_bytes([b[o], b[o + 1]]) +} +#[inline] +fn read_u32(b: &[u8], o: usize) -> u32 { + u32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} +#[inline] +fn read_i32(b: &[u8], o: usize) -> i32 { + i32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Parse the real steamclient64.dll (if present) and sanity-check the section + // table: must contain .text, the section table must be ordered, and RVA<->file + // round-trips inside .text must be consistent. + #[test] + fn parses_steamclient() { + let candidates = [ + r"C:\Games\Steam\steamclient64.dll", + r"C:\Program Files (x86)\Steam\steamclient64.dll", + ]; + let path = candidates.iter().find(|p| std::path::Path::new(p).is_file()); + let Some(path) = path else { + eprintln!("steamclient64.dll not found; skipping parity test"); + return; + }; + let data = std::fs::read(path).expect("read dll"); + let sections = PeSection::parse(&data); + assert!(!sections.is_empty(), "no sections parsed"); + + let text = PeSection::find(§ions, ".text").expect(".text section present"); + assert!(text.is_executable(), ".text must be executable"); + assert!(text.raw_size > 0); + + // round-trip a file offset in the middle of .text + let fo = (text.raw_offset + text.raw_size / 2) as i64; + let rva = PeSection::file_offset_to_rva(§ions, fo); + assert!(rva > 0); + let back = PeSection::rva_to_file_offset(§ions, rva); + assert_eq!(back, fo, "RVA<->file offset round-trip mismatch"); + + eprintln!("parsed {} sections from {}", sections.len(), path); + for s in §ions { + eprintln!( + " {:8} VA={:#010x} VSize={:#x} Raw={:#x} RawSize={:#x} exec={}", + s.name, s.virtual_address, s.virtual_size, s.raw_offset, s.raw_size, s.is_executable() + ); + } + } +} diff --git a/cli-rust/src/signatures.rs b/cli-rust/src/signatures.rs new file mode 100644 index 00000000..0a9c5dae --- /dev/null +++ b/cli-rust/src/signatures.rs @@ -0,0 +1,722 @@ +// SteamTools binary-patch signatures and the scan/resolve engine. +// Patterns, masks, offsets, validators and patch-site resolvers are exact; do not alter. + +use crate::pe::PeSection; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ScanRegion { + Text, + Obfuscated, + All, +} + +/// Resolved patch: where to write, the expected original bytes, the replacement. +#[derive(Clone)] +pub struct PatchEntry { + pub offset: i64, + pub original: Vec<u8>, + pub replacement: Vec<u8>, +} + +type Validator = fn(&[u8], usize) -> bool; +type PatchSiteResolver = fn(&[u8], usize) -> i64; + +/// Declarative patch definition: pattern + mask to locate the site, plus optional +/// validator / patch-site resolver and relative-scan window. +pub struct PatternPatch { + pub name: &'static str, + pub pattern: &'static [u8], + pub mask: &'static [u8], + pub patch_offset: i64, + pub original: &'static [u8], + pub replacement: &'static [u8], + pub region: ScanRegion, + pub wildcard_start: usize, + pub wildcard_len: usize, + pub validator: Option<Validator>, + pub patch_site_resolver: Option<PatchSiteResolver>, + pub relative_to_patch_index: Option<usize>, + pub relative_start: i64, + pub relative_end: i64, +} + +impl PatternPatch { + const fn new( + name: &'static str, + pattern: &'static [u8], + mask: &'static [u8], + patch_offset: i64, + original: &'static [u8], + replacement: &'static [u8], + region: ScanRegion, + ) -> Self { + PatternPatch { + name, + pattern, + mask, + patch_offset, + original, + replacement, + region, + wildcard_start: 0, + wildcard_len: 0, + validator: None, + patch_site_resolver: None, + relative_to_patch_index: None, + relative_start: 0, + relative_end: 0, + } + } +} + +#[inline] +fn read_i32(b: &[u8], o: usize) -> i32 { + i32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) +} + +/// First offset in [start, end) where `pattern` matches under `mask` (0 = wildcard). +pub fn scan_for_pattern(data: &[u8], start: i64, end: i64, pattern: &[u8], mask: &[u8]) -> i64 { + let limit = (end.min(data.len() as i64)) - pattern.len() as i64; + let mut i = start; + while i <= limit { + let base = i as usize; + let mut matched = true; + for j in 0..pattern.len() { + if mask[j] != 0 && data[base + j] != pattern[j] { + matched = false; + break; + } + } + if matched { + return i; + } + i += 1; + } + -1 +} + +pub fn scan_for_bytes(data: &[u8], start: i64, end: i64, needle: &[u8]) -> i64 { + let limit = (end.min(data.len() as i64)) - needle.len() as i64; + let mut i = start; + while i <= limit { + let base = i as usize; + if &data[base..base + needle.len()] == needle { + return i; + } + i += 1; + } + -1 +} + +/// Resolve a single PatternPatch to a file offset, or -1. +pub fn resolve_pattern_patch( + data: &[u8], + patch: &PatternPatch, + section_start: i64, + section_end: i64, + resolved_offsets: Option<&[i64]>, +) -> i64 { + let mut scan_start = section_start; + let mut scan_end = section_end; + + if let (Some(idx), Some(offsets)) = (patch.relative_to_patch_index, resolved_offsets) { + if idx < offsets.len() && offsets[idx] >= 0 { + scan_start = section_start.max(offsets[idx] + patch.relative_start); + scan_end = (offsets[idx] + patch.relative_end).min(section_end); + } + } + + let mut pos = scan_start; + while pos < scan_end { + let hit = scan_for_pattern(data, pos, scan_end, patch.pattern, patch.mask); + if hit < 0 { + break; + } + let hit_usize = hit as usize; + + if let Some(validate) = patch.validator { + if !validate(data, hit_usize) { + pos = hit + 1; + continue; + } + } + + if let Some(resolve) = patch.patch_site_resolver { + let resolved = resolve(data, hit_usize); + if resolved >= 0 { + return resolved; + } + pos = hit + 1; + continue; + } + + return hit + patch.patch_offset; + } + -1 +} + +/// Resolve an ordered group of patches; later patches may reference earlier +/// resolved offsets via relative_to_patch_index. Returns None if any required +/// patch fails to resolve. +pub fn resolve_pattern_group( + data: &[u8], + patches: &[PatternPatch], + text_start: i64, + text_end: i64, + obf_start: i64, + obf_end: i64, + log: &mut dyn FnMut(&str), +) -> Option<Vec<PatchEntry>> { + let mut result: Vec<PatchEntry> = Vec::with_capacity(patches.len()); + let mut offsets: Vec<i64> = vec![-1; patches.len()]; + + for (i, p) in patches.iter().enumerate() { + let (s_start, s_end) = match p.region { + ScanRegion::Text => (text_start, text_end), + ScanRegion::Obfuscated => (obf_start, obf_end), + ScanRegion::All => (0, data.len() as i64), + }; + + let offset = resolve_pattern_patch(data, p, s_start, s_end, Some(&offsets)); + if offset < 0 { + log(&format!(" Could not locate {}", p.name)); + return None; + } + offsets[i] = offset; + result.push(snapshot_patch(data, offset, p)); + log(&format!(" {} at 0x{:X}", p.name, offset)); + } + Some(result) +} + +/// Build a PatchEntry by cloning the template original/replacement, then snapshot +/// any wildcard bytes from the actual data at the resolved offset. +fn snapshot_patch(data: &[u8], offset: i64, template: &PatternPatch) -> PatchEntry { + let mut orig = template.original.to_vec(); + let mut repl = template.replacement.to_vec(); + let len = orig.len(); + + if template.wildcard_len > 0 + && template.wildcard_start + template.wildcard_len <= len + && offset as usize + template.wildcard_start + template.wildcard_len <= data.len() + { + let src = offset as usize + template.wildcard_start; + let slice = &data[src..src + template.wildcard_len]; + orig[template.wildcard_start..template.wildcard_start + template.wildcard_len] + .copy_from_slice(slice); + repl[template.wildcard_start..template.wildcard_start + template.wildcard_len] + .copy_from_slice(slice); + } + + PatchEntry { + offset, + original: orig, + replacement: repl, + } +} + +// Validators / resolvers (transcribed from the C# closures) + +fn core1_validator(data: &[u8], hit: usize) -> bool { + let opcode = data[hit + 9]; + if opcode == 0xE8 { + read_i32(data, hit + 10) < 0 + } else { + opcode == 0xB8 + } +} + +fn core2_validator(data: &[u8], hit: usize) -> bool { + let b = data[hit + 14]; + b == 0x74 || b == 0xEB +} + +fn p1_validator(data: &[u8], hit: usize) -> bool { + (data[hit + 18] == 0x0F && data[hit + 19] == 0x84) + || (data[hit + 18] == 0x90 && data[hit + 19] == 0xE9) +} + +fn p2_validator(data: &[u8], hit: usize) -> bool { + (data[hit + 22] == 0x8B && data[hit + 23] == 0x0D) + || (data[hit + 22] == 0x31 && data[hit + 23] == 0xC9) +} + +fn p3_resolver(data: &[u8], hit: usize) -> i64 { + let search_start = hit + 7; + let search_end = (search_start + 30).min(data.len()); + let mut i = search_start; + while i + 5 < search_end { + if data[i] == 0x89 && data[i + 1] == 0x3D { + return i as i64; + } + if data[i] == 0x90 + && data[i + 1] == 0x90 + && data[i + 2] == 0x90 + && data[i + 3] == 0x90 + && data[i + 4] == 0x90 + && data[i + 5] == 0x90 + { + return i as i64; + } + i += 1; + } + -1 +} + +fn has_bytes(bytes: &[u8], pos: i64, expected: &[u8]) -> bool { + if pos < 0 || pos as usize + expected.len() > bytes.len() { + return false; + } + &bytes[pos as usize..pos as usize + expected.len()] == expected +} + +fn skip_optional_bridge(bytes: &[u8], pos: i64) -> i64 { + if has_bytes(bytes, pos, &[0xE9]) { + pos + 5 + } else { + pos + } +} + +fn p4_resolver(data: &[u8], hit: usize) -> i64 { + let mut pos = hit as i64 + 3; + pos = skip_optional_bridge(data, pos); + if !has_bytes(data, pos, &[0x0F, 0x84]) { + return -1; + } + pos += 6; + + if !has_bytes(data, pos, &[0xE8]) { + return -1; + } + pos += 5; + + if !has_bytes(data, pos, &[0x85, 0xC0]) { + return -1; + } + pos += 2; + + pos = skip_optional_bridge(data, pos); + if !has_bytes(data, pos, &[0x0F, 0x85]) { + return -1; + } + pos += 6; + + if !has_bytes(data, pos, &[0xC6, 0x05]) { + return -1; + } + if pos as usize + 6 >= data.len() || data[pos as usize + 6] != 0x01 { + return -1; + } + pos += 7; + + pos = skip_optional_bridge(data, pos); + if !has_bytes(data, pos, &[0xE9]) { + return -1; + } + pos += 5; + + if !has_bytes(data, pos, &[0xC6, 0x05]) { + return -1; + } + if pos as usize + 6 >= data.len() { + return -1; + } + let val = data[pos as usize + 6]; + if val == 0x00 || val == 0x01 { + pos + } else { + -1 + } +} + +fn p5_validator(data: &[u8], hit: usize) -> bool { + if hit + 24 > data.len() { + return false; + } + let opcode = data[hit + 22]; + if opcode != 0x75 && opcode != 0xEB { + return false; + } + let skip_dist = data[hit + 23] as i8 as i64; + if skip_dist <= 0 { + return false; + } + let after_skip = hit as i64 + 24 + skip_dist; + if after_skip > data.len() as i64 { + return false; + } + let mut j = hit + 24; + while (j as i64) < after_skip && j < data.len() - 4 { + if data[j] == 0xE9 { + let rel = read_i32(data, j + 1); + if rel < 0 { + return true; + } + } + j += 1; + } + false +} + +fn p6_validator(data: &[u8], hit: usize) -> bool { + if hit + 48 > data.len() { + return false; + } + let is_old = data[hit + 18] == 0x35 + && data[hit + 19] == 0x36 + && data[hit + 44] == 0x5E + && data[hit + 45] == 0x00; + let is_new = data[hit + 18] == 0x35 + && data[hit + 19] == 0x37 + && data[hit + 46] == 0x43 + && data[hit + 47] == 0x00; + is_old || is_new +} + +// Core DLL patches (xinput1_4.dll / dwmapi.dll) + +pub fn core_patch_defs() -> Vec<PatternPatch> { + vec![ + // Core1: NOP download call (E8 -> B8). + PatternPatch { + wildcard_start: 1, + wildcard_len: 4, + validator: Some(core1_validator), + ..PatternPatch::new( + "Core1 (download call)", + &[ + 0x48, 0x8B, 0x4C, 0x24, 0x00, 0x48, 0x8D, 0x55, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x85, 0xC0, 0x0F, 0x84, 0x00, 0x00, 0x00, 0x00, 0x41, 0x83, 0xFC, 0x01, + ], + &[ + 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, + ], + 9, + &[0xE8, 0x7C, 0xF5, 0xFF, 0xFF], + &[0xB8, 0x01, 0x00, 0x00, 0x00], + ScanRegion::Text, + ) + }, + // Core2: jz -> jmp (hash check bypass), relative to Core1. + PatternPatch { + relative_to_patch_index: Some(0), + relative_start: -0x300, + relative_end: 0x300, + validator: Some(core2_validator), + ..PatternPatch::new( + "Core2 (hash check jz)", + &[ + 0x49, 0x8B, 0xD5, 0x48, 0x8D, 0x4D, 0x00, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x85, + 0xC0, 0x00, 0x00, 0x33, 0xFF, 0xE9, + ], + &[ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, + 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, + ], + 14, + &[0x74], + &[0xEB], + ScanRegion::Text, + ) + }, + ] +} + +// Payload patches P1-P3 (cloud redirect disable) + +pub fn payload_p123_defs() -> Vec<PatternPatch> { + vec![ + // P1: cloud rewrite jz -> nop jmp. + PatternPatch { + wildcard_start: 2, + wildcard_len: 4, + validator: Some(p1_validator), + ..PatternPatch::new( + "P1 (cloud rewrite skip)", + &[ + 0x44, 0x8B, 0x3D, 0x00, 0x00, 0x00, 0x00, 0x85, 0xC0, 0x0F, 0x85, 0x00, 0x00, + 0x00, 0x00, 0x45, 0x85, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ], + &[ + 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, + ], + 18, + &[0x0F, 0x84, 0x3B, 0x01, 0x00, 0x00], + &[0x90, 0xE9, 0x3B, 0x01, 0x00, 0x00], + ScanRegion::Text, + ) + }, + // P2: zero proxy appid load, relative to P1. + PatternPatch { + relative_to_patch_index: Some(0), + relative_start: 0, + relative_end: 0x500, + validator: Some(p2_validator), + ..PatternPatch::new( + "P2 (proxy appid zero)", + &[ + 0x48, 0x8B, 0xF0, 0x4C, 0x8B, 0xC7, 0x4C, 0x8B, 0x7C, 0x24, 0x00, 0x49, 0x8B, + 0xD7, 0x48, 0x8B, 0xC8, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x48, 0x8D, 0x14, 0x3E, 0x48, 0x81, 0xF9, 0x80, 0x00, 0x00, 0x00, + ], + &[ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ], + 22, + &[0x8B, 0x0D, 0x7D, 0xCA, 0x1B, 0x00], + &[0x31, 0xC9, 0x90, 0x90, 0x90, 0x90], + ScanRegion::Text, + ) + }, + // P3: NOP IPC appid preserve. + PatternPatch { + wildcard_start: 2, + wildcard_len: 4, + patch_site_resolver: Some(p3_resolver), + ..PatternPatch::new( + "P3 (IPC appid preserve)", + &[0xC7, 0x40, 0x09, 0xE0, 0x01, 0x00, 0x00], + &[0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], + 0, + &[0x89, 0x3D, 0x00, 0x00, 0x00, 0x00], + &[0x90, 0x90, 0x90, 0x90, 0x90, 0x90], + ScanRegion::Obfuscated, + ) + }, + ] +} + +// Payload setup patches P4/P5/P6 + +pub fn payload_setup_defs() -> Vec<PatternPatch> { + vec![ + // P4: force activation flag to 1. + PatternPatch { + wildcard_start: 2, + wildcard_len: 4, + patch_site_resolver: Some(p4_resolver), + ..PatternPatch::new( + "P4 (activation flag)", + &[0x4D, 0x85, 0xC0], + &[0xFF, 0xFF, 0xFF], + 0, + &[0xC6, 0x05, 0x00, 0x00, 0x00, 0x00, 0x00], + &[0xC6, 0x05, 0x00, 0x00, 0x00, 0x00, 0x01], + ScanRegion::Obfuscated, + ) + }, + // P5: skip GetCookie retry. + PatternPatch { + validator: Some(p5_validator), + ..PatternPatch::new( + "P5 (GetCookie retry skip)", + &[ + 0x66, 0x48, 0x0F, 0x7E, 0xC7, 0x66, 0x48, 0x0F, 0x7E, 0xCE, 0x48, 0x8D, 0x4D, + 0x00, 0xE8, 0x00, 0x00, 0x00, 0x00, 0x48, 0x85, 0xF6, 0x00, + ], + &[ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0x00, 0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00, + ], + 22, + &[0x75], + &[0xEB], + ScanRegion::Text, + ) + }, + // P6: fix GMRC pattern string (May 27 2026 Steam update). + PatternPatch { + validator: Some(p6_validator), + ..PatternPatch::new( + "P6 (GMRC pattern fix)", + &[ + 0x34, 0x38, 0x20, 0x38, 0x39, 0x20, 0x35, 0x43, 0x20, 0x32, 0x34, 0x20, 0x31, + 0x38, 0x20, 0x35, 0x35, 0x20, + ], + &[ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + ], + 0, + &[ + 0x34, 0x38, 0x20, 0x38, 0x39, 0x20, 0x35, 0x43, 0x20, 0x32, 0x34, 0x20, 0x31, + 0x38, 0x20, 0x35, 0x35, 0x20, 0x35, 0x36, 0x20, 0x35, 0x37, 0x20, 0x34, 0x31, + 0x20, 0x35, 0x35, 0x20, 0x34, 0x31, 0x20, 0x35, 0x37, 0x20, 0x34, 0x38, 0x20, + 0x38, 0x44, 0x20, 0x36, 0x43, 0x5E, 0x00, 0x00, 0x00, + ], + &[ + 0x34, 0x38, 0x20, 0x38, 0x39, 0x20, 0x35, 0x43, 0x20, 0x32, 0x34, 0x20, 0x31, + 0x38, 0x20, 0x35, 0x35, 0x20, 0x35, 0x37, 0x20, 0x34, 0x31, 0x20, 0x35, 0x34, + 0x20, 0x34, 0x31, 0x20, 0x35, 0x36, 0x20, 0x34, 0x31, 0x20, 0x35, 0x37, 0x20, + 0x34, 0x38, 0x20, 0x38, 0x44, 0x20, 0x36, 0x43, 0x00, + ], + ScanRegion::All, + ) + }, + ] +} + +// CloudRedirect hook finders + +/// Locate SendPkt via its alloca_probe setup and walk back to the prologue. -1 if absent. +pub fn find_send_pkt_function(data: &[u8], text_start: i64, text_end: i64) -> i64 { + let needle = [0xB8, 0x00, 0x11, 0x00, 0x00, 0xE8]; + let mut pos = text_start; + while pos < text_end { + let hit = scan_for_bytes(data, pos, text_end, &needle); + if hit < 0 { + break; + } + let func_start = hit - 0x18; + if func_start < text_start { + pos = hit + 1; + continue; + } + let fs = func_start as usize; + if (data[fs] == 0x48 + && data[fs + 1] == 0x89 + && data[fs + 2] == 0x5C + && data[fs + 3] == 0x24 + && data[fs + 4] == 0x20) + || data[fs] == 0xE9 + { + return func_start; + } + pos = hit + 1; + } + -1 +} + +/// Find a zero-filled code cave at the tail of an executable PE section, or -1. +pub fn find_code_cave(data: &[u8], sections: &[PeSection], required_size: i64) -> i64 { + for sec in sections { + if !sec.is_executable() { + continue; + } + let mut raw_end = (sec.raw_offset + sec.raw_size) as i64; + if raw_end > data.len() as i64 { + raw_end = data.len() as i64; + } + let raw_start = sec.raw_offset as i64; + + let mut zero_run: i64 = 0; + let mut i = raw_end - 1; + while i >= raw_start { + if data[i as usize] == 0 { + zero_run += 1; + } else { + break; + } + i -= 1; + } + if zero_run >= required_size { + return raw_end - zero_run; + } + } + -1 +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pe::PeSection; + + // Resolve the Core DLL patches against the live SteamTools core DLL. + #[test] + fn resolve_core_patches_live() { + let candidates = [ + r"C:\Games\Steam\xinput1_4.dll", + r"C:\Games\Steam\dwmapi.dll", + r"C:\Program Files (x86)\Steam\xinput1_4.dll", + r"C:\Program Files (x86)\Steam\dwmapi.dll", + ]; + let path = candidates.iter().find(|p| std::path::Path::new(p).is_file()); + let Some(path) = path else { + eprintln!("No SteamTools core DLL found; skipping"); + return; + }; + let dll = std::fs::read(path).expect("read dll"); + let sections = PeSection::parse(&dll); + let text = match PeSection::find(§ions, ".text") { + Some(t) => t, + None => { + eprintln!("{}: no .text; skipping (not a SteamTools core DLL?)", path); + return; + } + }; + let t_start = text.raw_offset as i64; + let t_end = (t_start + text.raw_size as i64).min(dll.len() as i64); + + let mut log = |m: &str| eprintln!("{}", m); + let defs = core_patch_defs(); + let result = resolve_pattern_group(&dll, &defs, t_start, t_end, 0, 0, &mut log); + match result { + Some(entries) => { + eprintln!("Core patches resolved in {}:", path); + for e in &entries { + eprintln!( + " @0x{:X} orig={:02X?} repl={:02X?}", + e.offset, e.original, e.replacement + ); + } + } + None => eprintln!( + "Core patches did NOT resolve in {} (may be a different SteamTools version)", + path + ), + } + } +} + +/// Locate recvPktGlobal: find `lea rcx, SendPkt`, then the following +/// `mov cs:qword, rcx` that stores RecvPkt. Returns -1 on miss. +pub fn find_recv_pkt_global_rva( + data: &[u8], + sections: &[PeSection], + send_pkt_rva: i64, + search_start: i64, + search_end: i64, +) -> i64 { + let mut i = search_start; + while i < search_end - 7 { + let iu = i as usize; + if data[iu] != 0x48 || data[iu + 1] != 0x8D || data[iu + 2] != 0x0D { + i += 1; + continue; + } + let rel = read_i32(data, iu + 3) as i64; + let instr_rva = PeSection::file_offset_to_rva(sections, i); + if instr_rva < 0 { + i += 1; + continue; + } + let target_rva = instr_rva + 7 + rel; + if target_rva != send_pkt_rva { + i += 1; + continue; + } + // Forward-scan for `mov cs:qword, rcx` (48 89 0D). + let mut j = i + 7; + let jend = (i + 0x100).min(search_end) - 7; + while j < jend { + let ju = j as usize; + if data[ju] == 0x48 && data[ju + 1] == 0x89 && data[ju + 2] == 0x0D { + let mov_rel = read_i32(data, ju + 3) as i64; + let mov_rva = PeSection::file_offset_to_rva(sections, j); + if mov_rva < 0 { + j += 1; + continue; + } + return mov_rva + 7 + mov_rel; + } + j += 1; + } + i += 1; + } + -1 +} diff --git a/cli-rust/src/steam_detector.rs b/cli-rust/src/steam_detector.rs new file mode 100644 index 00000000..16fe518c --- /dev/null +++ b/cli-rust/src/steam_detector.rs @@ -0,0 +1,106 @@ +// Steam installation detection and version checks. + +use std::path::{Path, PathBuf}; + +pub const SUPPORTED_STEAM_VERSIONS: [i64; 6] = + [1781041600, 1780352834, 1779918128, 1779486452, 1778281814, 1778003620]; + +pub fn is_supported_steam_version(version: i64) -> bool { + SUPPORTED_STEAM_VERSIONS.contains(&version) +} + +/// Locate the Steam install directory via the registry, then common paths. +pub fn find_steam_path() -> Option<PathBuf> { + try_registry().or_else(try_known_paths) +} + +fn dir_exists(p: &str) -> bool { + !p.is_empty() && Path::new(p).is_dir() +} + +fn try_registry() -> Option<PathBuf> { + use winreg::enums::*; + use winreg::RegKey; + + let hklm = RegKey::predef(HKEY_LOCAL_MACHINE); + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + + // HKLM\SOFTWARE\Wow6432Node\Valve\Steam : InstallPath + if let Ok(key) = hklm.open_subkey(r"SOFTWARE\Wow6432Node\Valve\Steam") { + if let Ok(path) = key.get_value::<String, _>("InstallPath") { + if dir_exists(&path) { + return Some(PathBuf::from(path)); + } + } + } + // HKLM\SOFTWARE\Valve\Steam : InstallPath + if let Ok(key) = hklm.open_subkey(r"SOFTWARE\Valve\Steam") { + if let Ok(path) = key.get_value::<String, _>("InstallPath") { + if dir_exists(&path) { + return Some(PathBuf::from(path)); + } + } + } + // HKCU\SOFTWARE\Valve\Steam : SteamPath + if let Ok(key) = hkcu.open_subkey(r"SOFTWARE\Valve\Steam") { + if let Ok(path) = key.get_value::<String, _>("SteamPath") { + if dir_exists(&path) { + return Some(PathBuf::from(path)); + } + } + } + None +} + +fn try_known_paths() -> Option<PathBuf> { + let mut candidates: Vec<PathBuf> = vec![ + PathBuf::from(r"C:\Games\Steam"), + PathBuf::from(r"C:\Program Files (x86)\Steam"), + PathBuf::from(r"C:\Program Files\Steam"), + PathBuf::from(r"D:\Steam"), + PathBuf::from(r"D:\Games\Steam"), + ]; + if let Ok(pf86) = std::env::var("ProgramFiles(x86)") { + candidates.push(PathBuf::from(pf86).join("Steam")); + } + candidates + .into_iter() + .find(|p| p.is_dir() && p.join("steam.exe").is_file()) +} + +/// True if any process named "steam.exe" is running. Uses `tasklist` to avoid +/// pulling in a process-enumeration dependency. +pub fn is_steam_running() -> bool { + use std::process::Command; + let out = Command::new("tasklist") + .args(["/FI", "IMAGENAME eq steam.exe", "/NH"]) + .output(); + match out { + Ok(o) => String::from_utf8_lossy(&o.stdout) + .to_lowercase() + .contains("steam.exe"), + Err(_) => false, + } +} + +/// Read the installed Steam client version from the package manifest. +pub fn get_steam_version(steam_path: &Path) -> Option<i64> { + let manifest = steam_path + .join("package") + .join("steam_client_win64.manifest"); + let text = std::fs::read_to_string(manifest).ok()?; + for line in text.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with("\"version\"") { + continue; + } + // value is the last quoted token on the line + let last = trimmed.rfind('"')?; + let second_last = trimmed[..last].rfind('"')?; + let val = &trimmed[second_last + 1..last]; + if let Ok(v) = val.parse::<i64>() { + return Some(v); + } + } + None +} diff --git a/flatpak/org.cloudredirect.CloudRedirect.metainfo.xml b/flatpak/org.cloudredirect.CloudRedirect.metainfo.xml index ab1e721f..8d7254f6 100644 --- a/flatpak/org.cloudredirect.CloudRedirect.metainfo.xml +++ b/flatpak/org.cloudredirect.CloudRedirect.metainfo.xml @@ -39,6 +39,9 @@ <releases> <release version="2.2.0" date="2026-06-16" /> + <release version="2.1.8" date="2026-06-11" /> + <release version="2.1.7" date="2026-06-07" /> + <release version="2.1.6" date="2026-06-06" /> <release version="2.1.5" date="2026-06-04" /> <release version="2.1.2" date="2026-06-02" /> <release version="2.0.4" date="2026-05-15" /> diff --git a/src/common/autocloud_scan.h b/src/common/autocloud_scan.h index c51a20b5..a6cf29a2 100644 --- a/src/common/autocloud_scan.h +++ b/src/common/autocloud_scan.h @@ -20,6 +20,7 @@ struct FileEntry { std::vector<uint8_t> sha; // SHA1 hash (20 bytes) std::string rootToken; // Cloud root token (e.g., "%WinAppDataLocal%") uint32_t rootId = 0; // Steam ERemoteStorageFileRoot enum value + std::vector<uint8_t> content; // hashed bytes, retained to avoid a re-read at commit }; struct ScanResult { diff --git a/src/common/stats_store.cpp b/src/common/stats_store.cpp index 30ea5f5c..dcd49f0d 100644 --- a/src/common/stats_store.cpp +++ b/src/common/stats_store.cpp @@ -1935,6 +1935,9 @@ const std::vector<uint8_t>& GetSchema(uint32_t appId) { return GetOrCreateLocked(appId).schema; } +// Forward decl: defined below, called from StartSession. +static bool EndSessionLocked(uint32_t appId); + void StartSession(uint32_t appId) { std::lock_guard<std::mutex> lock(g_mutex); // Flush any open session first: native Steam resumes the existing per-app diff --git a/ui/CloudRedirect.csproj b/ui/CloudRedirect.csproj index 86e42b3a..3866f790 100644 --- a/ui/CloudRedirect.csproj +++ b/ui/CloudRedirect.csproj @@ -42,14 +42,20 @@ <NativeDllDest>$(MSBuildProjectDirectory)\Resources\cloud_redirect.dll</NativeDllDest> <NativeCliSource>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\build\Release\cloud_redirect_cli.exe'))</NativeCliSource> <NativeCliDest>$(MSBuildProjectDirectory)\Resources\cloud_redirect_cli.exe</NativeCliDest> + <Native760Source>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)\..\build\Release\cloud760_tool.exe'))</Native760Source> + <Native760Dest>$(MSBuildProjectDirectory)\native\cloud760_tool.exe</Native760Dest> </PropertyGroup> <MakeDir Directories="$(MSBuildProjectDirectory)\Resources" Condition="!Exists('$(MSBuildProjectDirectory)\Resources')" /> + <MakeDir Directories="$(MSBuildProjectDirectory)\native" Condition="!Exists('$(MSBuildProjectDirectory)\native')" /> <Copy SourceFiles="$(NativeDllSource)" DestinationFiles="$(NativeDllDest)" Condition="Exists('$(NativeDllSource)')" SkipUnchangedFiles="true" /> <Copy SourceFiles="$(NativeCliSource)" DestinationFiles="$(NativeCliDest)" Condition="Exists('$(NativeCliSource)')" SkipUnchangedFiles="true" /> + <Copy SourceFiles="$(Native760Source)" DestinationFiles="$(Native760Dest)" + Condition="Exists('$(Native760Source)')" + SkipUnchangedFiles="true" /> <ItemGroup Condition="Exists('$(NativeDllDest)')"> <EmbeddedResource Include="$(NativeDllDest)" LogicalName="cloud_redirect.dll" /> </ItemGroup> @@ -74,10 +80,10 @@ 32-bit tool from there. This is the same steam_api.dll that SteamCloudFileManagerLite ships. --> <ItemGroup> - <None Remove="native\cloud760_tool.exe" /> - <None Remove="native\steam_api.dll" /> - <EmbeddedResource Include="native\cloud760_tool.exe" LogicalName="cloud760_tool.exe" /> - <EmbeddedResource Include="native\steam_api.dll" LogicalName="steam_api.dll" /> + <None Remove="native\cloud760_tool.exe" Condition="Exists('native\cloud760_tool.exe')" /> + <None Remove="native\steam_api.dll" Condition="Exists('native\steam_api.dll')" /> + <EmbeddedResource Include="native\cloud760_tool.exe" LogicalName="cloud760_tool.exe" Condition="Exists('native\cloud760_tool.exe')" /> + <EmbeddedResource Include="native\steam_api.dll" LogicalName="steam_api.dll" Condition="Exists('native\steam_api.dll')" /> </ItemGroup> </Project> From 1271fd1a44574413743d58d4dafcfb713d6e82d1 Mon Sep 17 00:00:00 2001 From: MohandL3G <mohandl3g@gmail.com> Date: Mon, 29 Jun 2026 09:17:32 +0200 Subject: [PATCH 24/24] Dynamic Steam controls: show Restart/Close when running, Start when stopped --- ui/MainWindow.xaml | 13 ++++- ui/MainWindow.xaml.cs | 54 +++++++++++++++---- ui/Pages/DashboardPage.xaml | 13 ++++- ui/Pages/DashboardPage.xaml.cs | 96 ++++++++++++++++++++++++++++++++++ ui/Resources/Strings.resx | 12 +++++ 5 files changed, 175 insertions(+), 13 deletions(-) diff --git a/ui/MainWindow.xaml b/ui/MainWindow.xaml index 11825393..9434e9a2 100644 --- a/ui/MainWindow.xaml +++ b/ui/MainWindow.xaml @@ -116,7 +116,18 @@ <ui:NavigationViewItem x:Name="RestartSteamItem" Content="{res:Loc Nav_RestartSteam}" Icon="{ui:SymbolIcon ArrowSync24}" - Click="RestartSteamItem_Click" /> + Click="SteamNav_Click" + Tag="restart" /> + <ui:NavigationViewItem x:Name="CloseSteamItem" + Content="{res:Loc Nav_CloseSteam}" + Icon="{ui:SymbolIcon Dismiss24}" + Click="SteamNav_Click" + Tag="close" /> + <ui:NavigationViewItem x:Name="StartSteamItem" + Content="{res:Loc Nav_StartSteam}" + Icon="{ui:SymbolIcon Play24}" + Click="SteamNav_Click" + Tag="start" /> <ui:NavigationViewItem Content="{res:Loc Nav_Settings}" Icon="{ui:SymbolIcon Settings24}" TargetPageType="{x:Type pages:SettingsPage}" /> diff --git a/ui/MainWindow.xaml.cs b/ui/MainWindow.xaml.cs index 3ccc4934..52314728 100644 --- a/ui/MainWindow.xaml.cs +++ b/ui/MainWindow.xaml.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; +using System.Windows.Threading; using Wpf.Ui.Appearance; using Wpf.Ui.Controls; using TextBlock = System.Windows.Controls.TextBlock; @@ -14,6 +15,7 @@ public partial class MainWindow : FluentWindow { private Services.AppUpdater.CheckResult? _pendingUpdate; public bool AppUpdateAvailable { get; private set; } + private readonly DispatcherTimer _steamStateTimer = new() { Interval = TimeSpan.FromSeconds(5) }; public MainWindow() { @@ -40,6 +42,10 @@ public MainWindow() RootNavigation.Navigate(typeof(Pages.DashboardPage)); } catch { } + + UpdateSteamNavItem(); + _steamStateTimer.Tick += (_, _) => UpdateSteamNavItem(); + _steamStateTimer.Start(); }; } @@ -179,18 +185,42 @@ private void UpdateSkip_Click(object sender, RoutedEventArgs e) public void ShowRestartSteam() { - // Button is always visible now; kept for callers. + UpdateSteamNavItem(); + } + + private void UpdateSteamNavItem() + { + var running = Services.SteamDetector.IsSteamRunning(); + RestartSteamItem.Visibility = running ? Visibility.Visible : Visibility.Collapsed; + CloseSteamItem.Visibility = running ? Visibility.Visible : Visibility.Collapsed; + StartSteamItem.Visibility = running ? Visibility.Collapsed : Visibility.Visible; } - private async void RestartSteamItem_Click(object sender, RoutedEventArgs e) + private async void SteamNav_Click(object sender, RoutedEventArgs e) { + var tag = (sender as FrameworkElement)?.Tag as string; + var steamPath = Services.SteamDetector.FindSteamPath(); if (steamPath == null) return; var steamExe = Path.Combine(steamPath, "steam.exe"); if (!File.Exists(steamExe)) return; - // Graceful shutdown first + if (tag == "start") + { + try + { + Process.Start(new ProcessStartInfo + { + FileName = steamExe, + UseShellExecute = true + })?.Dispose(); + } + catch { } + return; + } + + // restart or close — shut down Steam first var procs = Process.GetProcessesByName("steam"); bool wasRunning = procs.Length > 0; foreach (var p in procs) p.Dispose(); @@ -204,7 +234,6 @@ private async void RestartSteamItem_Click(object sender, RoutedEventArgs e) UseShellExecute = true })?.Dispose(); - // Wait up to 15s for Steam to close for (int i = 0; i < 30; i++) { await Task.Delay(500); @@ -215,16 +244,19 @@ private async void RestartSteamItem_Click(object sender, RoutedEventArgs e) } } - try + if (tag == "restart") { - Process.Start(new ProcessStartInfo + try { - FileName = steamExe, - UseShellExecute = true - })?.Dispose(); + Process.Start(new ProcessStartInfo + { + FileName = steamExe, + UseShellExecute = true + })?.Dispose(); + } + catch { } } - catch { } - RestartSteamItem.Visibility = Visibility.Collapsed; + UpdateSteamNavItem(); } } diff --git a/ui/Pages/DashboardPage.xaml b/ui/Pages/DashboardPage.xaml index 45e7411e..4282ecd8 100644 --- a/ui/Pages/DashboardPage.xaml +++ b/ui/Pages/DashboardPage.xaml @@ -99,10 +99,21 @@ Icon="{ui:SymbolIcon Document24}" Margin="0,0,8,8" Click="OpenLog_Click" /> - <ui:Button Content="{res:Loc Dashboard_RestartSteam}" + <ui:Button x:Name="RestartSteamBtn" + Content="{res:Loc Dashboard_RestartSteam}" Icon="{ui:SymbolIcon ArrowSync24}" Margin="0,0,8,8" Click="RestartSteam_Click" /> + <ui:Button x:Name="CloseSteamBtn" + Content="{res:Loc Dashboard_CloseSteam}" + Icon="{ui:SymbolIcon Dismiss24}" + Margin="0,0,8,8" + Click="CloseSteam_Click" /> + <ui:Button x:Name="StartSteamBtn" + Content="{res:Loc Dashboard_StartSteam}" + Icon="{ui:SymbolIcon Play24}" + Margin="0,0,8,8" + Click="StartSteam_Click" /> </WrapPanel> </StackPanel> </ScrollViewer> diff --git a/ui/Pages/DashboardPage.xaml.cs b/ui/Pages/DashboardPage.xaml.cs index 1420a17b..b2d4541e 100644 --- a/ui/Pages/DashboardPage.xaml.cs +++ b/ui/Pages/DashboardPage.xaml.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using System.Windows; using System.Windows.Controls; +using System.Windows.Threading; using CloudRedirect.Resources; namespace CloudRedirect.Pages; @@ -11,6 +12,7 @@ namespace CloudRedirect.Pages; public partial class DashboardPage : Page { private string? _steamPath; + private readonly DispatcherTimer _steamStateTimer = new() { Interval = TimeSpan.FromSeconds(5) }; public void HideDllUpdateBanner() { @@ -25,6 +27,10 @@ public DashboardPage() try { await LoadStatusAsync(); } catch { } + UpdateSteamButtons(); + _steamStateTimer.Tick += (_, _) => UpdateSteamButtons(); + _steamStateTimer.Start(); + // Give the app-level update check time to finish, then hide the // DLL banner if a full app update is available (avoids two banners). await Task.Delay(3000); @@ -268,6 +274,96 @@ await Task.Run(() => } } + private void UpdateSteamButtons() + { + var running = Services.SteamDetector.IsSteamRunning(); + RestartSteamBtn.Visibility = running ? Visibility.Visible : Visibility.Collapsed; + CloseSteamBtn.Visibility = running ? Visibility.Visible : Visibility.Collapsed; + StartSteamBtn.Visibility = running ? Visibility.Collapsed : Visibility.Visible; + } + + private async void CloseSteam_Click(object sender, RoutedEventArgs e) + { + var steamPath = Services.SteamDetector.FindSteamPath(); + if (steamPath == null) return; + + var steamExe = Path.Combine(steamPath, "steam.exe"); + if (!File.Exists(steamExe)) return; + + var button = (Wpf.Ui.Controls.Button)sender; + button.IsEnabled = false; + var originalContent = button.Content; + button.Content = S.Get("Dashboard_ShuttingDownSteam"); + + try + { + Process.Start(new ProcessStartInfo + { + FileName = steamExe, + Arguments = "-shutdown", + UseShellExecute = true + })?.Dispose(); + + bool exited = await Task.Run(async () => + { + for (int i = 0; i < 30; i++) + { + await Task.Delay(500); + var procs = Process.GetProcessesByName("steam"); + bool any = procs.Length > 0; + foreach (var p in procs) p.Dispose(); + if (!any) return true; + } + return false; + }); + + if (!exited) + { + button.Content = S.Get("Dashboard_ForceKilling"); + await Task.Run(() => + { + foreach (var proc in Process.GetProcessesByName("steam")) + { + try { proc.Kill(); } + catch { } + finally { proc.Dispose(); } + } + }); + await Task.Delay(1000); + } + } + catch { } + + button.Content = originalContent; + button.IsEnabled = true; + UpdateSteamButtons(); + } + + private async void StartSteam_Click(object sender, RoutedEventArgs e) + { + var steamPath = Services.SteamDetector.FindSteamPath(); + if (steamPath == null) return; + + var steamExe = Path.Combine(steamPath, "steam.exe"); + if (!File.Exists(steamExe)) return; + + var button = (Wpf.Ui.Controls.Button)sender; + button.IsEnabled = false; + + try + { + Process.Start(new ProcessStartInfo + { + FileName = steamExe, + UseShellExecute = true + })?.Dispose(); + } + catch { } + + button.IsEnabled = true; + UpdateSteamButtons(); + } + private async void UpdateDll_Click(object sender, RoutedEventArgs e) { if (_steamPath == null) return; diff --git a/ui/Resources/Strings.resx b/ui/Resources/Strings.resx index c695380d..ca246422 100644 --- a/ui/Resources/Strings.resx +++ b/ui/Resources/Strings.resx @@ -73,6 +73,12 @@ <data name="Nav_RestartSteam" xml:space="preserve"> <value>Restart Steam</value> </data> + <data name="Nav_CloseSteam" xml:space="preserve"> + <value>Close Steam</value> + </data> + <data name="Nav_StartSteam" xml:space="preserve"> + <value>Start Steam</value> + </data> <data name="Nav_ChoiceMode" xml:space="preserve"> <value>Mode</value> </data> @@ -518,6 +524,12 @@ If you skip this, saves will be stored locally in your Steam folder.</value> <data name="Dashboard_RestartSteam" xml:space="preserve"> <value>Restart Steam</value> </data> + <data name="Dashboard_CloseSteam" xml:space="preserve"> + <value>Close Steam</value> + </data> + <data name="Dashboard_StartSteam" xml:space="preserve"> + <value>Start Steam</value> + </data> <!-- DashboardPage code-behind --> <data name="Dashboard_NotFound" xml:space="preserve">