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;