From 0a7c37f94454dba6591cd68b22d04dead0752fdf Mon Sep 17 00:00:00 2001 From: Peron4TheWin Date: Mon, 22 Jun 2026 14:06:36 -0300 Subject: [PATCH 1/3] fix: force DLC ownership for family shared games in CheckAppOwnership --- src/Hook/Hooks_Package.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Hook/Hooks_Package.cpp b/src/Hook/Hooks_Package.cpp index a5e1539..e5ef135 100644 --- a/src/Hook/Hooks_Package.cpp +++ b/src/Hook/Hooks_Package.cpp @@ -97,7 +97,6 @@ namespace { bool result = oCheckAppOwnership(pObj, appId, pOwn); TryInitFakeLicenseOnce(); - // LOG_PACKAGE_TRACE("CheckAppOwnership: AppId={} result={} {}", appId, result, pOwn->DebugString()); if (LuaConfig::HasDepot(appId,false)) { if (result && pOwn->ExistInPackageNums > 1) { // Actually owned — record so HasDepot excludes it going forward @@ -106,6 +105,7 @@ namespace { } else { pOwn->PackageId = kInjectedPackageId; pOwn->ReleaseState = EAppReleaseState::Released; + pOwn->bOwnsLicense = true; //This forces DLCs on steam family shared games that u dont own when adding their appid via .lua // Setting this free flag to false will hide it from the library UI. pOwn->bFreeLicense = false; return true; From 1e8c42c5cf1cd5f94392c16ff967b771fb931429 Mon Sep 17 00:00:00 2001 From: Peron4TheWin Date: Wed, 24 Jun 2026 11:03:25 -0300 Subject: [PATCH 2/3] feat: Steam Cloud save redirection via CloudRedirect ([cloud]) --- opensteamtool.example.toml | 14 + src/CMakeLists.txt | 1 + src/Hook/Hooks_NetPacket.cpp | 2456 +++++++++-------- src/Utils/CloudRedirect/CloudRedirectHost.cpp | 168 ++ src/Utils/CloudRedirect/CloudRedirectHost.h | 40 + src/Utils/Config/Config.cpp | 19 + src/Utils/Config/Config.h | 34 +- src/Utils/Config/LuaFileWatcher.cpp | 2 + src/dllmain.cpp | 6 + 9 files changed, 1599 insertions(+), 1141 deletions(-) create mode 100644 src/Utils/CloudRedirect/CloudRedirectHost.cpp create mode 100644 src/Utils/CloudRedirect/CloudRedirectHost.h diff --git a/opensteamtool.example.toml b/opensteamtool.example.toml index 762c3dc..574a8f7 100644 --- a/opensteamtool.example.toml +++ b/opensteamtool.example.toml @@ -78,6 +78,20 @@ enabled = false # library_x64 = "OpenSteamTool.GameHook.x64.dll" # library_x86 = "OpenSteamTool.GameHook.x86.dll" +[cloud] +# Optional Steam Cloud save redirection for unlocked ("lua") games, powered by +# CloudRedirect (https://github.com/Selectively11/CloudRedirect). +# When enabled, OpenSteamTool loads cloud_redirect.dll inside Steam, registers +# every addappid() game as a redirected app, and routes their Steam Cloud RPCs +# through CloudRedirect's cloud-save engine. +# +# Provider sign-in (Google Drive / OneDrive / local folder) is still done through +# CloudRedirect's own companion app — OpenSteamTool only hosts the DLL. +enabled = false +# Path to cloud_redirect.dll. Absolute, or relative to the Steam root directory. +# Defaults to "/cloud_redirect.dll" when unset. +# library = "cloud_redirect.dll" + [remote] # Optional metadata mirror. Leave unset to use GitHub with jsDelivr fallback. # A custom mirror replaces the built-in remote sources and must include all diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e5414fb..8a979d9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -107,6 +107,7 @@ add_library(OpenSteamTool SHARED Utils/Config/ConfigFileWatcher.cpp Utils/Config/LuaConfig.cpp Utils/Config/LuaFileWatcher.cpp + Utils/CloudRedirect/CloudRedirectHost.cpp Utils/HookSupport/VehCommon.cpp Utils/Logging/Log.cpp Utils/SteamMetadata/IPCLoader.cpp diff --git a/src/Hook/Hooks_NetPacket.cpp b/src/Hook/Hooks_NetPacket.cpp index d5d0a1f..a5022d5 100644 --- a/src/Hook/Hooks_NetPacket.cpp +++ b/src/Hook/Hooks_NetPacket.cpp @@ -1,1130 +1,1328 @@ -#include "Hooks_NetPacket.h" -#include "Utils/SteamMetadata/ManifestClient.h" -#include "Hooks_Misc.h" -#include "HookMacros.h" -#include "dllmain.h" -#include "Utils/Tickets/AppTicket.h" -#include "Utils/Support/FnvHash.h" -#include -#include -#include - -#include "steam_messages.pb.h" - -// ════════════════════════════════════════════════════════════════ -// Shared infrastructure -// ════════════════════════════════════════════════════════════════ -namespace { - - constexpr uint32 kMaxBodySize = 8092; - constexpr uint32 kMaxHdrSize = 1024; - constexpr uint32 kMaxPacketSize = 8 + kMaxHdrSize + kMaxBodySize; - constexpr int kPacketPoolSize = 8; - - // ── Incoming (RecvPkt) packet pool ───────────────────── - uint8 g_NewBody[kMaxBodySize]; - uint32 g_cbNewBody = 0; - uint8 g_NewHdr[kMaxHdrSize]; - uint32 g_cbNewHdr = 0; - bool g_NeedReplaceBody = false; - bool g_NeedReplaceHdr = false; - bool g_ResizedInPlace = false; - uint32 g_NewBodySize = 0; - uint8 g_RecvPacketPool[kPacketPoolSize][kMaxPacketSize]; - int g_RecvPacketPoolIdx = 0; - - // ── Outgoing (BBuildAndAsyncSendFrame) — same pattern ─────── - uint8 g_SendNewBody[kMaxBodySize]; - uint32 g_cbSendNewBody = 0; - bool g_NeedReplaceSend = false; - uint8 g_SendPacketPool[kPacketPoolSize][kMaxPacketSize]; - int g_SendPacketPoolIdx = 0; - - // ── EMsg -> name lookup ───────────────────────── - RESOLVE_FUNC(PchMsgNameFromEMsg, char*, EMsg eMsg); - inline const char* MsgName(EMsg eMsg) { - if (oPchMsgNameFromEMsg) return oPchMsgNameFromEMsg(eMsg); - return "?"; - } - - - // ── Packet layout ────────────────────────────────────────── - inline bool UnpackRaw(const uint8* data, uint32 size, - EMsg& eMsg, const uint8*& pHdr, uint32& cbHdr, - const uint8*& pBody, uint32& cbBody) - { - if (!data || size < sizeof(MsgHdr)) { - fail: - eMsg = static_cast(0); - cbHdr = 0; - pHdr = nullptr; - pBody = nullptr; - cbBody = 0; - return false; - } - const MsgHdr* hdr = reinterpret_cast(data); - if (!(hdr->eMsg & kMsgHdrProtoFlag)) goto fail; - - eMsg = static_cast(hdr->eMsg & ~kMsgHdrProtoFlag); - cbHdr = hdr->headerLength; - uint32 off = sizeof(MsgHdr) + cbHdr; - if (off > size) goto fail; - pHdr = data + sizeof(MsgHdr); - pBody = data + off; - cbBody = size - off; - return true; - } - - // ── Incoming: replace header and/or body (ring-buffer pool) ── - inline void ReplaceRecvPacket(CNetPacket* p, - const uint8* pNewHdr, uint32 cbNewHdr, - const uint8* pNewBody, uint32 cbNewBody) - { - uint32 newSize = sizeof(MsgHdr) + cbNewHdr + cbNewBody; - if (newSize > sizeof(g_RecvPacketPool[0])) return; - - uint8* buf = g_RecvPacketPool[g_RecvPacketPoolIdx]; - const MsgHdr* orig = reinterpret_cast(p->m_pubData); - MsgHdr* out = reinterpret_cast(buf); - out->eMsg = orig->eMsg; - out->headerLength = cbNewHdr; - memcpy(buf + sizeof(MsgHdr), pNewHdr, cbNewHdr); - if (cbNewBody) - memcpy(buf + sizeof(MsgHdr) + cbNewHdr, pNewBody, cbNewBody); - p->m_pubData = buf; - p->m_cubData = newSize; - - g_RecvPacketPoolIdx = (g_RecvPacketPoolIdx + 1) % kPacketPoolSize; - } - - // ── Outgoing: assemble modified packet (ring-buffer pool) ──── - inline uint8* ReplaceSendPacket(const uint8* pubData, - uint32 cbHdr, const uint8* pHdr, - const uint8* pNewBody, uint32 cbNewBody, - uint32* pNewSize) - { - *pNewSize = sizeof(MsgHdr) + cbHdr + cbNewBody; - if (*pNewSize > sizeof(g_SendPacketPool[0])) return nullptr; - - uint8* buf = g_SendPacketPool[g_SendPacketPoolIdx]; - const MsgHdr* orig = reinterpret_cast(pubData); - MsgHdr* out = reinterpret_cast(buf); - out->eMsg = orig->eMsg; - out->headerLength = cbHdr; - memcpy(buf + sizeof(MsgHdr), pHdr, cbHdr); - memcpy(buf + sizeof(MsgHdr) + cbHdr, pNewBody, cbNewBody); - g_SendPacketPoolIdx = (g_SendPacketPoolIdx + 1) % kPacketPoolSize; - return buf; - } - - // ── Hash constants for target_job_name dispatch ───────────── - constexpr uint32 HASH_JOB_NotifyRunningApps = Fnv1aHash("FamilyGroupsClient.NotifyRunningApps#1"); - constexpr uint32 HASH_JOB_GetUserStats = Fnv1aHash("Player.GetUserStats#1"); - constexpr uint32 HASH_JOB_GetManifestRequestCode = Fnv1aHash("ContentServerDirectory.GetManifestRequestCode#1"); - -} // anonymous namespace - - -// ════════════════════════════════════════════════════════════════ -// Hooks_NetPacket_AccessToken -// -// Outgoing: CMsgClientPICSProductInfoRequest (eMsg 8903) -// ════════════════════════════════════════════════════════════════ -namespace Hooks_NetPacket_AccessToken { - - bool HandleSend(const uint8* pBody, uint32 cbBody) - { - CMsgClientPICSProductInfoRequest req; - if (!req.ParseFromArray(pBody, cbBody)) { - LOG_PICS_WARN("Failed to ParseFromArray CMsgClientPICSProductInfoRequest"); - return false; - } - LOG_PICS_DEBUG("CMsgClientPICSProductInfoRequest original body:\n{}", req.DebugString()); - - bool needsPatch = false; - for (const auto& app : req.apps()) { - if (LuaConfig::HasDepot(app.appid()) && LuaConfig::GetAccessToken(app.appid())) { - needsPatch = true; - LOG_PICS_DEBUG("CMsgClientPICSProductInfoRequest: found appid {} with access_token, need patching", app.appid()); - break; - } - } - if (!needsPatch) { - LOG_PICS_TRACE("CMsgClientPICSProductInfoRequest: no apps need token injection, skip"); - return false; - } - - int injected = 0, noToken = 0, notAddAppId = 0; - for (auto& app : *req.mutable_apps()) { - if (LuaConfig::HasDepot(app.appid())) { - uint64_t token = LuaConfig::GetAccessToken(app.appid()); - if (token) { - LOG_PICS_DEBUG("CMsgClientPICSProductInfoRequest: inject appid={}: {} -> {}", app.appid(), - app.has_access_token() ? std::to_string(app.access_token()) : "absent", - token); - app.set_access_token(token); - ++injected; - } else { - LOG_PICS_WARN("CMsgClientPICSProductInfoRequest: skip appid={}: in depot, no token configured", app.appid()); - ++noToken; - } - } else { - ++notAddAppId; - } - } - LOG_PICS_DEBUG("CMsgClientPICSProductInfoRequest: injected={} no_token={} not_in_add_appid={} total={}", - injected, noToken, notAddAppId, req.apps_size()); - - g_cbSendNewBody = static_cast(req.ByteSizeLong()); - if (g_cbSendNewBody > kMaxBodySize) { - LOG_PICS_WARN("CMsgClientPICSProductInfoRequest: encoded size {} exceeds buffer", g_cbSendNewBody); - return false; - } - if (!req.SerializeToArray(g_SendNewBody, kMaxBodySize)) { - LOG_PICS_WARN("CMsgClientPICSProductInfoRequest: Failed to encode modified request"); - return false; - } - - LOG_PICS_DEBUG("CMsgClientPICSProductInfoRequest: modified body: {}", req.DebugString()); - return true; - } - -} // namespace Hooks_NetPacket_AccessToken - - -// ════════════════════════════════════════════════════════════════ -// Hooks_NetPacket_UserStats -// -// Outgoing: CPlayer_GetUserStats_Request (eMsg 151 -> target: Player.GetUserStats#1) -// CMsgClientGetUserStats (eMsg 818) -// Incoming: CPlayer_GetUserStats_Response (eMsg 147 ← target: Player.GetUserStats#1) -// CMsgClientGetUserStatsResponse(eMsg 819) -// ════════════════════════════════════════════════════════════════ -namespace Hooks_NetPacket_UserStats { - - // jobid_source -> appid mapping (eMsg 151 request -> eMsg 147 response) - std::unordered_map g_JobIdToAppId; - - // ── Send: CPlayer_GetUserStats_Request (eMsg 151) ────────── - bool HandleSend_GetUserStats(const uint8* pBody, uint32 cbBody, - const uint8* pHdr, uint32 cbHdr) - { - - CPlayer_GetUserStats_Request req; - if (!req.ParseFromArray(pBody, cbBody)) { - LOG_ACHIEVEMENT_WARN("Player::GetUserStats request: failed to ParseFromArray"); - return false; - } - if (!req.has_appid()) { - LOG_ACHIEVEMENT_WARN("Player::GetUserStats request: missing appid"); - return false; - } - - LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats request: original body:\n{}", req.DebugString()); - - AppId_t appId = req.appid(); - bool hasShaSchema = req.has_sha_schema() && !req.sha_schema().empty(); - - if (hasShaSchema) { - LOG_ACHIEVEMENT_WARN("Player::GetUserStats request: sha_schema is present, do not spoof"); - return false; - } - if (!LuaConfig::HasDepot(appId)) { - LOG_ACHIEVEMENT_WARN("Player::GetUserStats request: appid={} is not in addappid", appId); - return false; - } - - // Save jobid_source -> appid for the response handler - CMsgProtoBufHeader hdr; - if (hdr.ParseFromArray(pHdr, cbHdr) && hdr.has_jobid_source()) { - uint64 jobId = hdr.jobid_source(); - g_JobIdToAppId[jobId] = appId; - LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats request: stored jobid={} -> appid={}", jobId, appId); - } - - uint64_t newSteamId = LuaConfig::GetStatSteamId(appId); - req.set_steamid(newSteamId); - - g_cbSendNewBody = static_cast(req.ByteSizeLong()); - if (!req.SerializeToArray(g_SendNewBody, kMaxBodySize)) { - LOG_ACHIEVEMENT_WARN("Player::GetUserStats request: failed to encode"); - return false; - } - - LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats request: modified body:\n{}", req.DebugString()); - return true; - } - - // ── Recv: CPlayer_GetUserStats_Response (eMsg 147) ───────── - // Header: set eresult=OK. Body: strip stats (field 4). - void HandleRecv_GetUserStatsResponse(const uint8* pHdr, uint32 cbHdr, - const uint8* pBody, uint32 cbBody) - { - // Header: set eresult=OK - CMsgProtoBufHeader hdrMsg; - if (!hdrMsg.ParseFromArray(pHdr, cbHdr)){ - LOG_ACHIEVEMENT_WARN("Player::GetUserStats response: failed to ParseFromArray original header"); - return; - } - LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats response: original header:\n{}", hdrMsg.DebugString()); - - // Look up appid via jobid_target -> jobid_source match - AppId_t appId = 0; - bool hasAppId = false; - if (hdrMsg.has_jobid_target()) { - uint64 jobId = hdrMsg.jobid_target(); - auto it = g_JobIdToAppId.find(jobId); - if (it != g_JobIdToAppId.end()) { - appId = it->second; - hasAppId = true; - LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats response: matched jobid={} -> appid={}", jobId, appId); - g_JobIdToAppId.erase(it); - } - } - - hdrMsg.set_eresult(static_cast(k_EResultOK)); - g_cbNewHdr = static_cast(hdrMsg.ByteSizeLong()); - if (g_cbNewHdr > kMaxHdrSize || !hdrMsg.SerializeToArray(g_NewHdr, kMaxHdrSize)) - return; - LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats response: modified header:\n{}", hdrMsg.DebugString()); - g_NeedReplaceHdr = true; - - // Body: strip stats (only if appid was matched and is in our config) - CPlayer_GetUserStats_Response resp; - if (!resp.ParseFromArray(pBody, cbBody)){ - LOG_ACHIEVEMENT_WARN("Player::GetUserStats response: failed to ParseFromArray original response"); - return; - } - LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats response: original body:\n{}", resp.DebugString()); - - if (!hasAppId || !LuaConfig::HasDepot(appId)) { - LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats response: no appid match, skip body strip"); - return; - } - - resp.clear_stats(); - g_NewBodySize = static_cast(resp.ByteSizeLong()); - if (!resp.SerializeToArray(const_cast(pBody), cbBody)){ - LOG_ACHIEVEMENT_WARN("Player::GetUserStats response: failed to SerializeToArray modified response"); - return; - } - g_ResizedInPlace = true; - - LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats response: modified body:\n{}", resp.DebugString()); - } - - // ── Send: CMsgClientGetUserStats (eMsg 818) ──────────────── - bool HandleSend_ClientGetUserStats(const uint8* pBody, uint32 cbBody) - { - CMsgClientGetUserStats req; - if (!req.ParseFromArray(pBody, cbBody)) { - LOG_ACHIEVEMENT_WARN("ClientGetUserStats request: failed to ParseFromArray"); - return false; - } - LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats request: original body:\n{}", req.DebugString()); - - if (!req.has_game_id()) { - LOG_ACHIEVEMENT_WARN("ClientGetUserStats request: missing game_id"); - return false; - } - AppId_t appId = static_cast(req.game_id()); - if (!LuaConfig::HasDepot(appId)) { - LOG_ACHIEVEMENT_WARN("ClientGetUserStats request: appid={} is not in addappid", appId); - return false; - } - if (!req.has_schema_local_version() || req.schema_local_version() != -1) { - LOG_ACHIEVEMENT_WARN("ClientGetUserStats request: schema_local_version is not -1"); - return false; - } - - uint64_t newSteamId = LuaConfig::GetStatSteamId(appId); - req.set_steam_id_for_user(newSteamId); - - g_cbSendNewBody = static_cast(req.ByteSizeLong()); - if (!req.SerializeToArray(g_SendNewBody, kMaxBodySize)) { - LOG_ACHIEVEMENT_WARN("ClientGetUserStats request: failed to SerializeToArray"); - return false; - } - - LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats request: modified body:\n{}", req.DebugString()); - return true; - } - - // ── Recv: CMsgClientGetUserStatsResponse (eMsg 819) ──────── - // Strip stats(5) + achievement_blocks(6), patch eresult->OK. - bool HandleRecv_ClientGetUserStatsResponse(const uint8* pBody, uint32 cbBody) - { - CMsgClientGetUserStatsResponse resp; - if (!resp.ParseFromArray(pBody, cbBody)) - return false; - LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats response: original body:\n{}", resp.DebugString()); - if(!resp.has_game_id() || !LuaConfig::HasDepot(static_cast(resp.game_id()))) { - LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats response: no modification needed"); - return false; - } - resp.clear_stats(); - resp.clear_achievement_blocks(); - resp.set_eresult(1); // k_EResultOK - LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats response: clear stats and achievement_blocks, set eresult=OK"); - - g_NewBodySize = static_cast(resp.ByteSizeLong()); - if (!resp.SerializeToArray(const_cast(pBody), cbBody)) - return false; - - g_ResizedInPlace = true; - LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats response: modified body:\n{}", resp.DebugString()); - return true; - } - -} // namespace Hooks_NetPacket_UserStats - - -// ════════════════════════════════════════════════════════════════ -// Hooks_NetPacket_ETicket -// -// Incoming: CMsgClientRequestEncryptedAppTicketResponse (eMsg 5527) -// ════════════════════════════════════════════════════════════════ -namespace Hooks_NetPacket_ETicket { - - void HandleEncryptedAppTicketResponse(const uint8* pBody, uint32 cbBody) - { - CMsgClientRequestEncryptedAppTicketResponse resp; - if (!resp.ParseFromArray(pBody, cbBody)) { - LOG_NETPACKET_WARN("ClientRequestEncryptedAppTicketResponse: failed to ParseFromArray"); - return; - } - LOG_NETPACKET_DEBUG("ClientRequestEncryptedAppTicketResponse: original body:\n{}", resp.DebugString()); - - if (resp.eresult() == k_EResultOK) return; - if (!LuaConfig::HasDepot(resp.app_id())) return; - +#include "Hooks_NetPacket.h" +#include "Utils/SteamMetadata/ManifestClient.h" +#include "Hooks_Misc.h" +#include "HookMacros.h" +#include "dllmain.h" +#include "Utils/Tickets/AppTicket.h" +#include "Utils/Support/FnvHash.h" +#include "Utils/CloudRedirect/CloudRedirectHost.h" +#include +#include +#include +#include +#include +#include +#include + +#include "steam_messages.pb.h" + +// ════════════════════════════════════════════════════════════════ +// Shared infrastructure +// ════════════════════════════════════════════════════════════════ +namespace { + + constexpr uint32 kMaxBodySize = 8092; + constexpr uint32 kMaxHdrSize = 1024; + constexpr uint32 kMaxPacketSize = 8 + kMaxHdrSize + kMaxBodySize; + constexpr int kPacketPoolSize = 8; + + // ── Incoming (RecvPkt) packet pool ───────────────────── + uint8 g_NewBody[kMaxBodySize]; + uint32 g_cbNewBody = 0; + uint8 g_NewHdr[kMaxHdrSize]; + uint32 g_cbNewHdr = 0; + bool g_NeedReplaceBody = false; + bool g_NeedReplaceHdr = false; + bool g_ResizedInPlace = false; + uint32 g_NewBodySize = 0; + uint8 g_RecvPacketPool[kPacketPoolSize][kMaxPacketSize]; + int g_RecvPacketPoolIdx = 0; + + // ── Outgoing (BBuildAndAsyncSendFrame) — same pattern ─────── + uint8 g_SendNewBody[kMaxBodySize]; + uint32 g_cbSendNewBody = 0; + bool g_NeedReplaceSend = false; + bool g_SuppressSend = false; // drop the outbound frame entirely (cloud RPC answered locally) + uint8 g_SendPacketPool[kPacketPoolSize][kMaxPacketSize]; + int g_SendPacketPoolIdx = 0; + + // ── EMsg -> name lookup ───────────────────────── + RESOLVE_FUNC(PchMsgNameFromEMsg, char*, EMsg eMsg); + inline const char* MsgName(EMsg eMsg) { + if (oPchMsgNameFromEMsg) return oPchMsgNameFromEMsg(eMsg); + return "?"; + } + + + // ── Packet layout ────────────────────────────────────────── + inline bool UnpackRaw(const uint8* data, uint32 size, + EMsg& eMsg, const uint8*& pHdr, uint32& cbHdr, + const uint8*& pBody, uint32& cbBody) + { + if (!data || size < sizeof(MsgHdr)) { + fail: + eMsg = static_cast(0); + cbHdr = 0; + pHdr = nullptr; + pBody = nullptr; + cbBody = 0; + return false; + } + const MsgHdr* hdr = reinterpret_cast(data); + if (!(hdr->eMsg & kMsgHdrProtoFlag)) goto fail; + + eMsg = static_cast(hdr->eMsg & ~kMsgHdrProtoFlag); + cbHdr = hdr->headerLength; + uint32 off = sizeof(MsgHdr) + cbHdr; + if (off > size) goto fail; + pHdr = data + sizeof(MsgHdr); + pBody = data + off; + cbBody = size - off; + return true; + } + + // ── Incoming: replace header and/or body (ring-buffer pool) ── + inline void ReplaceRecvPacket(CNetPacket* p, + const uint8* pNewHdr, uint32 cbNewHdr, + const uint8* pNewBody, uint32 cbNewBody) + { + uint32 newSize = sizeof(MsgHdr) + cbNewHdr + cbNewBody; + if (newSize > sizeof(g_RecvPacketPool[0])) return; + + uint8* buf = g_RecvPacketPool[g_RecvPacketPoolIdx]; + const MsgHdr* orig = reinterpret_cast(p->m_pubData); + MsgHdr* out = reinterpret_cast(buf); + out->eMsg = orig->eMsg; + out->headerLength = cbNewHdr; + memcpy(buf + sizeof(MsgHdr), pNewHdr, cbNewHdr); + if (cbNewBody) + memcpy(buf + sizeof(MsgHdr) + cbNewHdr, pNewBody, cbNewBody); + p->m_pubData = buf; + p->m_cubData = newSize; + + g_RecvPacketPoolIdx = (g_RecvPacketPoolIdx + 1) % kPacketPoolSize; + } + + // ── Outgoing: assemble modified packet (ring-buffer pool) ──── + inline uint8* ReplaceSendPacket(const uint8* pubData, + uint32 cbHdr, const uint8* pHdr, + const uint8* pNewBody, uint32 cbNewBody, + uint32* pNewSize) + { + *pNewSize = sizeof(MsgHdr) + cbHdr + cbNewBody; + if (*pNewSize > sizeof(g_SendPacketPool[0])) return nullptr; + + uint8* buf = g_SendPacketPool[g_SendPacketPoolIdx]; + const MsgHdr* orig = reinterpret_cast(pubData); + MsgHdr* out = reinterpret_cast(buf); + out->eMsg = orig->eMsg; + out->headerLength = cbHdr; + memcpy(buf + sizeof(MsgHdr), pHdr, cbHdr); + memcpy(buf + sizeof(MsgHdr) + cbHdr, pNewBody, cbNewBody); + g_SendPacketPoolIdx = (g_SendPacketPoolIdx + 1) % kPacketPoolSize; + return buf; + } + + // ── Hash constants for target_job_name dispatch ───────────── + constexpr uint32 HASH_JOB_NotifyRunningApps = Fnv1aHash("FamilyGroupsClient.NotifyRunningApps#1"); + constexpr uint32 HASH_JOB_GetUserStats = Fnv1aHash("Player.GetUserStats#1"); + constexpr uint32 HASH_JOB_GetManifestRequestCode = Fnv1aHash("ContentServerDirectory.GetManifestRequestCode#1"); + +} // anonymous namespace + + +// ════════════════════════════════════════════════════════════════ +// Hooks_NetPacket_AccessToken +// +// Outgoing: CMsgClientPICSProductInfoRequest (eMsg 8903) +// ════════════════════════════════════════════════════════════════ +namespace Hooks_NetPacket_AccessToken { + + bool HandleSend(const uint8* pBody, uint32 cbBody) + { + CMsgClientPICSProductInfoRequest req; + if (!req.ParseFromArray(pBody, cbBody)) { + LOG_PICS_WARN("Failed to ParseFromArray CMsgClientPICSProductInfoRequest"); + return false; + } + LOG_PICS_DEBUG("CMsgClientPICSProductInfoRequest original body:\n{}", req.DebugString()); + + bool needsPatch = false; + for (const auto& app : req.apps()) { + if (LuaConfig::HasDepot(app.appid()) && LuaConfig::GetAccessToken(app.appid())) { + needsPatch = true; + LOG_PICS_DEBUG("CMsgClientPICSProductInfoRequest: found appid {} with access_token, need patching", app.appid()); + break; + } + } + if (!needsPatch) { + LOG_PICS_TRACE("CMsgClientPICSProductInfoRequest: no apps need token injection, skip"); + return false; + } + + int injected = 0, noToken = 0, notAddAppId = 0; + for (auto& app : *req.mutable_apps()) { + if (LuaConfig::HasDepot(app.appid())) { + uint64_t token = LuaConfig::GetAccessToken(app.appid()); + if (token) { + LOG_PICS_DEBUG("CMsgClientPICSProductInfoRequest: inject appid={}: {} -> {}", app.appid(), + app.has_access_token() ? std::to_string(app.access_token()) : "absent", + token); + app.set_access_token(token); + ++injected; + } else { + LOG_PICS_WARN("CMsgClientPICSProductInfoRequest: skip appid={}: in depot, no token configured", app.appid()); + ++noToken; + } + } else { + ++notAddAppId; + } + } + LOG_PICS_DEBUG("CMsgClientPICSProductInfoRequest: injected={} no_token={} not_in_add_appid={} total={}", + injected, noToken, notAddAppId, req.apps_size()); + + g_cbSendNewBody = static_cast(req.ByteSizeLong()); + if (g_cbSendNewBody > kMaxBodySize) { + LOG_PICS_WARN("CMsgClientPICSProductInfoRequest: encoded size {} exceeds buffer", g_cbSendNewBody); + return false; + } + if (!req.SerializeToArray(g_SendNewBody, kMaxBodySize)) { + LOG_PICS_WARN("CMsgClientPICSProductInfoRequest: Failed to encode modified request"); + return false; + } + + LOG_PICS_DEBUG("CMsgClientPICSProductInfoRequest: modified body: {}", req.DebugString()); + return true; + } + +} // namespace Hooks_NetPacket_AccessToken + + +// ════════════════════════════════════════════════════════════════ +// Hooks_NetPacket_UserStats +// +// Outgoing: CPlayer_GetUserStats_Request (eMsg 151 -> target: Player.GetUserStats#1) +// CMsgClientGetUserStats (eMsg 818) +// Incoming: CPlayer_GetUserStats_Response (eMsg 147 ← target: Player.GetUserStats#1) +// CMsgClientGetUserStatsResponse(eMsg 819) +// ════════════════════════════════════════════════════════════════ +namespace Hooks_NetPacket_UserStats { + + // jobid_source -> appid mapping (eMsg 151 request -> eMsg 147 response) + std::unordered_map g_JobIdToAppId; + + // ── Send: CPlayer_GetUserStats_Request (eMsg 151) ────────── + bool HandleSend_GetUserStats(const uint8* pBody, uint32 cbBody, + const uint8* pHdr, uint32 cbHdr) + { + + CPlayer_GetUserStats_Request req; + if (!req.ParseFromArray(pBody, cbBody)) { + LOG_ACHIEVEMENT_WARN("Player::GetUserStats request: failed to ParseFromArray"); + return false; + } + if (!req.has_appid()) { + LOG_ACHIEVEMENT_WARN("Player::GetUserStats request: missing appid"); + return false; + } + + LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats request: original body:\n{}", req.DebugString()); + + AppId_t appId = req.appid(); + bool hasShaSchema = req.has_sha_schema() && !req.sha_schema().empty(); + + if (hasShaSchema) { + LOG_ACHIEVEMENT_WARN("Player::GetUserStats request: sha_schema is present, do not spoof"); + return false; + } + if (!LuaConfig::HasDepot(appId)) { + LOG_ACHIEVEMENT_WARN("Player::GetUserStats request: appid={} is not in addappid", appId); + return false; + } + + // Save jobid_source -> appid for the response handler + CMsgProtoBufHeader hdr; + if (hdr.ParseFromArray(pHdr, cbHdr) && hdr.has_jobid_source()) { + uint64 jobId = hdr.jobid_source(); + g_JobIdToAppId[jobId] = appId; + LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats request: stored jobid={} -> appid={}", jobId, appId); + } + + uint64_t newSteamId = LuaConfig::GetStatSteamId(appId); + req.set_steamid(newSteamId); + + g_cbSendNewBody = static_cast(req.ByteSizeLong()); + if (!req.SerializeToArray(g_SendNewBody, kMaxBodySize)) { + LOG_ACHIEVEMENT_WARN("Player::GetUserStats request: failed to encode"); + return false; + } + + LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats request: modified body:\n{}", req.DebugString()); + return true; + } + + // ── Recv: CPlayer_GetUserStats_Response (eMsg 147) ───────── + // Header: set eresult=OK. Body: strip stats (field 4). + void HandleRecv_GetUserStatsResponse(const uint8* pHdr, uint32 cbHdr, + const uint8* pBody, uint32 cbBody) + { + // Header: set eresult=OK + CMsgProtoBufHeader hdrMsg; + if (!hdrMsg.ParseFromArray(pHdr, cbHdr)){ + LOG_ACHIEVEMENT_WARN("Player::GetUserStats response: failed to ParseFromArray original header"); + return; + } + LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats response: original header:\n{}", hdrMsg.DebugString()); + + // Look up appid via jobid_target -> jobid_source match + AppId_t appId = 0; + bool hasAppId = false; + if (hdrMsg.has_jobid_target()) { + uint64 jobId = hdrMsg.jobid_target(); + auto it = g_JobIdToAppId.find(jobId); + if (it != g_JobIdToAppId.end()) { + appId = it->second; + hasAppId = true; + LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats response: matched jobid={} -> appid={}", jobId, appId); + g_JobIdToAppId.erase(it); + } + } + + hdrMsg.set_eresult(static_cast(k_EResultOK)); + g_cbNewHdr = static_cast(hdrMsg.ByteSizeLong()); + if (g_cbNewHdr > kMaxHdrSize || !hdrMsg.SerializeToArray(g_NewHdr, kMaxHdrSize)) + return; + LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats response: modified header:\n{}", hdrMsg.DebugString()); + g_NeedReplaceHdr = true; + + // Body: strip stats (only if appid was matched and is in our config) + CPlayer_GetUserStats_Response resp; + if (!resp.ParseFromArray(pBody, cbBody)){ + LOG_ACHIEVEMENT_WARN("Player::GetUserStats response: failed to ParseFromArray original response"); + return; + } + LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats response: original body:\n{}", resp.DebugString()); + + if (!hasAppId || !LuaConfig::HasDepot(appId)) { + LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats response: no appid match, skip body strip"); + return; + } + + resp.clear_stats(); + g_NewBodySize = static_cast(resp.ByteSizeLong()); + if (!resp.SerializeToArray(const_cast(pBody), cbBody)){ + LOG_ACHIEVEMENT_WARN("Player::GetUserStats response: failed to SerializeToArray modified response"); + return; + } + g_ResizedInPlace = true; + + LOG_ACHIEVEMENT_DEBUG("Player::GetUserStats response: modified body:\n{}", resp.DebugString()); + } + + // ── Send: CMsgClientGetUserStats (eMsg 818) ──────────────── + bool HandleSend_ClientGetUserStats(const uint8* pBody, uint32 cbBody) + { + CMsgClientGetUserStats req; + if (!req.ParseFromArray(pBody, cbBody)) { + LOG_ACHIEVEMENT_WARN("ClientGetUserStats request: failed to ParseFromArray"); + return false; + } + LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats request: original body:\n{}", req.DebugString()); + + if (!req.has_game_id()) { + LOG_ACHIEVEMENT_WARN("ClientGetUserStats request: missing game_id"); + return false; + } + AppId_t appId = static_cast(req.game_id()); + if (!LuaConfig::HasDepot(appId)) { + LOG_ACHIEVEMENT_WARN("ClientGetUserStats request: appid={} is not in addappid", appId); + return false; + } + if (!req.has_schema_local_version() || req.schema_local_version() != -1) { + LOG_ACHIEVEMENT_WARN("ClientGetUserStats request: schema_local_version is not -1"); + return false; + } + + uint64_t newSteamId = LuaConfig::GetStatSteamId(appId); + req.set_steam_id_for_user(newSteamId); + + g_cbSendNewBody = static_cast(req.ByteSizeLong()); + if (!req.SerializeToArray(g_SendNewBody, kMaxBodySize)) { + LOG_ACHIEVEMENT_WARN("ClientGetUserStats request: failed to SerializeToArray"); + return false; + } + + LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats request: modified body:\n{}", req.DebugString()); + return true; + } + + // ── Recv: CMsgClientGetUserStatsResponse (eMsg 819) ──────── + // Strip stats(5) + achievement_blocks(6), patch eresult->OK. + bool HandleRecv_ClientGetUserStatsResponse(const uint8* pBody, uint32 cbBody) + { + CMsgClientGetUserStatsResponse resp; + if (!resp.ParseFromArray(pBody, cbBody)) + return false; + LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats response: original body:\n{}", resp.DebugString()); + if(!resp.has_game_id() || !LuaConfig::HasDepot(static_cast(resp.game_id()))) { + LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats response: no modification needed"); + return false; + } + resp.clear_stats(); + resp.clear_achievement_blocks(); + resp.set_eresult(1); // k_EResultOK + LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats response: clear stats and achievement_blocks, set eresult=OK"); + + g_NewBodySize = static_cast(resp.ByteSizeLong()); + if (!resp.SerializeToArray(const_cast(pBody), cbBody)) + return false; + + g_ResizedInPlace = true; + LOG_ACHIEVEMENT_DEBUG("ClientGetUserStats response: modified body:\n{}", resp.DebugString()); + return true; + } + +} // namespace Hooks_NetPacket_UserStats + + +// ════════════════════════════════════════════════════════════════ +// Hooks_NetPacket_ETicket +// +// Incoming: CMsgClientRequestEncryptedAppTicketResponse (eMsg 5527) +// ════════════════════════════════════════════════════════════════ +namespace Hooks_NetPacket_ETicket { + + void HandleEncryptedAppTicketResponse(const uint8* pBody, uint32 cbBody) + { + CMsgClientRequestEncryptedAppTicketResponse resp; + if (!resp.ParseFromArray(pBody, cbBody)) { + LOG_NETPACKET_WARN("ClientRequestEncryptedAppTicketResponse: failed to ParseFromArray"); + return; + } + LOG_NETPACKET_DEBUG("ClientRequestEncryptedAppTicketResponse: original body:\n{}", resp.DebugString()); + + if (resp.eresult() == k_EResultOK) return; + if (!LuaConfig::HasDepot(resp.app_id())) return; + auto ticket = AppTicket::GetEncryptedTicketFromCredentialStore(resp.app_id()); - if (ticket.empty()) return; - - if (!resp.mutable_encrypted_app_ticket()->ParseFromArray( - ticket.data(), static_cast(ticket.size()))) { - LOG_NETPACKET_WARN("ClientRequestEncryptedAppTicketResponse: failed to ParseFromArray EncryptedAppTicket"); - return; - } - - resp.set_eresult(k_EResultOK); - - auto encSize = resp.ByteSizeLong(); - if (encSize > sizeof(g_NewBody)) { - LOG_NETPACKET_WARN("ClientRequestEncryptedAppTicketResponse: modified message too large"); - return; - } - if (!resp.SerializeToArray(g_NewBody, sizeof(g_NewBody))) { - LOG_NETPACKET_WARN("ClientRequestEncryptedAppTicketResponse: failed to SerializeToArray modified response"); - return; - } - - LOG_NETPACKET_DEBUG("ClientRequestEncryptedAppTicketResponse: modified body:\n{}", resp.DebugString()); - - g_cbNewBody = static_cast(encSize); - g_NeedReplaceBody = true; - } - -} // namespace Hooks_NetPacket_ETicket - - -// ════════════════════════════════════════════════════════════════ -// Hooks_NetPacket_FamilySharing -// ════════════════════════════════════════════════════════════════ -namespace Hooks_NetPacket_FamilySharing { - - void ClearBody(const uint8*, uint32) - { - LOG_NETPACKET_DEBUG("Clearing family sharing message..."); - g_cbNewBody = 0; - g_NeedReplaceBody = true; - } - -} // namespace Hooks_NetPacket_FamilySharing - - -// ════════════════════════════════════════════════════════════════ -// Hooks_NetPacket_Manifest -// -// Outgoing: ContentServerDirectory.GetManifestRequestCode#1 (eMsg 151) -// Incoming: ContentServerDirectory.GetManifestRequestCode#1 (eMsg 147) -// -// Launches an async HTTP fetch on send; the recv handler waits up to -// 12 s for the result and patches both header (eresult=OK) and body -// (manifest_request_code). On timeout or failure the original -// response passes through unmodified. -// ════════════════════════════════════════════════════════════════ -namespace Hooks_NetPacket_Manifest { - - std::unordered_map> g_CodeFutures; - std::mutex g_CodeMutex; - constexpr uint32 kMaxWaitSeconds = 12; - - bool HandleSend(const uint8* pBody, uint32 cbBody, - const uint8* pHdr, uint32 cbHdr) - { - CContentServerDirectory_GetManifestRequestCode_Request req; - if (!req.ParseFromArray(pBody, cbBody)) { - LOG_MANIFEST_WARN("GetManifestRequestCode: failed to parse request"); - return false; - } - if (!req.has_depot_id() || !req.has_manifest_id()) return false; - if (!LuaConfig::HasDepot(req.depot_id())) return false; - - CMsgProtoBufHeader hdr; - if (!hdr.ParseFromArray(pHdr, cbHdr) || !hdr.has_jobid_source()) { - LOG_MANIFEST_WARN("GetManifestRequestCode: missing jobid_source in header"); - return false; - } - - uint64 jobId = hdr.jobid_source(); - uint64 manifestGid = req.manifest_id(); - uint32 depotId = req.depot_id(); - uint32 appId = req.has_app_id() ? req.app_id() : 0; - - LOG_MANIFEST_DEBUG("GetManifestRequestCode send: depot={} gid={} jobid={} app_id={}", - depotId, manifestGid, jobId, appId); - - auto task = std::async(std::launch::async, - [manifestGid, depotId, appId]() -> uint64 { - uint64 code = 0; - ManifestClient::FetchManifestRequestCode(manifestGid, &code, appId, depotId); - return code; - }); - - { - std::lock_guard lock(g_CodeMutex); - g_CodeFutures[jobId] = task.share(); - } - - return false; // Don't modify the outgoing request body - } - - void HandleRecv(const uint8* pBody, uint32 cbBody, - const uint8* pHdr, uint32 cbHdr) - { - CMsgProtoBufHeader hdr; - if (!hdr.ParseFromArray(pHdr, cbHdr)){ - LOG_MANIFEST_WARN("GetManifestRequestCode recv: failed to ParseFromArray original header"); - return; - } - - uint64 jobId = hdr.jobid_target(); - std::shared_future future; - - { - std::lock_guard lock(g_CodeMutex); - auto it = g_CodeFutures.find(jobId); - if (it == g_CodeFutures.end()) return; - future = it->second; - g_CodeFutures.erase(it); // Always clean up immediately - } - // Wait up to kMaxWaitSeconds seconds for the HTTP fetch to complete - auto status = future.wait_for(std::chrono::seconds(kMaxWaitSeconds)); - if (status != std::future_status::ready) { - LOG_MANIFEST_WARN("GetManifestRequestCode recv: HTTP timed out for jobid={}", jobId); - return; - } - - uint64 code = future.get(); - if (!code) { - LOG_MANIFEST_WARN("GetManifestRequestCode recv: HTTP returned 0 for jobid={}", jobId); - return; - } - - LOG_MANIFEST_DEBUG("GetManifestRequestCode recv: injecting code={} for jobid={}", - code, jobId); - - // Header: set eresult=OK - hdr.set_eresult(static_cast(k_EResultOK)); - g_cbNewHdr = static_cast(hdr.ByteSizeLong()); - if (g_cbNewHdr > kMaxHdrSize || !hdr.SerializeToArray(g_NewHdr, kMaxHdrSize)){ - LOG_MANIFEST_WARN("GetManifestRequestCode recv: failed to SerializeToArray modified header," - "g_cbNewHdr: {}, kMaxHdrSize: {}", g_cbNewHdr, kMaxHdrSize); - return; - } - g_NeedReplaceHdr = true; - - // Body: set manifest_request_code - CContentServerDirectory_GetManifestRequestCode_Response resp; - resp.set_manifest_request_code(code); - - g_cbNewBody = static_cast(resp.ByteSizeLong()); - if (g_cbNewBody > kMaxBodySize || !resp.SerializeToArray(g_NewBody, kMaxBodySize)){ - LOG_MANIFEST_WARN("GetManifestRequestCode recv: failed to SerializeToArray modified body," - "g_cbNewBody:{}, kMaxBodySize:{}", g_cbNewBody, kMaxBodySize); - return; - } - g_NeedReplaceBody = true; - } - -} // namespace Hooks_NetPacket_Manifest - - -// ════════════════════════════════════════════════════════════════ -// Hooks_NetPacket_RichPresence -// -// Outgoing: CMsgClientGamesPlayed (eMsg 742 / 5410) -// Incoming: CMsgClientPersonaState (eMsg 766) -// -// The server drops friend-side broadcasts for unowned AppIds, so the -// banner stays on the last cached state. Cache real self-pushes and -// re-deliver a patched copy through oRecvPkt by borrowing the next -// carrier packet's data pointer. -// ════════════════════════════════════════════════════════════════ -namespace Hooks_NetPacket_RichPresence { - - AppId_t g_PlayingAppId = 0; - uint64 g_LocalSteamId = 0; - - // Most recent self-PersonaState bytes captured from a real server push. - // Reused as the template every game launch. - uint8 g_SelfHdr [kMaxHdrSize]; - uint32 g_cbSelfHdr = 0; - uint8 g_SelfBody[kMaxBodySize]; - uint32 g_cbSelfBody = 0; - bool g_HaveSelfCached = false; - - // Manufactured PersonaState packet (eMsg 766) ready to inject. - uint8 g_InjectPkt[kMaxPacketSize]; - uint32 g_cbInjectPkt = 0; - bool g_InjectPending = false; - - // Rich presence KVs per AppId, captured from outbound - // CMsgClientRichPresenceUpload. Per-AppId so a multi-game stack - // does not conflate KV state. - std::unordered_map>> g_RPKvsByAppId; - - // Walk Steam's binary KV1 stream (a top-level "RP" struct around - // string KVs) and collect every string KV at any depth. Type 0x00 - // starts a struct, 0x01 a string KV, 0x08 ends a struct. String KVs - // are null-terminated key + null-terminated value. - static void ExtractStringKVs(const uint8* data, uint32 size, - std::vector>& out) - { - uint32 pos = 0; - int depth = 0; - auto readCStr = [&](std::string& s) -> bool { - uint32 start = pos; - while (pos < size && data[pos] != 0) ++pos; - if (pos >= size) return false; - s.assign(reinterpret_cast(data + start), pos - start); - ++pos; - return true; - }; - while (pos < size) { - uint8 type = data[pos++]; - if (type == 0x08) { - if (depth > 0) { --depth; continue; } - break; - } - if (type == 0x00) { - std::string name; - if (!readCStr(name)) return; - ++depth; - } else if (type == 0x01) { - std::string key, value; - if (!readCStr(key) || !readCStr(value)) return; - out.emplace_back(std::move(key), std::move(value)); - } else { - return; - } - } - } - - // Patch the self Friend entry with the appid (0 = stopped) and per-app - // KVs. Mask status_flags's RichPresence bit (0x1000) on appid + empty - // KVs so a freshly launched game's first inject does not wipe the UI's - // m_mapRichPresence, which is rebuilt from rich_presence() whenever - // that bit is set. - static void ApplyGameFields(CMsgClientPersonaState& msg, - CMsgClientPersonaState::Friend* entry, - AppId_t appid) - { - // EClientPersonaStateFlag::k_EClientPersonaStateFlagRichPresence - constexpr uint32 kStatusFlagRichPresence = 0x1000; - - if (appid) { - entry->set_game_played_app_id(appid); - entry->set_gameid(static_cast(appid)); - std::string name = Hooks_Misc::GetGameNameByAppID(appid); - if (!name.empty()) entry->set_game_name(name); - entry->clear_rich_presence(); - auto it = g_RPKvsByAppId.find(appid); - const bool hasKvs = (it != g_RPKvsByAppId.end()) && !it->second.empty(); - if (hasKvs) { - for (const auto& [k, v] : it->second) { - auto* kv = entry->add_rich_presence(); - kv->set_key(k); - kv->set_value(v); - } - msg.set_status_flags(msg.status_flags() | kStatusFlagRichPresence); - } else { - msg.set_status_flags(msg.status_flags() & ~kStatusFlagRichPresence); - } - } else { - entry->clear_game_played_app_id(); - entry->clear_gameid(); - entry->clear_game_name(); - entry->clear_rich_presence(); - msg.set_status_flags(msg.status_flags() | kStatusFlagRichPresence); - } - } - - static bool BuildInject(AppId_t appid) - { - if (!g_HaveSelfCached) return false; - - CMsgClientPersonaState msg; - if (!msg.ParseFromArray(g_SelfBody, g_cbSelfBody)) return false; - - // Find our entry (a self-push always contains it). - CMsgClientPersonaState::Friend* entry = nullptr; - for (int i = 0; i < msg.friends_size(); ++i) { - auto* f = msg.mutable_friends(i); - if (f->has_friendid() && f->friendid() == g_LocalSteamId) { - entry = f; - break; - } - } - if (!entry) return false; - - ApplyGameFields(msg, entry, appid); - - uint32 hdrSize = g_cbSelfHdr; - uint32 bodySize = static_cast(msg.ByteSizeLong()); - uint32 total = sizeof(MsgHdr) + hdrSize + bodySize; - if (total > sizeof(g_InjectPkt) || bodySize > kMaxBodySize) { - LOG_RICHPRESENCE_WARN("Inject packet too large ({} bytes)", total); - return false; - } - - auto* mhdr = reinterpret_cast(g_InjectPkt); - mhdr->eMsg = static_cast( - static_cast(k_EMsgClientPersonaState) | kMsgHdrProtoFlag); - mhdr->headerLength = hdrSize; - memcpy(g_InjectPkt + sizeof(MsgHdr), g_SelfHdr, hdrSize); - if (!msg.SerializeToArray(g_InjectPkt + sizeof(MsgHdr) + hdrSize, bodySize)) - return false; - - g_cbInjectPkt = total; - LOG_RICHPRESENCE_INFO("Built inject for appid {} ({} bytes)", appid, total); - return true; - } - - // Decode outbound CMsgClientRichPresenceUpload into per-AppId KVs - // and stage a fresh PersonaState inject. - void TrackRPSend(const uint8* pBody, uint32 cbBody) - { - if (g_LocalSteamId == 0 || g_PlayingAppId == 0) return; - - CMsgClientRichPresenceUpload up; - if (!up.ParseFromArray(pBody, cbBody)) return; - if (!up.has_rich_presence_kv()) return; - - const std::string& kv = up.rich_presence_kv(); - auto& kvs = g_RPKvsByAppId[g_PlayingAppId]; - kvs.clear(); - ExtractStringKVs(reinterpret_cast(kv.data()), - static_cast(kv.size()), kvs); - LOG_RICHPRESENCE_DEBUG("RP upload appid={}: kv_bytes={} extracted={} pairs", - g_PlayingAppId, kv.size(), kvs.size()); - - if (BuildInject(g_PlayingAppId)) g_InjectPending = true; - } - - void TrackSend(const CMsgClientGamesPlayed& msg, const uint8* pHdr, uint32 cbHdr) - { - if (g_LocalSteamId == 0) { - CMsgProtoBufHeader hdr; - if (hdr.ParseFromArray(pHdr, cbHdr) && hdr.has_steamid() && hdr.steamid()) { - g_LocalSteamId = hdr.steamid(); - LOG_RICHPRESENCE_DEBUG("Captured local SteamID 0x{:X}", g_LocalSteamId); - } - } - - // Steam stacks running games in games_played; the banner follows - // the tail (most recently launched). Mirror that — only the - // tail's appid drives our inject. - AppId_t topmost = 0; - if (msg.games_played_size() > 0) { - topmost = static_cast( - msg.games_played(msg.games_played_size() - 1).game_id() & UINT32_MAX); - } - - // Only track when the topmost is an unlocked AppId we can inject for. - // Owned games on top let the server's natural broadcast paint the - // cache; -onlinefix games are already handled by the OnlineFix path. - AppId_t newTracked = 0; - if (topmost != 0 && topmost != kOnlineFixAppId && LuaConfig::HasDepot(topmost)) - newTracked = topmost; - - if (g_PlayingAppId == newTracked) return; - g_PlayingAppId = newTracked; - - if (newTracked != 0) { - LOG_RICHPRESENCE_INFO("Tracking topmost appid {}", newTracked); - if (BuildInject(newTracked)) g_InjectPending = true; - } else if (topmost == 0) { - // Stack went empty — inject a clear so the cache reverts. - LOG_RICHPRESENCE_DEBUG("GamesPlayed empty, scheduling cache clear"); - if (BuildInject(0)) g_InjectPending = true; - } else { - // Topmost is owned (or -onlinefix); let the server's broadcast - // paint it. Skipping the clear-inject here avoids a brief - // "Online" flicker between our drop and the server's push. - LOG_RICHPRESENCE_DEBUG("Topmost is appid {} (owned or onlinefix); deferring to server", topmost); - } - } - - // Cache real self-pushes as the inject template; if we are tracking - // an unowned game, patch the live message in place so a periodic - // refresh does not overwrite the injected game info. - bool HandleRecv(const uint8* pBody, uint32 cbBody, const uint8* pHdr, uint32 cbHdr) - { - CMsgClientPersonaState msg; - if (!msg.ParseFromArray(pBody, cbBody)) return false; - - CMsgClientPersonaState::Friend* selfEntry = nullptr; - for (int i = 0; i < msg.friends_size(); ++i) { - auto* f = msg.mutable_friends(i); - if (f->has_friendid() && f->friendid() == g_LocalSteamId) { - selfEntry = f; - break; - } - } - if (!selfEntry) return false; - - LOG_RICHPRESENCE_DEBUG( - "Recv self PersonaState: status_flags=0x{:X} friends_size={}", - msg.status_flags(), msg.friends_size()); - - if (cbHdr <= sizeof(g_SelfHdr) && cbBody <= sizeof(g_SelfBody)) { - memcpy(g_SelfHdr, pHdr, cbHdr); - memcpy(g_SelfBody, pBody, cbBody); - g_cbSelfHdr = cbHdr; - g_cbSelfBody = cbBody; - g_HaveSelfCached = true; - } - - if (g_PlayingAppId == 0) return false; - - ApplyGameFields(msg, selfEntry, g_PlayingAppId); - g_cbNewBody = static_cast(msg.ByteSizeLong()); - if (g_cbNewBody > kMaxBodySize) { - LOG_RICHPRESENCE_WARN("In-place patch too large ({} bytes)", g_cbNewBody); - return false; - } - if (!msg.SerializeToArray(g_NewBody, kMaxBodySize)) { - LOG_RICHPRESENCE_WARN("In-place patch SerializeToArray failed"); - return false; - } - LOG_RICHPRESENCE_INFO("Patched live self push with appid {}", g_PlayingAppId); - return true; - } - - // Deliver the pending manufactured PersonaState by borrowing the - // carrier's data pointer for one oRecvPkt call, then restore. - void TryInject(void* pThis, CNetPacket* pCarrier, - bool (*invokeOriginal)(void*, CNetPacket*)) - { - if (!g_InjectPending || g_cbInjectPkt == 0) return; - g_InjectPending = false; - - uint8* origData = pCarrier->m_pubData; - uint32 origSize = pCarrier->m_cubData; - pCarrier->m_pubData = g_InjectPkt; - pCarrier->m_cubData = g_cbInjectPkt; - invokeOriginal(pThis, pCarrier); - pCarrier->m_pubData = origData; - pCarrier->m_cubData = origSize; - LOG_RICHPRESENCE_INFO("Delivered manufactured self-PersonaState ({} bytes)", g_cbInjectPkt); - } - -} // namespace Hooks_NetPacket_RichPresence - - -// ════════════════════════════════════════════════════════════════ -// Hooks_NetPacket_OnlineFix -// -// Outgoing: CMsgClientGamesPlayed (eMsg 742 / 5410) -// -// When a game launched with -onlinefix reports appid 480, replace -// game_extra_info with the real game's localized name so friends -// see the correct title. -// ════════════════════════════════════════════════════════════════ -namespace Hooks_NetPacket_OnlineFix { - - bool HandleSend(const uint8* pBody, uint32 cbBody, - const uint8* pHdr, uint32 cbHdr) - { - CMsgClientGamesPlayed msg; - if (!msg.ParseFromArray(pBody, cbBody)) { - LOG_ONLINEFIX_WARN("OnlineFix: failed to parse CMsgClientGamesPlayed"); - return false; - } - LOG_ONLINEFIX_DEBUG("OnlineFix: original body:\n{}", msg.DebugString()); - - Hooks_NetPacket_RichPresence::TrackSend(msg, pHdr, cbHdr); - - bool patched = false; - for (int i = 0; i < msg.games_played_size(); ++i) { - auto* game = msg.mutable_games_played(i); - AppId_t appid = static_cast(game->game_id() & UINT32_MAX); - - // SpawnProcess rewrites pGameID to 480, so game_id is already 480. - // Fill game_extra_info with the real game name. - if (appid == kOnlineFixAppId) { - AppId_t realAppId = Hooks_Misc::ResolveAppId(); - if (realAppId && LuaConfig::HasDepot(realAppId)) { - std::string name = Hooks_Misc::GetGameNameByAppID(realAppId); - if (!name.empty()) { - game->set_game_extra_info(name); - patched = true; - LOG_ONLINEFIX_INFO("OnlineFix: 480 -> name '{}' (real appid {})", - name, realAppId); - } - } - } - } - - if (!patched) return false; - - g_cbSendNewBody = static_cast(msg.ByteSizeLong()); - if (g_cbSendNewBody > kMaxBodySize) { - LOG_ONLINEFIX_WARN("OnlineFix: encoded size {} exceeds buffer", g_cbSendNewBody); - return false; - } - if (!msg.SerializeToArray(g_SendNewBody, kMaxBodySize)) { - LOG_ONLINEFIX_WARN("OnlineFix: failed to SerializeToArray"); - return false; - } - - LOG_ONLINEFIX_DEBUG("OnlineFix: modified body:\n{}", msg.DebugString()); - return true; - } - -} // namespace Hooks_NetPacket_OnlineFix - - -// ════════════════════════════════════════════════════════════════ -// Dispatch -// ════════════════════════════════════════════════════════════════ -namespace { - - bool SendServiceJob(const char* targetJobName, - const uint8* pBody, uint32 cbBody, - const uint8* pHdr, uint32 cbHdr) - { - LOG_NETPACKET_DEBUG("Send target_job_name: {}", targetJobName); - switch (Fnv1aHash(targetJobName)) { - - case HASH_JOB_GetUserStats: - return Hooks_NetPacket_UserStats::HandleSend_GetUserStats(pBody, cbBody, pHdr, cbHdr); - - case HASH_JOB_GetManifestRequestCode: - return Hooks_NetPacket_Manifest::HandleSend(pBody, cbBody, pHdr, cbHdr); - - // ---- add new 151 service methods here ---- - } - return false; - } - - void SendJob(EMsg eMsg, const uint8* pBody, uint32 cbBody, - const uint8* pHdr, uint32 cbHdr) - { - g_NeedReplaceSend = false; - - LOG_NETPACKET_DEBUG("Send eMsg {}({}) (cbBody={}, cbHdr={})", - MsgName(eMsg), static_cast(eMsg), cbBody, cbHdr); - - switch (eMsg) { - - case k_EMsgServiceMethodCallFromClient: { // 151 - CMsgProtoBufHeader hdr; - if (hdr.ParseFromArray(pHdr, cbHdr) && hdr.has_target_job_name()) { - g_NeedReplaceSend = SendServiceJob(hdr.target_job_name().c_str(), pBody, cbBody, pHdr, cbHdr); - } - return; - } - - case k_EMsgClientPICSProductInfoRequest: // 8903 - g_NeedReplaceSend = Hooks_NetPacket_AccessToken::HandleSend(pBody, cbBody); - return; - - case k_EMsgClientGamesPlayed: // 742 - case k_EMsgClientGamesPlayedWithDataBlob: // 5410 - g_NeedReplaceSend = Hooks_NetPacket_OnlineFix::HandleSend(pBody, cbBody, pHdr, cbHdr); - return; - - case k_EMsgClientRichPresenceUpload: // 7501 - Hooks_NetPacket_RichPresence::TrackRPSend(pBody, cbBody); - return; - - case k_EMsgClientGetUserStats: // 818 - g_NeedReplaceSend = Hooks_NetPacket_UserStats::HandleSend_ClientGetUserStats(pBody, cbBody); - return; - - default: - return; - } - } - - void RecvServiceJob(const char* targetJobName, - const uint8* pBody, uint32 cbBody, - const uint8* pHdr, uint32 cbHdr) - { - LOG_NETPACKET_DEBUG("Recv target_job_name: {}", targetJobName); - g_NeedReplaceBody = false; - g_NeedReplaceHdr = false; - - switch (Fnv1aHash(targetJobName)) { - - case HASH_JOB_NotifyRunningApps: - Hooks_NetPacket_FamilySharing::ClearBody(pBody, cbBody); - return; - - case HASH_JOB_GetUserStats: - Hooks_NetPacket_UserStats::HandleRecv_GetUserStatsResponse(pHdr, cbHdr, pBody, cbBody); - return; - - case HASH_JOB_GetManifestRequestCode: - Hooks_NetPacket_Manifest::HandleRecv(pBody, cbBody, pHdr, cbHdr); - return; - - // ---- add new 147 service methods here ---- - } - } - - void RecvJob(EMsg eMsg, const uint8* pBody, uint32 cbBody, - const uint8* pHdr, uint32 cbHdr) - { - g_NeedReplaceBody = false; - g_NeedReplaceHdr = false; - - if(eMsg == k_EMsgMulti) { - LOG_NETPACKET_TRACE("Received k_EMsgMulti, skipping dispatch"); - return; - } - LOG_NETPACKET_DEBUG("Recv eMsg {}({}) (cbBody={}, cbHdr={})", - MsgName(eMsg), static_cast(eMsg), cbBody, cbHdr); - - switch (eMsg) { - - case k_EMsgServiceMethodResponse: { // 147 - CMsgProtoBufHeader hdr; - if (hdr.ParseFromArray(pHdr, cbHdr) && hdr.has_target_job_name()) - RecvServiceJob(hdr.target_job_name().c_str(), pBody, cbBody, pHdr, cbHdr); - return; - } - - // migrated to IPC Layer Hooks_IPC_ISteamUser::GetEncryptedAppTicketResponse - // case k_EMsgClientRequestEncryptedAppTicketResponse: // 5527 - // Hooks_NetPacket_ETicket::HandleEncryptedAppTicketResponse(pBody, cbBody); - // return; - - case k_EMsgClientGetUserStatsResponse: // 819 - g_NeedReplaceBody = Hooks_NetPacket_UserStats::HandleRecv_ClientGetUserStatsResponse( - pBody, cbBody); - return; - - case k_EMsgClientSharedLibraryStopPlaying: // 9406 - Hooks_NetPacket_FamilySharing::ClearBody(pBody, cbBody); - return; - - case k_EMsgClientPersonaState: // 766 - g_NeedReplaceBody = Hooks_NetPacket_RichPresence::HandleRecv(pBody, cbBody, pHdr, cbHdr); - return; - - default: - return; - } - } - - // ════════════════════════════════════════════════════════════ - // Hooks - // ════════════════════════════════════════════════════════════ - - HOOK_FUNC(BBuildAndAsyncSendFrame, bool, - void* pObject, EWebSocketOpCode eWebSocketOpCode, - uint8* pubData, uint32 cubData) - { - if (eWebSocketOpCode != k_eWebSocketOpCode_Binary) - return oBBuildAndAsyncSendFrame(pObject, eWebSocketOpCode, pubData, cubData); - - EMsg eMsg; - const uint8 *pHdr, *pBody; - uint32 cbHdr, cbBody; - bool result; - if (UnpackRaw(pubData, cubData, eMsg, pHdr, cbHdr, pBody, cbBody)) { - SendJob(eMsg, pBody, cbBody, pHdr, cbHdr); - - if (g_NeedReplaceSend) { - uint32 newSize = 0; - uint8* buf = ReplaceSendPacket(pubData, cbHdr, pHdr, - g_SendNewBody, g_cbSendNewBody, &newSize); - result = buf - ? oBBuildAndAsyncSendFrame(pObject, eWebSocketOpCode, buf, newSize) - : oBBuildAndAsyncSendFrame(pObject, eWebSocketOpCode, pubData, cubData); - } else { - result = oBBuildAndAsyncSendFrame(pObject, eWebSocketOpCode, pubData, cubData); - } - } else { - result = oBBuildAndAsyncSendFrame(pObject, eWebSocketOpCode, pubData, cubData); - } - - return result; - } - - HOOK_FUNC(RecvPkt, void*, void* pThis, CNetPacket* pPacket) - { - Hooks_NetPacket_RichPresence::TryInject( - pThis, pPacket, - [](void* pT, CNetPacket* pP) -> bool { return oRecvPkt(pT, pP) != nullptr; }); - - EMsg eMsg; - const uint8 *pBody, *pHdr; - uint32 cbBody, cbHdr; - if (UnpackRaw(pPacket->m_pubData, pPacket->m_cubData, - eMsg, pHdr, cbHdr, pBody, cbBody)) { - g_ResizedInPlace = false; - RecvJob(eMsg, pBody, cbBody, pHdr, cbHdr); - - if (g_ResizedInPlace && g_NeedReplaceHdr) { - // Body shrunk in-place + header changed -> full replace via pool - ReplaceRecvPacket(pPacket, - g_NewHdr, g_cbNewHdr, - pBody, g_NewBodySize); - } else if (g_ResizedInPlace) { - pPacket->m_cubData = sizeof(MsgHdr) + cbHdr + g_NewBodySize; - } else if (g_NeedReplaceHdr || g_NeedReplaceBody) { - ReplaceRecvPacket(pPacket, - g_NeedReplaceHdr ? g_NewHdr : pHdr, - g_NeedReplaceHdr ? g_cbNewHdr : cbHdr, - g_NeedReplaceBody ? g_NewBody : pBody, - g_NeedReplaceBody ? g_cbNewBody : cbBody); - } - } - - return oRecvPkt(pThis, pPacket); - } - -} // anonymous namespace - - -namespace Hooks_NetPacket { - void Install() { - RESOLVE_C(PchMsgNameFromEMsg); - HOOK_BEGIN(); - INSTALL_HOOK_C(BBuildAndAsyncSendFrame); - INSTALL_HOOK_C(RecvPkt); - HOOK_END(); - } - - void Uninstall() { - UNHOOK_BEGIN(); - UNINSTALL_HOOK(BBuildAndAsyncSendFrame); - UNINSTALL_HOOK(RecvPkt); - UNHOOK_END(); - } -} + if (ticket.empty()) return; + + if (!resp.mutable_encrypted_app_ticket()->ParseFromArray( + ticket.data(), static_cast(ticket.size()))) { + LOG_NETPACKET_WARN("ClientRequestEncryptedAppTicketResponse: failed to ParseFromArray EncryptedAppTicket"); + return; + } + + resp.set_eresult(k_EResultOK); + + auto encSize = resp.ByteSizeLong(); + if (encSize > sizeof(g_NewBody)) { + LOG_NETPACKET_WARN("ClientRequestEncryptedAppTicketResponse: modified message too large"); + return; + } + if (!resp.SerializeToArray(g_NewBody, sizeof(g_NewBody))) { + LOG_NETPACKET_WARN("ClientRequestEncryptedAppTicketResponse: failed to SerializeToArray modified response"); + return; + } + + LOG_NETPACKET_DEBUG("ClientRequestEncryptedAppTicketResponse: modified body:\n{}", resp.DebugString()); + + g_cbNewBody = static_cast(encSize); + g_NeedReplaceBody = true; + } + +} // namespace Hooks_NetPacket_ETicket + + +// ════════════════════════════════════════════════════════════════ +// Hooks_NetPacket_FamilySharing +// ════════════════════════════════════════════════════════════════ +namespace Hooks_NetPacket_FamilySharing { + + void ClearBody(const uint8*, uint32) + { + LOG_NETPACKET_DEBUG("Clearing family sharing message..."); + g_cbNewBody = 0; + g_NeedReplaceBody = true; + } + +} // namespace Hooks_NetPacket_FamilySharing + + +// ════════════════════════════════════════════════════════════════ +// Hooks_NetPacket_Manifest +// +// Outgoing: ContentServerDirectory.GetManifestRequestCode#1 (eMsg 151) +// Incoming: ContentServerDirectory.GetManifestRequestCode#1 (eMsg 147) +// +// Launches an async HTTP fetch on send; the recv handler waits up to +// 12 s for the result and patches both header (eresult=OK) and body +// (manifest_request_code). On timeout or failure the original +// response passes through unmodified. +// ════════════════════════════════════════════════════════════════ +namespace Hooks_NetPacket_Manifest { + + std::unordered_map> g_CodeFutures; + std::mutex g_CodeMutex; + constexpr uint32 kMaxWaitSeconds = 12; + + bool HandleSend(const uint8* pBody, uint32 cbBody, + const uint8* pHdr, uint32 cbHdr) + { + CContentServerDirectory_GetManifestRequestCode_Request req; + if (!req.ParseFromArray(pBody, cbBody)) { + LOG_MANIFEST_WARN("GetManifestRequestCode: failed to parse request"); + return false; + } + if (!req.has_depot_id() || !req.has_manifest_id()) return false; + if (!LuaConfig::HasDepot(req.depot_id())) return false; + + CMsgProtoBufHeader hdr; + if (!hdr.ParseFromArray(pHdr, cbHdr) || !hdr.has_jobid_source()) { + LOG_MANIFEST_WARN("GetManifestRequestCode: missing jobid_source in header"); + return false; + } + + uint64 jobId = hdr.jobid_source(); + uint64 manifestGid = req.manifest_id(); + uint32 depotId = req.depot_id(); + uint32 appId = req.has_app_id() ? req.app_id() : 0; + + LOG_MANIFEST_DEBUG("GetManifestRequestCode send: depot={} gid={} jobid={} app_id={}", + depotId, manifestGid, jobId, appId); + + auto task = std::async(std::launch::async, + [manifestGid, depotId, appId]() -> uint64 { + uint64 code = 0; + ManifestClient::FetchManifestRequestCode(manifestGid, &code, appId, depotId); + return code; + }); + + { + std::lock_guard lock(g_CodeMutex); + g_CodeFutures[jobId] = task.share(); + } + + return false; // Don't modify the outgoing request body + } + + void HandleRecv(const uint8* pBody, uint32 cbBody, + const uint8* pHdr, uint32 cbHdr) + { + CMsgProtoBufHeader hdr; + if (!hdr.ParseFromArray(pHdr, cbHdr)){ + LOG_MANIFEST_WARN("GetManifestRequestCode recv: failed to ParseFromArray original header"); + return; + } + + uint64 jobId = hdr.jobid_target(); + std::shared_future future; + + { + std::lock_guard lock(g_CodeMutex); + auto it = g_CodeFutures.find(jobId); + if (it == g_CodeFutures.end()) return; + future = it->second; + g_CodeFutures.erase(it); // Always clean up immediately + } + // Wait up to kMaxWaitSeconds seconds for the HTTP fetch to complete + auto status = future.wait_for(std::chrono::seconds(kMaxWaitSeconds)); + if (status != std::future_status::ready) { + LOG_MANIFEST_WARN("GetManifestRequestCode recv: HTTP timed out for jobid={}", jobId); + return; + } + + uint64 code = future.get(); + if (!code) { + LOG_MANIFEST_WARN("GetManifestRequestCode recv: HTTP returned 0 for jobid={}", jobId); + return; + } + + LOG_MANIFEST_DEBUG("GetManifestRequestCode recv: injecting code={} for jobid={}", + code, jobId); + + // Header: set eresult=OK + hdr.set_eresult(static_cast(k_EResultOK)); + g_cbNewHdr = static_cast(hdr.ByteSizeLong()); + if (g_cbNewHdr > kMaxHdrSize || !hdr.SerializeToArray(g_NewHdr, kMaxHdrSize)){ + LOG_MANIFEST_WARN("GetManifestRequestCode recv: failed to SerializeToArray modified header," + "g_cbNewHdr: {}, kMaxHdrSize: {}", g_cbNewHdr, kMaxHdrSize); + return; + } + g_NeedReplaceHdr = true; + + // Body: set manifest_request_code + CContentServerDirectory_GetManifestRequestCode_Response resp; + resp.set_manifest_request_code(code); + + g_cbNewBody = static_cast(resp.ByteSizeLong()); + if (g_cbNewBody > kMaxBodySize || !resp.SerializeToArray(g_NewBody, kMaxBodySize)){ + LOG_MANIFEST_WARN("GetManifestRequestCode recv: failed to SerializeToArray modified body," + "g_cbNewBody:{}, kMaxBodySize:{}", g_cbNewBody, kMaxBodySize); + return; + } + g_NeedReplaceBody = true; + } + +} // namespace Hooks_NetPacket_Manifest + + +// ════════════════════════════════════════════════════════════════ +// Hooks_NetPacket_RichPresence +// +// Outgoing: CMsgClientGamesPlayed (eMsg 742 / 5410) +// Incoming: CMsgClientPersonaState (eMsg 766) +// +// The server drops friend-side broadcasts for unowned AppIds, so the +// banner stays on the last cached state. Cache real self-pushes and +// re-deliver a patched copy through oRecvPkt by borrowing the next +// carrier packet's data pointer. +// ════════════════════════════════════════════════════════════════ +namespace Hooks_NetPacket_RichPresence { + + AppId_t g_PlayingAppId = 0; + uint64 g_LocalSteamId = 0; + + // Most recent self-PersonaState bytes captured from a real server push. + // Reused as the template every game launch. + uint8 g_SelfHdr [kMaxHdrSize]; + uint32 g_cbSelfHdr = 0; + uint8 g_SelfBody[kMaxBodySize]; + uint32 g_cbSelfBody = 0; + bool g_HaveSelfCached = false; + + // Manufactured PersonaState packet (eMsg 766) ready to inject. + uint8 g_InjectPkt[kMaxPacketSize]; + uint32 g_cbInjectPkt = 0; + bool g_InjectPending = false; + + // Rich presence KVs per AppId, captured from outbound + // CMsgClientRichPresenceUpload. Per-AppId so a multi-game stack + // does not conflate KV state. + std::unordered_map>> g_RPKvsByAppId; + + // Walk Steam's binary KV1 stream (a top-level "RP" struct around + // string KVs) and collect every string KV at any depth. Type 0x00 + // starts a struct, 0x01 a string KV, 0x08 ends a struct. String KVs + // are null-terminated key + null-terminated value. + static void ExtractStringKVs(const uint8* data, uint32 size, + std::vector>& out) + { + uint32 pos = 0; + int depth = 0; + auto readCStr = [&](std::string& s) -> bool { + uint32 start = pos; + while (pos < size && data[pos] != 0) ++pos; + if (pos >= size) return false; + s.assign(reinterpret_cast(data + start), pos - start); + ++pos; + return true; + }; + while (pos < size) { + uint8 type = data[pos++]; + if (type == 0x08) { + if (depth > 0) { --depth; continue; } + break; + } + if (type == 0x00) { + std::string name; + if (!readCStr(name)) return; + ++depth; + } else if (type == 0x01) { + std::string key, value; + if (!readCStr(key) || !readCStr(value)) return; + out.emplace_back(std::move(key), std::move(value)); + } else { + return; + } + } + } + + // Patch the self Friend entry with the appid (0 = stopped) and per-app + // KVs. Mask status_flags's RichPresence bit (0x1000) on appid + empty + // KVs so a freshly launched game's first inject does not wipe the UI's + // m_mapRichPresence, which is rebuilt from rich_presence() whenever + // that bit is set. + static void ApplyGameFields(CMsgClientPersonaState& msg, + CMsgClientPersonaState::Friend* entry, + AppId_t appid) + { + // EClientPersonaStateFlag::k_EClientPersonaStateFlagRichPresence + constexpr uint32 kStatusFlagRichPresence = 0x1000; + + if (appid) { + entry->set_game_played_app_id(appid); + entry->set_gameid(static_cast(appid)); + std::string name = Hooks_Misc::GetGameNameByAppID(appid); + if (!name.empty()) entry->set_game_name(name); + entry->clear_rich_presence(); + auto it = g_RPKvsByAppId.find(appid); + const bool hasKvs = (it != g_RPKvsByAppId.end()) && !it->second.empty(); + if (hasKvs) { + for (const auto& [k, v] : it->second) { + auto* kv = entry->add_rich_presence(); + kv->set_key(k); + kv->set_value(v); + } + msg.set_status_flags(msg.status_flags() | kStatusFlagRichPresence); + } else { + msg.set_status_flags(msg.status_flags() & ~kStatusFlagRichPresence); + } + } else { + entry->clear_game_played_app_id(); + entry->clear_gameid(); + entry->clear_game_name(); + entry->clear_rich_presence(); + msg.set_status_flags(msg.status_flags() | kStatusFlagRichPresence); + } + } + + static bool BuildInject(AppId_t appid) + { + if (!g_HaveSelfCached) return false; + + CMsgClientPersonaState msg; + if (!msg.ParseFromArray(g_SelfBody, g_cbSelfBody)) return false; + + // Find our entry (a self-push always contains it). + CMsgClientPersonaState::Friend* entry = nullptr; + for (int i = 0; i < msg.friends_size(); ++i) { + auto* f = msg.mutable_friends(i); + if (f->has_friendid() && f->friendid() == g_LocalSteamId) { + entry = f; + break; + } + } + if (!entry) return false; + + ApplyGameFields(msg, entry, appid); + + uint32 hdrSize = g_cbSelfHdr; + uint32 bodySize = static_cast(msg.ByteSizeLong()); + uint32 total = sizeof(MsgHdr) + hdrSize + bodySize; + if (total > sizeof(g_InjectPkt) || bodySize > kMaxBodySize) { + LOG_RICHPRESENCE_WARN("Inject packet too large ({} bytes)", total); + return false; + } + + auto* mhdr = reinterpret_cast(g_InjectPkt); + mhdr->eMsg = static_cast( + static_cast(k_EMsgClientPersonaState) | kMsgHdrProtoFlag); + mhdr->headerLength = hdrSize; + memcpy(g_InjectPkt + sizeof(MsgHdr), g_SelfHdr, hdrSize); + if (!msg.SerializeToArray(g_InjectPkt + sizeof(MsgHdr) + hdrSize, bodySize)) + return false; + + g_cbInjectPkt = total; + LOG_RICHPRESENCE_INFO("Built inject for appid {} ({} bytes)", appid, total); + return true; + } + + // Decode outbound CMsgClientRichPresenceUpload into per-AppId KVs + // and stage a fresh PersonaState inject. + void TrackRPSend(const uint8* pBody, uint32 cbBody) + { + if (g_LocalSteamId == 0 || g_PlayingAppId == 0) return; + + CMsgClientRichPresenceUpload up; + if (!up.ParseFromArray(pBody, cbBody)) return; + if (!up.has_rich_presence_kv()) return; + + const std::string& kv = up.rich_presence_kv(); + auto& kvs = g_RPKvsByAppId[g_PlayingAppId]; + kvs.clear(); + ExtractStringKVs(reinterpret_cast(kv.data()), + static_cast(kv.size()), kvs); + LOG_RICHPRESENCE_DEBUG("RP upload appid={}: kv_bytes={} extracted={} pairs", + g_PlayingAppId, kv.size(), kvs.size()); + + if (BuildInject(g_PlayingAppId)) g_InjectPending = true; + } + + void TrackSend(const CMsgClientGamesPlayed& msg, const uint8* pHdr, uint32 cbHdr) + { + if (g_LocalSteamId == 0) { + CMsgProtoBufHeader hdr; + if (hdr.ParseFromArray(pHdr, cbHdr) && hdr.has_steamid() && hdr.steamid()) { + g_LocalSteamId = hdr.steamid(); + LOG_RICHPRESENCE_DEBUG("Captured local SteamID 0x{:X}", g_LocalSteamId); + } + } + + // Steam stacks running games in games_played; the banner follows + // the tail (most recently launched). Mirror that — only the + // tail's appid drives our inject. + AppId_t topmost = 0; + if (msg.games_played_size() > 0) { + topmost = static_cast( + msg.games_played(msg.games_played_size() - 1).game_id() & UINT32_MAX); + } + + // Only track when the topmost is an unlocked AppId we can inject for. + // Owned games on top let the server's natural broadcast paint the + // cache; -onlinefix games are already handled by the OnlineFix path. + AppId_t newTracked = 0; + if (topmost != 0 && topmost != kOnlineFixAppId && LuaConfig::HasDepot(topmost)) + newTracked = topmost; + + if (g_PlayingAppId == newTracked) return; + g_PlayingAppId = newTracked; + + if (newTracked != 0) { + LOG_RICHPRESENCE_INFO("Tracking topmost appid {}", newTracked); + if (BuildInject(newTracked)) g_InjectPending = true; + } else if (topmost == 0) { + // Stack went empty — inject a clear so the cache reverts. + LOG_RICHPRESENCE_DEBUG("GamesPlayed empty, scheduling cache clear"); + if (BuildInject(0)) g_InjectPending = true; + } else { + // Topmost is owned (or -onlinefix); let the server's broadcast + // paint it. Skipping the clear-inject here avoids a brief + // "Online" flicker between our drop and the server's push. + LOG_RICHPRESENCE_DEBUG("Topmost is appid {} (owned or onlinefix); deferring to server", topmost); + } + } + + // Cache real self-pushes as the inject template; if we are tracking + // an unowned game, patch the live message in place so a periodic + // refresh does not overwrite the injected game info. + bool HandleRecv(const uint8* pBody, uint32 cbBody, const uint8* pHdr, uint32 cbHdr) + { + CMsgClientPersonaState msg; + if (!msg.ParseFromArray(pBody, cbBody)) return false; + + CMsgClientPersonaState::Friend* selfEntry = nullptr; + for (int i = 0; i < msg.friends_size(); ++i) { + auto* f = msg.mutable_friends(i); + if (f->has_friendid() && f->friendid() == g_LocalSteamId) { + selfEntry = f; + break; + } + } + if (!selfEntry) return false; + + LOG_RICHPRESENCE_DEBUG( + "Recv self PersonaState: status_flags=0x{:X} friends_size={}", + msg.status_flags(), msg.friends_size()); + + if (cbHdr <= sizeof(g_SelfHdr) && cbBody <= sizeof(g_SelfBody)) { + memcpy(g_SelfHdr, pHdr, cbHdr); + memcpy(g_SelfBody, pBody, cbBody); + g_cbSelfHdr = cbHdr; + g_cbSelfBody = cbBody; + g_HaveSelfCached = true; + } + + if (g_PlayingAppId == 0) return false; + + ApplyGameFields(msg, selfEntry, g_PlayingAppId); + g_cbNewBody = static_cast(msg.ByteSizeLong()); + if (g_cbNewBody > kMaxBodySize) { + LOG_RICHPRESENCE_WARN("In-place patch too large ({} bytes)", g_cbNewBody); + return false; + } + if (!msg.SerializeToArray(g_NewBody, kMaxBodySize)) { + LOG_RICHPRESENCE_WARN("In-place patch SerializeToArray failed"); + return false; + } + LOG_RICHPRESENCE_INFO("Patched live self push with appid {}", g_PlayingAppId); + return true; + } + + // Deliver the pending manufactured PersonaState by borrowing the + // carrier's data pointer for one oRecvPkt call, then restore. + void TryInject(void* pThis, CNetPacket* pCarrier, + bool (*invokeOriginal)(void*, CNetPacket*)) + { + if (!g_InjectPending || g_cbInjectPkt == 0) return; + g_InjectPending = false; + + uint8* origData = pCarrier->m_pubData; + uint32 origSize = pCarrier->m_cubData; + pCarrier->m_pubData = g_InjectPkt; + pCarrier->m_cubData = g_cbInjectPkt; + invokeOriginal(pThis, pCarrier); + pCarrier->m_pubData = origData; + pCarrier->m_cubData = origSize; + LOG_RICHPRESENCE_INFO("Delivered manufactured self-PersonaState ({} bytes)", g_cbInjectPkt); + } + +} // namespace Hooks_NetPacket_RichPresence + + +// ════════════════════════════════════════════════════════════════ +// Hooks_NetPacket_OnlineFix +// +// Outgoing: CMsgClientGamesPlayed (eMsg 742 / 5410) +// +// When a game launched with -onlinefix reports appid 480, replace +// game_extra_info with the real game's localized name so friends +// see the correct title. +// ════════════════════════════════════════════════════════════════ +namespace Hooks_NetPacket_OnlineFix { + + bool HandleSend(const uint8* pBody, uint32 cbBody, + const uint8* pHdr, uint32 cbHdr) + { + CMsgClientGamesPlayed msg; + if (!msg.ParseFromArray(pBody, cbBody)) { + LOG_ONLINEFIX_WARN("OnlineFix: failed to parse CMsgClientGamesPlayed"); + return false; + } + LOG_ONLINEFIX_DEBUG("OnlineFix: original body:\n{}", msg.DebugString()); + + Hooks_NetPacket_RichPresence::TrackSend(msg, pHdr, cbHdr); + + bool patched = false; + for (int i = 0; i < msg.games_played_size(); ++i) { + auto* game = msg.mutable_games_played(i); + AppId_t appid = static_cast(game->game_id() & UINT32_MAX); + + // SpawnProcess rewrites pGameID to 480, so game_id is already 480. + // Fill game_extra_info with the real game name. + if (appid == kOnlineFixAppId) { + AppId_t realAppId = Hooks_Misc::ResolveAppId(); + if (realAppId && LuaConfig::HasDepot(realAppId)) { + std::string name = Hooks_Misc::GetGameNameByAppID(realAppId); + if (!name.empty()) { + game->set_game_extra_info(name); + patched = true; + LOG_ONLINEFIX_INFO("OnlineFix: 480 -> name '{}' (real appid {})", + name, realAppId); + } + } + } + } + + if (!patched) return false; + + g_cbSendNewBody = static_cast(msg.ByteSizeLong()); + if (g_cbSendNewBody > kMaxBodySize) { + LOG_ONLINEFIX_WARN("OnlineFix: encoded size {} exceeds buffer", g_cbSendNewBody); + return false; + } + if (!msg.SerializeToArray(g_SendNewBody, kMaxBodySize)) { + LOG_ONLINEFIX_WARN("OnlineFix: failed to SerializeToArray"); + return false; + } + + LOG_ONLINEFIX_DEBUG("OnlineFix: modified body:\n{}", msg.DebugString()); + return true; + } + +} // namespace Hooks_NetPacket_OnlineFix + + +// ════════════════════════════════════════════════════════════════ +// Hooks_NetPacket_Cloud +// +// Steam Cloud save redirection via CloudRedirect (cloud_redirect.dll). +// +// Outgoing: ServiceMethodCallFromClient (eMsg 151) with target_job_name +// "Cloud.*" for an addappid()-unlocked game. +// Incoming: a synthesized ServiceMethodResponse (eMsg 147) carrying the +// answer produced by CloudRedirect, correlated by jobid. +// +// CloudRedirect answers the RPC locally (reading/writing the real save +// bytes to the user's cloud provider). We therefore SUPPRESS the outbound +// request (it must not reach Valve) and DELIVER the response the same way +// the RichPresence path injects packets: by borrowing the next inbound +// "carrier" packet for one oRecvPkt call (CloudRedirect's "Approach D"). +// ════════════════════════════════════════════════════════════════ +namespace Hooks_NetPacket_Cloud { + + std::mutex g_queueMutex; + std::deque> g_pending; // ready-to-inject 147 packets + uint64 g_localSteamId = 0; + + // ── minimal top-level protobuf varint field reader ────────── + // Avoids pulling the Cloud.* request message definitions into the + // proto set just to read one appid field. + static bool ReadVarint(const uint8* d, uint32 size, uint32& pos, uint64& out) { + out = 0; + int shift = 0; + while (pos < size) { + uint8 b = d[pos++]; + out |= static_cast(b & 0x7F) << shift; + if (!(b & 0x80)) return true; + shift += 7; + if (shift >= 64) return false; + } + return false; + } + + static bool FindVarintField(const uint8* d, uint32 size, uint32 target, uint64& out) { + uint32 pos = 0; + while (pos < size) { + uint64 tag; + if (!ReadVarint(d, size, pos, tag)) return false; + const uint32 field = static_cast(tag >> 3); + const uint32 wireType = static_cast(tag & 7); + if (field == target && wireType == 0) + return ReadVarint(d, size, pos, out); + + switch (wireType) { + case 0: { uint64 tmp; if (!ReadVarint(d, size, pos, tmp)) return false; break; } + case 1: if (pos + 8 > size) return false; pos += 8; break; + case 5: if (pos + 4 > size) return false; pos += 4; break; + case 2: { + uint64 len; + if (!ReadVarint(d, size, pos, len)) return false; + if (pos + len > size) return false; + pos += static_cast(len); + break; + } + default: return false; // groups (3/4) — bail + } + } + return false; + } + + // appid lives in field 1 of every Cloud.* request except + // ClientCommitFileUpload, where it is field 2 (mirrors CloudRedirect's + // CloudRpcUtils::ExtractAppId). + static uint32 ExtractAppId(const char* jobName, const uint8* body, uint32 cbBody) { + uint32 fieldNum = 1; + if (std::strcmp(jobName, "Cloud.ClientCommitFileUpload#1") == 0) + fieldNum = 2; + uint64 v = 0; + if (FindVarintField(body, cbBody, fieldNum, v)) + return static_cast(v); + return 0; + } + + // Returns true when CloudRedirect handled the request — the caller must + // then suppress the outbound frame. + bool HandleSend(const char* jobName, + const uint8* pBody, uint32 cbBody, + const uint8* pHdr, uint32 cbHdr) + { + if (!CloudRedirectHost::IsActive()) return false; + + CMsgProtoBufHeader reqHdr; + if (!reqHdr.ParseFromArray(pHdr, cbHdr)) return false; + if (reqHdr.has_steamid() && reqHdr.steamid()) + g_localSteamId = reqHdr.steamid(); + + const uint32 appId = ExtractAppId(jobName, pBody, cbBody); + if (appId == 0 || !CloudRedirectHost::IsApp(appId)) return false; + + const uint32 accountId = static_cast(g_localSteamId & 0xFFFFFFFFull); + + static thread_local uint8 respBuf[kMaxBodySize]; + uint32 respLen = 0; + int32_t eresult = 2; // EResult::Fail + if (!CloudRedirectHost::HandleCloudRpc(jobName, appId, accountId, + pBody, cbBody, + respBuf, static_cast(sizeof(respBuf)), + &respLen, &eresult)) { + return false; // not a namespace app / unrecognised — let it pass through + } + + // Build the 147 ServiceMethodResponse correlated to the request job. + CMsgProtoBufHeader respHdr; + if (reqHdr.has_jobid_source()) respHdr.set_jobid_target(reqHdr.jobid_source()); + respHdr.set_eresult(eresult); + respHdr.set_target_job_name(jobName); + + const uint32 cbRespHdr = static_cast(respHdr.ByteSizeLong()); + const uint32 total = sizeof(MsgHdr) + cbRespHdr + respLen; + if (cbRespHdr > kMaxHdrSize || respLen > kMaxBodySize || total > kMaxPacketSize) { + // Can't deliver a response this big — fall through so Steam errors + // normally instead of leaving the job hung. + LOG_NETPACKET_WARN("Cloud: {} response too large ({} bytes), passing through", + jobName, total); + return false; + } + + std::vector pkt(total); + auto* mhdr = reinterpret_cast(pkt.data()); + mhdr->eMsg = static_cast( + static_cast(k_EMsgServiceMethodResponse) | kMsgHdrProtoFlag); + mhdr->headerLength = cbRespHdr; + if (!respHdr.SerializeToArray(pkt.data() + sizeof(MsgHdr), cbRespHdr)) + return false; + if (respLen) + memcpy(pkt.data() + sizeof(MsgHdr) + cbRespHdr, respBuf, respLen); + + { + std::lock_guard lk(g_queueMutex); + if (g_pending.size() < 64) + g_pending.push_back(std::move(pkt)); + } + LOG_NETPACKET_DEBUG("Cloud: handled {} app={} -> queued {}-byte response (eresult={})", + jobName, appId, total, eresult); + return true; + } + + // Deliver any queued cloud responses by borrowing the carrier packet for + // one oRecvPkt call each (same trick as RichPresence::TryInject). Runs on + // the network thread from inside the RecvPkt hook. + void Drain(void* pThis, CNetPacket* pCarrier, + bool (*invokeOriginal)(void*, CNetPacket*)) + { + for (;;) { + std::vector pkt; + { + std::lock_guard lk(g_queueMutex); + if (g_pending.empty()) return; + pkt = std::move(g_pending.front()); + g_pending.pop_front(); + } + + uint8* origData = pCarrier->m_pubData; + uint32 origSize = pCarrier->m_cubData; + pCarrier->m_pubData = pkt.data(); + pCarrier->m_cubData = static_cast(pkt.size()); + invokeOriginal(pThis, pCarrier); + pCarrier->m_pubData = origData; + pCarrier->m_cubData = origSize; + LOG_NETPACKET_DEBUG("Cloud: delivered {}-byte response", pkt.size()); + } + } + +} // namespace Hooks_NetPacket_Cloud + + +// ════════════════════════════════════════════════════════════════ +// Dispatch +// ════════════════════════════════════════════════════════════════ +namespace { + + bool SendServiceJob(const char* targetJobName, + const uint8* pBody, uint32 cbBody, + const uint8* pHdr, uint32 cbHdr) + { + LOG_NETPACKET_DEBUG("Send target_job_name: {}", targetJobName); + + // Steam Cloud save redirection: hand every "Cloud.*" request to + // CloudRedirect. If it answers, suppress the outbound frame (the + // synthesized response is delivered from the RecvPkt hook). + if (std::strncmp(targetJobName, "Cloud.", 6) == 0) { + if (Hooks_NetPacket_Cloud::HandleSend(targetJobName, pBody, cbBody, pHdr, cbHdr)) + g_SuppressSend = true; + return false; // never body-replace a cloud frame + } + + switch (Fnv1aHash(targetJobName)) { + + case HASH_JOB_GetUserStats: + return Hooks_NetPacket_UserStats::HandleSend_GetUserStats(pBody, cbBody, pHdr, cbHdr); + + case HASH_JOB_GetManifestRequestCode: + return Hooks_NetPacket_Manifest::HandleSend(pBody, cbBody, pHdr, cbHdr); + + // ---- add new 151 service methods here ---- + } + return false; + } + + void SendJob(EMsg eMsg, const uint8* pBody, uint32 cbBody, + const uint8* pHdr, uint32 cbHdr) + { + g_NeedReplaceSend = false; + g_SuppressSend = false; + + LOG_NETPACKET_DEBUG("Send eMsg {}({}) (cbBody={}, cbHdr={})", + MsgName(eMsg), static_cast(eMsg), cbBody, cbHdr); + + switch (eMsg) { + + case k_EMsgServiceMethodCallFromClient: { // 151 + CMsgProtoBufHeader hdr; + if (hdr.ParseFromArray(pHdr, cbHdr) && hdr.has_target_job_name()) { + g_NeedReplaceSend = SendServiceJob(hdr.target_job_name().c_str(), pBody, cbBody, pHdr, cbHdr); + } + return; + } + + case k_EMsgClientPICSProductInfoRequest: // 8903 + g_NeedReplaceSend = Hooks_NetPacket_AccessToken::HandleSend(pBody, cbBody); + return; + + case k_EMsgClientGamesPlayed: // 742 + case k_EMsgClientGamesPlayedWithDataBlob: // 5410 + g_NeedReplaceSend = Hooks_NetPacket_OnlineFix::HandleSend(pBody, cbBody, pHdr, cbHdr); + return; + + case k_EMsgClientRichPresenceUpload: // 7501 + Hooks_NetPacket_RichPresence::TrackRPSend(pBody, cbBody); + return; + + case k_EMsgClientGetUserStats: // 818 + g_NeedReplaceSend = Hooks_NetPacket_UserStats::HandleSend_ClientGetUserStats(pBody, cbBody); + return; + + default: + return; + } + } + + void RecvServiceJob(const char* targetJobName, + const uint8* pBody, uint32 cbBody, + const uint8* pHdr, uint32 cbHdr) + { + LOG_NETPACKET_DEBUG("Recv target_job_name: {}", targetJobName); + g_NeedReplaceBody = false; + g_NeedReplaceHdr = false; + + switch (Fnv1aHash(targetJobName)) { + + case HASH_JOB_NotifyRunningApps: + Hooks_NetPacket_FamilySharing::ClearBody(pBody, cbBody); + return; + + case HASH_JOB_GetUserStats: + Hooks_NetPacket_UserStats::HandleRecv_GetUserStatsResponse(pHdr, cbHdr, pBody, cbBody); + return; + + case HASH_JOB_GetManifestRequestCode: + Hooks_NetPacket_Manifest::HandleRecv(pBody, cbBody, pHdr, cbHdr); + return; + + // ---- add new 147 service methods here ---- + } + } + + void RecvJob(EMsg eMsg, const uint8* pBody, uint32 cbBody, + const uint8* pHdr, uint32 cbHdr) + { + g_NeedReplaceBody = false; + g_NeedReplaceHdr = false; + + if(eMsg == k_EMsgMulti) { + LOG_NETPACKET_TRACE("Received k_EMsgMulti, skipping dispatch"); + return; + } + LOG_NETPACKET_DEBUG("Recv eMsg {}({}) (cbBody={}, cbHdr={})", + MsgName(eMsg), static_cast(eMsg), cbBody, cbHdr); + + switch (eMsg) { + + case k_EMsgServiceMethodResponse: { // 147 + CMsgProtoBufHeader hdr; + if (hdr.ParseFromArray(pHdr, cbHdr) && hdr.has_target_job_name()) + RecvServiceJob(hdr.target_job_name().c_str(), pBody, cbBody, pHdr, cbHdr); + return; + } + + // migrated to IPC Layer Hooks_IPC_ISteamUser::GetEncryptedAppTicketResponse + // case k_EMsgClientRequestEncryptedAppTicketResponse: // 5527 + // Hooks_NetPacket_ETicket::HandleEncryptedAppTicketResponse(pBody, cbBody); + // return; + + case k_EMsgClientGetUserStatsResponse: // 819 + g_NeedReplaceBody = Hooks_NetPacket_UserStats::HandleRecv_ClientGetUserStatsResponse( + pBody, cbBody); + return; + + case k_EMsgClientSharedLibraryStopPlaying: // 9406 + Hooks_NetPacket_FamilySharing::ClearBody(pBody, cbBody); + return; + + case k_EMsgClientPersonaState: // 766 + g_NeedReplaceBody = Hooks_NetPacket_RichPresence::HandleRecv(pBody, cbBody, pHdr, cbHdr); + return; + + default: + return; + } + } + + // ════════════════════════════════════════════════════════════ + // Hooks + // ════════════════════════════════════════════════════════════ + + HOOK_FUNC(BBuildAndAsyncSendFrame, bool, + void* pObject, EWebSocketOpCode eWebSocketOpCode, + uint8* pubData, uint32 cubData) + { + if (eWebSocketOpCode != k_eWebSocketOpCode_Binary) + return oBBuildAndAsyncSendFrame(pObject, eWebSocketOpCode, pubData, cubData); + + EMsg eMsg; + const uint8 *pHdr, *pBody; + uint32 cbHdr, cbBody; + bool result; + if (UnpackRaw(pubData, cubData, eMsg, pHdr, cbHdr, pBody, cbBody)) { + SendJob(eMsg, pBody, cbBody, pHdr, cbHdr); + + if (g_SuppressSend) { + // CloudRedirect answered this RPC locally; do not forward to + // Valve. Report success so Steam treats the frame as sent. + return true; + } + + if (g_NeedReplaceSend) { + uint32 newSize = 0; + uint8* buf = ReplaceSendPacket(pubData, cbHdr, pHdr, + g_SendNewBody, g_cbSendNewBody, &newSize); + result = buf + ? oBBuildAndAsyncSendFrame(pObject, eWebSocketOpCode, buf, newSize) + : oBBuildAndAsyncSendFrame(pObject, eWebSocketOpCode, pubData, cubData); + } else { + result = oBBuildAndAsyncSendFrame(pObject, eWebSocketOpCode, pubData, cubData); + } + } else { + result = oBBuildAndAsyncSendFrame(pObject, eWebSocketOpCode, pubData, cubData); + } + + return result; + } + + HOOK_FUNC(RecvPkt, void*, void* pThis, CNetPacket* pPacket) + { + Hooks_NetPacket_RichPresence::TryInject( + pThis, pPacket, + [](void* pT, CNetPacket* pP) -> bool { return oRecvPkt(pT, pP) != nullptr; }); + + Hooks_NetPacket_Cloud::Drain( + pThis, pPacket, + [](void* pT, CNetPacket* pP) -> bool { return oRecvPkt(pT, pP) != nullptr; }); + + EMsg eMsg; + const uint8 *pBody, *pHdr; + uint32 cbBody, cbHdr; + if (UnpackRaw(pPacket->m_pubData, pPacket->m_cubData, + eMsg, pHdr, cbHdr, pBody, cbBody)) { + g_ResizedInPlace = false; + RecvJob(eMsg, pBody, cbBody, pHdr, cbHdr); + + if (g_ResizedInPlace && g_NeedReplaceHdr) { + // Body shrunk in-place + header changed -> full replace via pool + ReplaceRecvPacket(pPacket, + g_NewHdr, g_cbNewHdr, + pBody, g_NewBodySize); + } else if (g_ResizedInPlace) { + pPacket->m_cubData = sizeof(MsgHdr) + cbHdr + g_NewBodySize; + } else if (g_NeedReplaceHdr || g_NeedReplaceBody) { + ReplaceRecvPacket(pPacket, + g_NeedReplaceHdr ? g_NewHdr : pHdr, + g_NeedReplaceHdr ? g_cbNewHdr : cbHdr, + g_NeedReplaceBody ? g_NewBody : pBody, + g_NeedReplaceBody ? g_cbNewBody : cbBody); + } + } + + return oRecvPkt(pThis, pPacket); + } + +} // anonymous namespace + + +namespace Hooks_NetPacket { + void Install() { + RESOLVE_C(PchMsgNameFromEMsg); + HOOK_BEGIN(); + INSTALL_HOOK_C(BBuildAndAsyncSendFrame); + INSTALL_HOOK_C(RecvPkt); + HOOK_END(); + } + + void Uninstall() { + UNHOOK_BEGIN(); + UNINSTALL_HOOK(BBuildAndAsyncSendFrame); + UNINSTALL_HOOK(RecvPkt); + UNHOOK_END(); + } +} diff --git a/src/Utils/CloudRedirect/CloudRedirectHost.cpp b/src/Utils/CloudRedirect/CloudRedirectHost.cpp new file mode 100644 index 0000000..3a593f9 --- /dev/null +++ b/src/Utils/CloudRedirect/CloudRedirectHost.cpp @@ -0,0 +1,168 @@ +#include "CloudRedirectHost.h" + +#include "OSTPlatform/include/DynamicLibrary.h" +#include "Utils/Config/Config.h" +#include "Utils/Config/LuaConfig.h" +#include "Utils/Logging/Log.h" + +#include +#include +#include +#include + +namespace CloudRedirectHost { +namespace { + + // --- CloudRedirect third-party client ABI (CloudRedirect/src/common/cr_api.h) + // Declared locally so OpenSteamTool does not need CloudRedirect's headers. + using CR_NotifyFn = void (*)(int level, const char* title, const char* message); + using CR_InitCloudSave_t = bool (*)(const char* steamPath, CR_NotifyFn notify); + using CR_HandleCloudRpc_t = bool (*)(const char* method, uint32_t appId, uint32_t accountId, + const uint8_t* reqBody, uint32_t reqLen, + uint8_t* respBuf, uint32_t respMaxLen, + uint32_t* respLen, int32_t* eresult); + using CR_AddApp_t = void (*)(uint32_t appId); + using CR_RemoveApp_t = void (*)(uint32_t appId); + using CR_IsApp_t = bool (*)(uint32_t appId); + using CR_SetApps_t = void (*)(const uint32_t* appIds, uint32_t count); + using CR_Shutdown_t = void (*)(); + + std::mutex g_mutex; + std::atomic g_active{false}; + OSTPlatform::DynamicLibrary::ModuleHandle g_module = nullptr; + + CR_InitCloudSave_t g_initCloudSave = nullptr; + CR_HandleCloudRpc_t g_handleCloudRpc = nullptr; + CR_SetApps_t g_setApps = nullptr; + CR_IsApp_t g_isApp = nullptr; + CR_Shutdown_t g_shutdownFn = nullptr; + + // Routes CloudRedirect's notifications into OpenSteamTool's log instead of + // popping a MessageBox from inside Steam. + void CloudNotify(int level, const char* title, const char* message) { + const char* t = title ? title : "CloudRedirect"; + const char* m = message ? message : ""; + switch (level) { + case 2: LOG_ERROR("[CloudRedirect] {}: {}", t, m); break; + case 1: LOG_WARN("[CloudRedirect] {}: {}", t, m); break; + default: LOG_INFO("[CloudRedirect] {}: {}", t, m); break; + } + } + + std::filesystem::path ResolveLibraryPath(const std::string& steamRoot, + const std::string& configured) { + if (configured.empty()) + return std::filesystem::path(steamRoot) / "cloud_redirect.dll"; + + std::filesystem::path lib(configured); + if (lib.is_absolute()) + return lib; + return std::filesystem::path(steamRoot) / lib; + } + + template + bool ResolveSymbol(OSTPlatform::DynamicLibrary::ModuleHandle module, + const char* name, T& out) { + out = reinterpret_cast(OSTPlatform::DynamicLibrary::GetSymbol(module, name)); + if (!out) { + LOG_WARN("CloudRedirect: export {} not found in cloud_redirect.dll", name); + return false; + } + return true; + } + +} // namespace + +void Initialize(const char* steamInstallPath) { + const Config::CloudSettings cloud = Config::GetCloudSettings(); + if (!cloud.enabled) { + LOG_INFO("CloudRedirect: [cloud].enabled is false, cloud save redirection disabled"); + return; + } + if (!steamInstallPath || steamInstallPath[0] == '\0') { + LOG_WARN("CloudRedirect: empty Steam install path, cannot initialise"); + return; + } + + std::lock_guard lock(g_mutex); + if (g_active.load(std::memory_order_acquire)) return; + + const std::filesystem::path libPath = ResolveLibraryPath(steamInstallPath, cloud.library); + if (!std::filesystem::exists(libPath)) { + LOG_WARN("CloudRedirect: cloud_redirect.dll not found at {}", libPath.string()); + return; + } + + g_module = OSTPlatform::DynamicLibrary::Load(libPath); + if (!g_module) { + LOG_WARN("CloudRedirect: failed to load {} (err={})", + libPath.string(), OSTPlatform::DynamicLibrary::GetLastErrorCode()); + return; + } + + bool ok = true; + ok &= ResolveSymbol(g_module, "CR_InitCloudSave", g_initCloudSave); + ok &= ResolveSymbol(g_module, "CR_HandleCloudRpc", g_handleCloudRpc); + ok &= ResolveSymbol(g_module, "CR_SetApps", g_setApps); + ok &= ResolveSymbol(g_module, "CR_IsApp", g_isApp); + ok &= ResolveSymbol(g_module, "CR_Shutdown", g_shutdownFn); + if (!ok) { + LOG_WARN("CloudRedirect: cloud_redirect.dll is missing required exports, disabling"); + g_module = nullptr; + return; + } + + if (!g_initCloudSave(steamInstallPath, &CloudNotify)) { + LOG_WARN("CloudRedirect: CR_InitCloudSave failed, disabling cloud save redirection"); + g_module = nullptr; + return; + } + + g_active.store(true, std::memory_order_release); + LOG_INFO("CloudRedirect: loaded {} and initialised cloud save redirection", + libPath.string()); + + // Push the current unlocked-app set without re-locking g_mutex. + std::vector depots = LuaConfig::GetAllDepotIds(); + std::vector appIds(depots.begin(), depots.end()); + g_setApps(appIds.empty() ? nullptr : appIds.data(), + static_cast(appIds.size())); + LOG_INFO("CloudRedirect: registered {} redirected app(s)", appIds.size()); +} + +void SyncAppSet() { + if (!g_active.load(std::memory_order_acquire) || !g_setApps) return; + + std::vector depots = LuaConfig::GetAllDepotIds(); + std::vector appIds(depots.begin(), depots.end()); + g_setApps(appIds.empty() ? nullptr : appIds.data(), + static_cast(appIds.size())); + LOG_DEBUG("CloudRedirect: re-synced redirected app set ({} app(s))", appIds.size()); +} + +bool IsActive() { + return g_active.load(std::memory_order_acquire); +} + +bool IsApp(uint32_t appId) { + if (!g_active.load(std::memory_order_acquire) || !g_isApp) return false; + return g_isApp(appId); +} + +bool HandleCloudRpc(const char* method, uint32_t appId, uint32_t accountId, + const uint8_t* reqBody, uint32_t reqLen, + uint8_t* respBuf, uint32_t respMaxLen, + uint32_t* respLen, int32_t* eresult) { + if (!g_active.load(std::memory_order_acquire) || !g_handleCloudRpc) return false; + return g_handleCloudRpc(method, appId, accountId, reqBody, reqLen, + respBuf, respMaxLen, respLen, eresult); +} + +void Shutdown() { + std::lock_guard lock(g_mutex); + if (!g_active.exchange(false)) return; + if (g_shutdownFn) g_shutdownFn(); + LOG_INFO("CloudRedirect: shut down"); +} + +} // namespace CloudRedirectHost diff --git a/src/Utils/CloudRedirect/CloudRedirectHost.h b/src/Utils/CloudRedirect/CloudRedirectHost.h new file mode 100644 index 0000000..c2e4a1c --- /dev/null +++ b/src/Utils/CloudRedirect/CloudRedirectHost.h @@ -0,0 +1,40 @@ +#pragma once + +#include + +// Hosts CloudRedirect's prebuilt cloud_redirect.dll inside the Steam process and +// drives its third-party client API (see CloudRedirect/src/common/cr_api.h): +// - loads the DLL and resolves the CR_* exports, +// - calls CR_InitCloudSave in cloud-save-only mode, +// - registers every addappid() game as a redirected ("namespace") app, +// - forwards Cloud.* RPCs from the NetPacket hook to CR_HandleCloudRpc. +// +// All entry points are safe no-ops unless [cloud].enabled is set and the DLL +// loaded and initialised successfully. +namespace CloudRedirectHost { + + // Load + initialise. Called once from the init worker thread, after the + // Steam hooks are installed. steamInstallPath is the Steam root directory. + void Initialize(const char* steamInstallPath); + + // Re-push the current unlocked-app set to CloudRedirect. Called after a Lua + // hot-reload so the redirected set tracks addappid() changes. + void SyncAppSet(); + + // True once the DLL is loaded and CR_InitCloudSave succeeded. + bool IsActive(); + + // Whether CloudRedirect is currently redirecting saves for this appid. + bool IsApp(uint32_t appId); + + // Bridge from the NetPacket hook: forwards a single Cloud.* RPC to + // CloudRedirect. Returns false when not handled (caller chains to original). + bool HandleCloudRpc(const char* method, uint32_t appId, uint32_t accountId, + const uint8_t* reqBody, uint32_t reqLen, + uint8_t* respBuf, uint32_t respMaxLen, + uint32_t* respLen, int32_t* eresult); + + // Teardown. Called from DLL_PROCESS_DETACH. + void Shutdown(); + +} diff --git a/src/Utils/Config/Config.cpp b/src/Utils/Config/Config.cpp index a779053..953f4b6 100644 --- a/src/Utils/Config/Config.cpp +++ b/src/Utils/Config/Config.cpp @@ -19,6 +19,7 @@ namespace { std::string remoteUrlTemplate; bool statsEnableApi = true; InjectionSettings injection; + CloudSettings cloud; }; std::mutex g_mutex; @@ -55,6 +56,8 @@ namespace { injectEnabled = snapshot.injection.enabled; injectLibraryX86 = snapshot.injection.libraryX86; injectLibraryX64 = snapshot.injection.libraryX64; + cloudEnabled = snapshot.cloud.enabled; + cloudLibrary = snapshot.cloud.library; } void ApplyManifestProvider(const std::string& provider) { @@ -155,6 +158,14 @@ namespace { snapshot.injection.libraryX64 = *val; } + // [cloud] + if (auto cloud = tbl["cloud"].as_table()) { + if (auto val = (*cloud)["enabled"].value()) + snapshot.cloud.enabled = *val; + if (auto val = (*cloud)["library"].value()) + snapshot.cloud.library = *val; + } + ApplyManifestProvider(snapshot.manifestProvider); LoadResult result = ApplySnapshotLocked(snapshot); LOG_INFO("Config loaded: manifest.url={} log.level={} lua.paths={} stats.enable_api={} remote.url_template={}", @@ -230,4 +241,12 @@ namespace { return statsEnableApi; } + CloudSettings GetCloudSettings() { + std::lock_guard lock(g_mutex); + return { + cloudEnabled, + cloudLibrary, + }; + } + } diff --git a/src/Utils/Config/Config.h b/src/Utils/Config/Config.h index d69e764..82f145a 100644 --- a/src/Utils/Config/Config.h +++ b/src/Utils/Config/Config.h @@ -21,6 +21,11 @@ namespace Config { std::string libraryX64; }; + struct CloudSettings { + bool enabled = false; + std::string library; + }; + struct LoadResult { bool applied = false; bool luaPathsChanged = false; @@ -34,23 +39,24 @@ namespace Config { std::vector GetLuaPaths(); std::string GetRemoteUrlTemplate(); InjectionSettings GetInjectionSettings(); + CloudSettings GetCloudSettings(); bool GetStatsEnableApi(); - - // [manifest] — provider selection lives in ManifestClient (table-driven). + + // [manifest] — provider selection lives in ManifestClient (table-driven). inline uint32_t manifestTimeoutResolve = 5000; inline uint32_t manifestTimeoutConnect = 5000; inline uint32_t manifestTimeoutSend = 10000; inline uint32_t manifestTimeoutRecv = 10000; - - // [log] - inline LogLevel logLevel = LogLevel::Debug; - - // derived from configPath: /opensteamtool/ - inline std::string logDir; - - // [lua] - inline std::vector luaPaths; - + + // [log] + inline LogLevel logLevel = LogLevel::Debug; + + // derived from configPath: /opensteamtool/ + inline std::string logDir; + + // [lua] + inline std::vector luaPaths; + // [remote] inline std::string remoteUrlTemplate; @@ -62,4 +68,8 @@ namespace Config { inline std::string injectLibraryX86; inline std::string injectLibraryX64; + // [cloud] - optional Steam Cloud save redirection via CloudRedirect. + inline bool cloudEnabled = false; + inline std::string cloudLibrary; + } diff --git a/src/Utils/Config/LuaFileWatcher.cpp b/src/Utils/Config/LuaFileWatcher.cpp index f5bbb07..c28c76f 100644 --- a/src/Utils/Config/LuaFileWatcher.cpp +++ b/src/Utils/Config/LuaFileWatcher.cpp @@ -1,6 +1,7 @@ #include "Hook/Hooks_Package.h" #include "Utils/Config/LuaFileWatcher.h" #include "Utils/Config/LuaConfig.h" +#include "Utils/CloudRedirect/CloudRedirectHost.h" #include "Utils/Logging/Log.h" #include "OSTPlatform/include/DirectoryWatch.h" @@ -115,6 +116,7 @@ void ProcessChanges(const std::vector& changes) { } Hooks_Package::NotifyLicenseChanged(); + CloudRedirectHost::SyncAppSet(); LOG_PACKAGE_DEBUG("Lua refresh completed"); } diff --git a/src/dllmain.cpp b/src/dllmain.cpp index 4307250..ea6c584 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -2,6 +2,7 @@ #include "Hook/HookManager.h" #include "Utils/Config/ConfigFileWatcher.h" #include "Utils/Config/LuaFileWatcher.h" +#include "Utils/CloudRedirect/CloudRedirectHost.h" #include "Utils/SteamMetadata/IPCLoader.h" #include "Utils/SteamMetadata/PatternLoader.h" #include "Utils/SteamMetadata/SteamDiagnostics.h" @@ -81,6 +82,10 @@ static uint32_t InitThread(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) // Surface any functions that FindPattern() could not locate. PatternLoader::ReportMissingFunctions(); + // Optional Steam Cloud save redirection (CloudRedirect). No-op unless + // [cloud].enabled is set and cloud_redirect.dll is present. + CloudRedirectHost::Initialize(SteamInstallPath); + LOG_INFO("OpenSteamTool init complete"); return 0; } @@ -102,6 +107,7 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved) LuaFileWatcher::Stop(); SteamUI::CoreUnhook(); SteamClient::CoreUnhook(); + CloudRedirectHost::Shutdown(); } return TRUE; From ca22ca3564ba908d2c2222e3ad6369f4746d4e9a Mon Sep 17 00:00:00 2001 From: Peron4TheWin Date: Wed, 24 Jun 2026 21:20:08 -0300 Subject: [PATCH 3/3] fix: broadcast rich presence for unowned games to friends Steam drops buddy persona-state broadcasts for unowned appids, so friends never see the user playing an unlocked game. Rewrite the topmost unowned game entry in the outbound CMsgClientGamesPlayed to appid 480 (SpaceWar, owned by every account) and set game_extra_info to the real name. The server broadcasts the status to friends, who see the correct title. --- src/Hook/Hooks_NetPacket.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Hook/Hooks_NetPacket.cpp b/src/Hook/Hooks_NetPacket.cpp index a5022d5..662b029 100644 --- a/src/Hook/Hooks_NetPacket.cpp +++ b/src/Hook/Hooks_NetPacket.cpp @@ -894,6 +894,24 @@ namespace Hooks_NetPacket_OnlineFix { } } + // Rich Presence: rewrite topmost unowned game to appid 480 so the + // server broadcasts it to friends. Steam drops buddy broadcasts for + // unowned appids; everyone owns SpaceWar. game_extra_info carries + // the real name. + if (!patched) { + AppId_t trackedId = Hooks_NetPacket_RichPresence::g_PlayingAppId; + if (trackedId != 0 && trackedId != kOnlineFixAppId) { + std::string name = Hooks_Misc::GetGameNameByAppID(trackedId); + if (!name.empty()) { + auto* topGame = msg.mutable_games_played(msg.games_played_size() - 1); + topGame->set_game_id(kOnlineFixAppId); + topGame->set_game_extra_info(name); + patched = true; + LOG_ONLINEFIX_INFO("RichPresence: appid {} -> 480 + name '{}'", trackedId, name); + } + } + } + if (!patched) return false; g_cbSendNewBody = static_cast(msg.ByteSizeLong());