diff --git a/src/Hook/Hooks_SteamUI.cpp b/src/Hook/Hooks_SteamUI.cpp index 4d1c4e4f..24948509 100644 --- a/src/Hook/Hooks_SteamUI.cpp +++ b/src/Hook/Hooks_SteamUI.cpp @@ -4,12 +4,186 @@ #include "dllmain.h" #include "steam_messages.pb.h" #include "Utils/HookSupport/VehCommon.h" +#include +#include +#include #include +#include +#include #include #include +#include namespace { + using namespace std::chrono_literals; + constexpr int kMaxRetry = 500; + constexpr auto kRetryInterval = 10ms; + + static std::string_view ExtractFileName(std::string_view path) { + const size_t pos = path.find_last_of("\\/"); + return (pos == std::string_view::npos) ? path : path.substr(pos + 1); + } + + static std::wstring_view ExtractFileNameW(std::wstring_view path) { + const size_t pos = path.find_last_of(L"\\/"); + return (pos == std::wstring_view::npos) ? path : path.substr(pos + 1); + } + + static bool IsSteamClientPath(const char* path) { + if (!path) return false; + std::string_view fn = ExtractFileName(path); + switch (fn.size()) { + case 17: return _strnicmp(fn.data(), "steamclient64.dll", 17) == 0; + case 15: return _strnicmp(fn.data(), "steamclient.dll", 15) == 0; + case 13: return _strnicmp(fn.data(), "steamclient64", 13) == 0; + case 11: return _strnicmp(fn.data(), "steamclient", 11) == 0; + default: return false; + } + } + + static bool IsSteamClientPathW(const wchar_t* path) { + if (!path) return false; + std::wstring_view fn = ExtractFileNameW(path); + switch (fn.size()) { + case 17: return _wcsnicmp(fn.data(), L"steamclient64.dll", 17) == 0; + case 15: return _wcsnicmp(fn.data(), L"steamclient.dll", 15) == 0; + case 13: return _wcsnicmp(fn.data(), L"steamclient64", 13) == 0; + case 11: return _wcsnicmp(fn.data(), L"steamclient", 11) == 0; + default: return false; + } + } + + // Original pointers for system module lookup APIs. While the diversion + // module is active, callers asking for "steamclient64.dll" are handed the + // loaded copy (client_hModule) instead of the untouched original. + static decltype(&GetModuleHandleA) oGetModuleHandleA = &GetModuleHandleA; + static decltype(&GetModuleHandleW) oGetModuleHandleW = &GetModuleHandleW; + static decltype(&GetModuleHandleExA) oGetModuleHandleExA = &GetModuleHandleExA; + static decltype(&GetModuleHandleExW) oGetModuleHandleExW = &GetModuleHandleExW; + + HMODULE WINAPI hkGetModuleHandleA(LPCSTR lpModuleName) + { + if (client_hModule && IsSteamClientPath(lpModuleName)) { + return reinterpret_cast(client_hModule); + } + return oGetModuleHandleA(lpModuleName); + } + + HMODULE WINAPI hkGetModuleHandleW(LPCWSTR lpModuleName) + { + if (client_hModule && IsSteamClientPathW(lpModuleName)) { + return reinterpret_cast(client_hModule); + } + return oGetModuleHandleW(lpModuleName); + } + + BOOL WINAPI hkGetModuleHandleExA(DWORD dwFlags, LPCSTR lpModuleName, HMODULE* phModule) + { + if (client_hModule && !(dwFlags & GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS) && + IsSteamClientPath(lpModuleName)) + { + if (!phModule) { + SetLastError(ERROR_INVALID_PARAMETER); + return FALSE; + } + if ((dwFlags & GET_MODULE_HANDLE_EX_FLAG_PIN) && + (dwFlags & GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT)) + { + SetLastError(ERROR_INVALID_PARAMETER); + return FALSE; + } + if (!(dwFlags & GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT)) { + HMODULE dummy = nullptr; + if (!oGetModuleHandleExA((dwFlags & GET_MODULE_HANDLE_EX_FLAG_PIN) | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + reinterpret_cast(client_hModule), &dummy)) + { + *phModule = nullptr; + return FALSE; + } + } + *phModule = reinterpret_cast(client_hModule); + return TRUE; + } + return oGetModuleHandleExA(dwFlags, lpModuleName, phModule); + } + + BOOL WINAPI hkGetModuleHandleExW(DWORD dwFlags, LPCWSTR lpModuleName, HMODULE* phModule) + { + if (client_hModule && !(dwFlags & GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS) && + IsSteamClientPathW(lpModuleName)) + { + if (!phModule) { + SetLastError(ERROR_INVALID_PARAMETER); + return FALSE; + } + if ((dwFlags & GET_MODULE_HANDLE_EX_FLAG_PIN) && + (dwFlags & GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT)) + { + SetLastError(ERROR_INVALID_PARAMETER); + return FALSE; + } + if (!(dwFlags & GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT)) { + HMODULE dummy = nullptr; + if (!oGetModuleHandleExW((dwFlags & GET_MODULE_HANDLE_EX_FLAG_PIN) | GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, + reinterpret_cast(client_hModule), &dummy)) + { + *phModule = nullptr; + return FALSE; + } + } + *phModule = reinterpret_cast(client_hModule); + return TRUE; + } + return oGetModuleHandleExW(dwFlags, lpModuleName, phModule); + } + + // SteamUI loads its client module through this function. While the + // diversion module is active the load is answered with the already-loaded + // copy, so SteamUI and the hook shim agree on one client module. Waits + // (bounded) for hook installation so this can never race the init thread. + HOOK_FUNC(LoadModuleWithPath, void*, const char* path, bool flags) + { + LOG_STEAMUI_INFO("LoadModuleWithPath called with path: {}, flags: {}", + path ? path : "(null)", flags); + + const bool isSteamClient = IsSteamClientPath(path); + + if (isSteamClient) { + bool logged = false; + for (int i = 0; i < kMaxRetry && !g_HooksInstalled.load(std::memory_order_acquire); ++i) { + if (!logged) { + LOG_STEAMUI_DEBUG("LoadModuleWithPath: waiting for hooks to be installed..."); + logged = true; + } + std::this_thread::sleep_for(kRetryInterval); + } + } + + void* h = oLoadModuleWithPath(path, flags); + + if (isSteamClient) { + if (!g_HooksInstalled.load(std::memory_order_acquire)) { + LOG_STEAMUI_WARN("LoadModuleWithPath: hooks initialization timed out after {} ms, aborting diversion", + kMaxRetry * static_cast(kRetryInterval.count())); + return h; + } + + if (client_hModule) { + if (g_IsDiversionActive.load(std::memory_order_acquire)) { + LOG_STEAMUI_INFO("LoadModuleWithPath: diverted {} (original {:p}) -> diversion {:p}", + path ? path : "steamclient64.dll", h, static_cast(client_hModule)); + } else { + LOG_STEAMUI_INFO("LoadModuleWithPath: returned fallback steamclient64.dll ({:p})", + static_cast(client_hModule)); + } + return client_hModule; + } + } + + return h; + } + RESOLVE_FUNC(RepeatedFieldUint32_Add, void, void* field, const uint32* value); CAPTURE_THIS_FUNC(GetAppByID, CSteamApp*, g_pController,void* pThis, AppId_t appId, bool bCreate); @@ -50,7 +224,9 @@ namespace } // Apps to drop from the library: queued off-thread, marked on the UI thread. + // g_hasPendingRemovals lets the per-frame hook skip the lock when idle. std::mutex g_removalMutex; + std::atomic g_hasPendingRemovals{false}; std::vector g_pendingRemovals; std::unordered_set g_removedAppIds; @@ -60,48 +236,63 @@ namespace CAppOverview_Change *pChange, void *optionalCallbackSlot) { oBuildCompleteAppOverviewChange(pController, pChange, optionalCallbackSlot); - std::lock_guard lock(g_removalMutex); - if (pChange && !g_removedAppIds.empty() && oRepeatedFieldUint32_Add) + if (pChange && oRepeatedFieldUint32_Add) { - auto* field = pChange->mutable_removed_appid(); - for (AppId_t appId : g_removedAppIds){ - oRepeatedFieldUint32_Add(field, &appId); + std::lock_guard lock(g_removalMutex); + if (!g_removedAppIds.empty()) + { + auto* field = pChange->mutable_removed_appid(); + for (AppId_t appId : g_removedAppIds) { + oRepeatedFieldUint32_Add(field, &appId); + } + LOG_STEAMUI_DEBUG("BuildCompleteAppOverviewChange: appended {} removed_appid entries", + g_removedAppIds.size()); } - LOG_STEAMUI_DEBUG("BuildCompleteAppOverviewChange: appended {} removed_appid entries", - g_removedAppIds.size()); } } - // Clearing ownership makes ShouldShowAppInLibrary() false (delta drops it, // the full snapshot skips it); MarkAppChange triggers the flush. HOOK_FUNC(CSteamUIAppControllerRunFrame, void *, void *pController) { if (CAPTURE_READY(GetAppByID) && CAPTURE_READY(MarkAppChange)) { - std::vector draining; - { - std::lock_guard lock(g_removalMutex); - draining.swap(g_pendingRemovals); - } - for (AppId_t appId : draining) + if (g_hasPendingRemovals.load(std::memory_order_acquire)) { - if (LuaConfig::IsOwned(appId)) + std::vector draining; { - LOG_STEAMUI_DEBUG("RunFrame: appId {} is owned again, skipping removal", appId); - continue; + std::lock_guard lock(g_removalMutex); + draining.swap(g_pendingRemovals); + g_hasPendingRemovals.store(false, std::memory_order_release); } - if (CSteamApp *pApp = oGetAppByID(g_pController, appId, false)) + if (!draining.empty()) { - // Only remove from the library if it's not already uninstalled - pApp->OwnershipFlags = k_EAppOwnershipFlags_None; - if(pApp->AppStateFlags == k_EAppStateUninstalled){ + std::vector uninstalledAppIds; + for (AppId_t appId : draining) + { + if (LuaConfig::IsOwned(appId)) + { + LOG_STEAMUI_DEBUG("RunFrame: appId {} is owned again, skipping removal", appId); + continue; + } + if (CSteamApp *pApp = oGetAppByID(g_pController, appId, false)) + { + // Only remove from the library if it's not already uninstalled + pApp->OwnershipFlags = k_EAppOwnershipFlags_None; + if (pApp->AppStateFlags == k_EAppStateUninstalled) { + uninstalledAppIds.push_back(appId); + } + } + + oMarkAppChange(g_pAppChangeSource, appId, EAppChangeFlags::AppInfoOrConfig); + } + if (!uninstalledAppIds.empty()) { std::lock_guard lock(g_removalMutex); - g_removedAppIds.insert(appId); + for (AppId_t appId : uninstalledAppIds) { + g_removedAppIds.insert(appId); + } } } - - oMarkAppChange(g_pAppChangeSource, appId, EAppChangeFlags::AppInfoOrConfig); } } return oCSteamUIAppControllerRunFrame(pController); @@ -110,6 +301,8 @@ namespace namespace Hooks_SteamUI { + static bool g_systemHooksAttached = false; + void Install() { ARM_CAPTURE_U(GetAppByID); @@ -117,26 +310,66 @@ namespace Hooks_SteamUI RESOLVE_U(RepeatedFieldUint32_Add); + bool sysAttached = false; HOOK_BEGIN(); + INSTALL_HOOK_U(LoadModuleWithPath); INSTALL_HOOK_U(FillInAppOverview); INSTALL_HOOK_U(BuildCompleteAppOverviewChange); INSTALL_HOOK_U(CSteamUIAppControllerRunFrame); + + // System module handle redirection for the diversion shadow module. + // These are plain system detours, attached once and idempotently. + if (!g_systemHooksAttached) { + bool ok = true; + if (!OSTPlatform::Detour::Attach(reinterpret_cast(&oGetModuleHandleA), reinterpret_cast(hkGetModuleHandleA))) ok = false; + if (!OSTPlatform::Detour::Attach(reinterpret_cast(&oGetModuleHandleW), reinterpret_cast(hkGetModuleHandleW))) ok = false; + if (!OSTPlatform::Detour::Attach(reinterpret_cast(&oGetModuleHandleExA), reinterpret_cast(hkGetModuleHandleExA))) ok = false; + if (!OSTPlatform::Detour::Attach(reinterpret_cast(&oGetModuleHandleExW), reinterpret_cast(hkGetModuleHandleExW))) ok = false; + if (ok) { + sysAttached = true; + } else { + _ost_detour_transaction_ok_ = false; + } + } + HOOK_END(); + if (sysAttached) { + g_systemHooksAttached = true; + } } void Uninstall() { + bool sysDetached = false; UNHOOK_BEGIN(); + if (g_systemHooksAttached) { + bool ok = true; + if (!OSTPlatform::Detour::Detach(reinterpret_cast(&oGetModuleHandleA), reinterpret_cast(hkGetModuleHandleA))) ok = false; + if (!OSTPlatform::Detour::Detach(reinterpret_cast(&oGetModuleHandleW), reinterpret_cast(hkGetModuleHandleW))) ok = false; + if (!OSTPlatform::Detour::Detach(reinterpret_cast(&oGetModuleHandleExA), reinterpret_cast(hkGetModuleHandleExA))) ok = false; + if (!OSTPlatform::Detour::Detach(reinterpret_cast(&oGetModuleHandleExW), reinterpret_cast(hkGetModuleHandleExW))) ok = false; + if (ok) { + sysDetached = true; + } else { + _ost_detour_transaction_ok_ = false; + } + } + + UNINSTALL_HOOK(LoadModuleWithPath); UNINSTALL_HOOK(FillInAppOverview); UNINSTALL_HOOK(BuildCompleteAppOverviewChange); UNINSTALL_HOOK(CSteamUIAppControllerRunFrame); UNHOOK_END(); + if (sysDetached) { + g_systemHooksAttached = false; + } } void QueueRemoval(AppId_t appId) { std::lock_guard lock(g_removalMutex); g_pendingRemovals.push_back(appId); + g_hasPendingRemovals.store(true, std::memory_order_release); } void CancelRemoval(AppId_t appId) @@ -144,6 +377,9 @@ namespace Hooks_SteamUI std::lock_guard lock(g_removalMutex); std::erase(g_pendingRemovals, appId); g_removedAppIds.erase(appId); + if (g_pendingRemovals.empty()) { + g_hasPendingRemovals.store(false, std::memory_order_release); + } } size_t ActiveDownloadCount() diff --git a/src/Utils/CloudRedirect/CloudRedirectHost.cpp b/src/Utils/CloudRedirect/CloudRedirectHost.cpp index ee12043e..f7048229 100644 --- a/src/Utils/CloudRedirect/CloudRedirectHost.cpp +++ b/src/Utils/CloudRedirect/CloudRedirectHost.cpp @@ -1,6 +1,8 @@ #include "CloudRedirectHost.h" +#include "dllmain.h" #include "OSTPlatform/include/DynamicLibrary.h" +#include "OSTPlatform/include/Encoding.h" #include "Utils/Config/Config.h" #include "Utils/Config/LuaConfig.h" #include "Utils/Logging/Log.h" @@ -63,13 +65,28 @@ namespace { 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()) + std::error_code ec; + const std::string filename = configured.empty() ? "cloud_redirect.dll" : configured; + const std::filesystem::path lib(OSTPlatform::Encoding::Utf8ToWide(filename)); + if (lib.is_absolute()) { return lib; - return std::filesystem::path(steamRoot) / lib; + } + + // Lookup order: next to this DLL, next to opensteamtool.toml (when + // different from the Steam root), then the Steam root itself. + if (DllDir[0] != '\0') { + const auto p = std::filesystem::path(OSTPlatform::Encoding::Utf8ToWide(DllDir)) / lib; + if (std::filesystem::exists(p, ec) && !ec) return p; + } + if (ConfigPath[0] != '\0') { + const auto configParent = std::filesystem::path(OSTPlatform::Encoding::Utf8ToWide(ConfigPath)).parent_path(); + const auto steamRootPath = std::filesystem::path(OSTPlatform::Encoding::Utf8ToWide(steamRoot)); + if (configParent != steamRootPath) { + const auto p = configParent / lib; + if (std::filesystem::exists(p, ec) && !ec) return p; + } + } + return std::filesystem::path(OSTPlatform::Encoding::Utf8ToWide(steamRoot)) / lib; } template @@ -100,15 +117,17 @@ void Initialize(const char* steamInstallPath) { 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()); + std::error_code libEc; + const std::string libPathUtf8 = OSTPlatform::Encoding::WideToUtf8(libPath.wstring()); + if (!std::filesystem::exists(libPath, libEc) || libEc) { + LOG_WARN("CloudRedirect: cloud_redirect.dll not found at {}", libPathUtf8); return; } g_module = OSTPlatform::DynamicLibrary::Load(libPath); if (!g_module) { LOG_WARN("CloudRedirect: failed to load {} (err={})", - libPath.string(), OSTPlatform::DynamicLibrary::GetLastErrorCode()); + libPathUtf8, OSTPlatform::DynamicLibrary::GetLastErrorCode()); return; } @@ -139,8 +158,8 @@ void Initialize(const char* steamInstallPath) { } g_active.store(true, std::memory_order_release); - LOG_INFO("CloudRedirect: loaded {} and initialised cloud save redirection", - libPath.string()); + LOG_INFO("CloudRedirect: loaded {} and initialised cloud save redirection (diversion: {:p})", + libPathUtf8, static_cast(client_hModule)); if (g_enableStatsSync) { g_enableStatsSync(true, true); @@ -157,7 +176,7 @@ void Initialize(const char* steamInstallPath) { // Vtable hooks let CR handle Cloud RPCs synchronously (slot4 semantics). if (g_installVtableHooks) { if (g_installVtableHooks()) - LOG_INFO("CloudRedirect: vtable hooks installed"); + LOG_INFO("CloudRedirect: vtable hooks installed (routed to diversion module)"); else LOG_WARN("CloudRedirect: vtable hook install failed, using packet-layer path"); } @@ -215,6 +234,21 @@ void Shutdown() { std::lock_guard lock(g_mutex); if (!g_active.exchange(false)) return; if (g_shutdownFn) g_shutdownFn(); + // Drop every function pointer and the module handle: after shutdown the + // cloud_redirect.dll image may be unloaded and dangling calls must not + // be possible. + g_initCloudSave = nullptr; + g_handleCloudRpc = nullptr; + g_setApps = nullptr; + g_isApp = nullptr; + g_shutdownFn = nullptr; + g_enableStatsSync = nullptr; + g_setAccountId = nullptr; + g_notifyAppRunning = nullptr; + g_notifyStatsStored = nullptr; + g_getAchievements = nullptr; + g_installVtableHooks = nullptr; + g_module = nullptr; LOG_INFO("CloudRedirect: shut down"); } diff --git a/src/Utils/SteamMetadata/IPCLoader.cpp b/src/Utils/SteamMetadata/IPCLoader.cpp index c2c646f2..6d93d28c 100644 --- a/src/Utils/SteamMetadata/IPCLoader.cpp +++ b/src/Utils/SteamMetadata/IPCLoader.cpp @@ -189,7 +189,7 @@ bool Load(const std::string& steamclientPath) LOG_INFO("IPCLoader: loaded {} methods across {} interfaces ({})", MethodCount(), InterfaceCount(), - r.fromCache ? "cache fallback" : "remote"); + r.fromCache ? "cache" : "remote"); return true; } diff --git a/src/Utils/SteamMetadata/PatternLoader.cpp b/src/Utils/SteamMetadata/PatternLoader.cpp index e4d39f84..d3ec98bb 100644 --- a/src/Utils/SteamMetadata/PatternLoader.cpp +++ b/src/Utils/SteamMetadata/PatternLoader.cpp @@ -187,7 +187,7 @@ bool Load(OSTPlatform::DynamicLibrary::ModuleHandle module, const std::string& d PatternMap map = ParsePatternString(r.body, &parseErr); if (!map.empty()) { LOG_INFO("PatternLoader: loaded {} patterns for {} ({})", - map.size(), component, r.fromCache ? "cache fallback" : "remote"); + map.size(), component, r.fromCache ? "cache" : "remote"); g_moduleMaps[module] = std::move(map); return true; } diff --git a/src/Utils/SteamMetadata/RemoteToml.cpp b/src/Utils/SteamMetadata/RemoteToml.cpp index 4411e070..777b3ff4 100644 --- a/src/Utils/SteamMetadata/RemoteToml.cpp +++ b/src/Utils/SteamMetadata/RemoteToml.cpp @@ -1,4 +1,5 @@ #include "RemoteToml.h" +#include "OSTPlatform/include/Encoding.h" #include "OSTPlatform/include/Http.h" #include "Utils/Config/Config.h" #include "Utils/Logging/Log.h" @@ -6,10 +7,12 @@ #include #include +#include #include #include #include #include +#include namespace RemoteToml { @@ -95,17 +98,19 @@ Result Fetch(const Request& request) LOG_INFO("RemoteToml({}/{}): sha256 = {} ({} ms)", request.channel, request.component, out.sha256, hashMs); - // 2. Cache path & dir. - fs::path steamRoot = fs::path(request.dllPath).parent_path(); + // 2. Cache path & dir. UTF-8 input is converted explicitly so non-ASCII + // install paths resolve correctly. + fs::path steamRoot = fs::path(OSTPlatform::Encoding::Utf8ToWide(request.dllPath)).parent_path(); fs::path cacheDir = steamRoot / "opensteamtool" / request.channel / request.component; fs::path cachePath = cacheDir / (out.sha256 + ".toml"); - const std::string cachePathText = cachePath.string(); + const std::string cachePathText = OSTPlatform::Encoding::WideToUtf8(cachePath.wstring()); std::error_code mkdirEc; fs::create_directories(cacheDir, mkdirEc); if (mkdirEc) { LOG_WARN("RemoteToml({}/{}): could not create cache dir {} ({})", - request.channel, request.component, cacheDir.string(), mkdirEc.message()); + request.channel, request.component, + OSTPlatform::Encoding::WideToUtf8(cacheDir.wstring()), mkdirEc.message()); } // 3. Try remote (mirror chain with early-out on 404). @@ -135,17 +140,35 @@ Result Fetch(const Request& request) } } - // 4. Remote OK → write cache, return body. + // 4. Remote OK -> write the cache atomically, return body. if (http.ok && http.status == 200 && !http.body.empty()) { - std::ofstream ofs(cachePath, std::ios::binary); + std::string tempFileName = std::format("{}_{}.tmp", out.sha256, GetCurrentProcessId()); + fs::path tempPath = cacheDir / tempFileName; + std::ofstream ofs(tempPath, std::ios::binary); if (ofs) { ofs.write(http.body.data(), static_cast(http.body.size())); - LOG_INFO("RemoteToml({}/{}): cached to {}", - request.channel, request.component, cachePathText); + ofs.close(); + + if (MoveFileExW(tempPath.c_str(), cachePath.c_str(), MOVEFILE_REPLACE_EXISTING)) { + LOG_INFO("RemoteToml({}/{}): cached to {}", + request.channel, request.component, cachePathText); + } else { + std::error_code renameEc; + fs::rename(tempPath, cachePath, renameEc); + if (!renameEc) { + LOG_INFO("RemoteToml({}/{}): cached to {}", + request.channel, request.component, cachePathText); + } else { + LOG_WARN("RemoteToml({}/{}): atomic cache rename failed: {}", + request.channel, request.component, renameEc.message()); + fs::remove(tempPath, renameEc); + } + } } else { LOG_WARN("RemoteToml({}/{}): could not open {} for writing", - request.channel, request.component, cachePathText); + request.channel, request.component, + OSTPlatform::Encoding::WideToUtf8(tempPath.wstring())); } out.body = std::move(http.body); out.ok = true; @@ -153,7 +176,8 @@ Result Fetch(const Request& request) } // 5. Remote failed → fall back to whatever is cached for this exact SHA. - if (fs::exists(cachePath)) { + std::error_code existsEc; + if (fs::exists(cachePath, existsEc) && !existsEc) { LOG_WARN("RemoteToml({}/{}): remote failed (last URL {} HTTP {}); " "falling back to local cache {}", request.channel, request.component, diff --git a/src/Utils/SteamMetadata/RemoteToml.h b/src/Utils/SteamMetadata/RemoteToml.h index e5d56c92..585891cc 100644 --- a/src/Utils/SteamMetadata/RemoteToml.h +++ b/src/Utils/SteamMetadata/RemoteToml.h @@ -16,7 +16,7 @@ namespace RemoteToml { std::string sha256; }; - // Fetch remote TOML first, then fall back to the exact local cache entry. + // Load exact local cache entry first, then fall back to fetching remote TOML. Result Fetch(const Request& request); } // namespace RemoteToml diff --git a/src/dllmain.cpp b/src/dllmain.cpp index 7110664f..75e0235b 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -14,34 +14,123 @@ #endif #include "OSTPlatform/include/Dialog.h" #include "OSTPlatform/include/DynamicLibrary.h" +#include "OSTPlatform/include/Encoding.h" #include "OSTPlatform/include/Thread.h" #include #include // prepare key runtime paths. -bool InitializeSteamComponents() +// +// Diversion shadow module: steamclient64.dll is copied to \bin\diversion64.dll +// and the COPY is loaded and hooked, so the original module stays untouched in +// memory. Callers that ask for "steamclient64.dll" are redirected to the copy +// (see Hooks_SteamUI.cpp). If the copy or load fails, the original is loaded +// unchanged ("Diversion inactive") - the process must always end up with a +// working client module. +bool InitializeSteamComponents(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) { - const std::string steamInstallPath = OSTPlatform::DynamicLibrary::GetCurrentDirectoryPath(); + auto steamExeDir = OSTPlatform::DynamicLibrary::GetModuleDirectory(nullptr); + std::string steamInstallPath = OSTPlatform::Encoding::WideToUtf8(steamExeDir.wstring()); + if (steamInstallPath.empty()) { + steamInstallPath = OSTPlatform::DynamicLibrary::GetCurrentDirectoryPath(); + } if (steamInstallPath.empty()) { return false; } sprintf_s(SteamInstallPath, kRuntimePathCapacity, "%s", steamInstallPath.c_str()); - sprintf_s(SteamclientPath, kRuntimePathCapacity, "%s\\steamclient64.dll", SteamInstallPath); - sprintf_s(SteamUIPath, kRuntimePathCapacity, "%s\\steamui.dll", SteamInstallPath); - sprintf_s(DiversionPath, kRuntimePathCapacity, "%s\\bin\\diversion.dll", SteamInstallPath); - sprintf_s(LuaDir, kRuntimePathCapacity, "%s\\config\\stplug-in", SteamInstallPath); - sprintf_s(ConfigPath, kRuntimePathCapacity, "%s\\opensteamtool.toml", SteamInstallPath); - - client_hModule = OSTPlatform::DynamicLibrary::Load(SteamclientPath); + sprintf_s(SteamclientPath, kRuntimePathCapacity, "%s\\steamclient64.dll", SteamInstallPath); + sprintf_s(SteamUIPath, kRuntimePathCapacity, "%s\\steamui.dll", SteamInstallPath); + sprintf_s(DiversionPath, kRuntimePathCapacity, "%s\\bin\\diversion64.dll", SteamInstallPath); + sprintf_s(LuaDir, kRuntimePathCapacity, "%s\\config\\stplug-in", SteamInstallPath); + sprintf_s(ConfigPath, kRuntimePathCapacity, "%s\\opensteamtool.toml", SteamInstallPath); + + auto dllDir = OSTPlatform::DynamicLibrary::GetModuleDirectory(selfModule); + std::string dllPath = OSTPlatform::Encoding::WideToUtf8(dllDir.wstring()); + if (dllPath.empty()) { + dllPath = steamInstallPath; + } + sprintf_s(DllDir, kRuntimePathCapacity, "%s", dllPath.c_str()); + + // Diversion shadow module cloning & loading: + // Clone steamclient64.dll into bin\diversion64.dll so all hooks and patches + // are isolated to the diversion module while original steamclient64.dll stays 100% clean. + WIN32_FILE_ATTRIBUTE_DATA origAttr{}, divAttr{}; + const std::wstring wideSteamclientPath = OSTPlatform::Encoding::Utf8ToWide(SteamclientPath); + const std::wstring wideDiversionPath = OSTPlatform::Encoding::Utf8ToWide(DiversionPath); + const std::wstring wideSteamUIPath = OSTPlatform::Encoding::Utf8ToWide(SteamUIPath); + + const bool origExists = GetFileAttributesExW(wideSteamclientPath.c_str(), GetFileExInfoStandard, &origAttr) != 0; + const bool divExists = GetFileAttributesExW(wideDiversionPath.c_str(), GetFileExInfoStandard, &divAttr) != 0; + + bool isUpToDate = false; + if (origExists && divExists) { + if (origAttr.nFileSizeHigh == divAttr.nFileSizeHigh && + origAttr.nFileSizeLow == divAttr.nFileSizeLow && + origAttr.ftLastWriteTime.dwLowDateTime == divAttr.ftLastWriteTime.dwLowDateTime && + origAttr.ftLastWriteTime.dwHighDateTime == divAttr.ftLastWriteTime.dwHighDateTime) + { + isUpToDate = true; + } + } + + bool copyOk = isUpToDate; + if (isUpToDate) { + LOG_DEBUG("Diversion module is already up to date ({}), skipping copy", DiversionPath); + } else { + std::filesystem::path diversionFsPath(wideDiversionPath); + std::error_code ec; + std::filesystem::create_directories(diversionFsPath.parent_path(), ec); + if (divExists && (divAttr.dwFileAttributes & FILE_ATTRIBUTE_READONLY)) { + SetFileAttributesW(wideDiversionPath.c_str(), FILE_ATTRIBUTE_NORMAL); + } + + // Retry up to 3 times in case the old Steam process is still releasing the file handle + constexpr int kMaxCopyRetries = 3; + DWORD gle = ERROR_SUCCESS; + for (int attempt = 1; attempt <= kMaxCopyRetries; ++attempt) { + if (CopyFileW(wideSteamclientPath.c_str(), wideDiversionPath.c_str(), FALSE)) { + copyOk = true; + LOG_INFO("Cloned steamclient64.dll -> {}", DiversionPath); + break; + } + gle = GetLastError(); + if (gle != ERROR_SHARING_VIOLATION && gle != ERROR_ACCESS_DENIED) { + break; + } + if (attempt < kMaxCopyRetries) { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + } + + if (!copyOk) { + LOG_WARN("CopyFileW to diversion64.dll failed after {} attempts (err={})", kMaxCopyRetries, gle); + } + } + + if (copyOk) { + client_hModule = OSTPlatform::DynamicLibrary::Load(std::filesystem::path(wideDiversionPath)); + if (client_hModule) { + g_IsDiversionActive.store(true, std::memory_order_release); + LOG_INFO("Loaded diversion module from {}", DiversionPath); + } else { + LOG_WARN("Load diversion module failed (path={}, err={}), falling back to real steamclient64.dll", + DiversionPath, OSTPlatform::DynamicLibrary::GetLastErrorCode()); + } + } + if (!client_hModule) { - LOG_ERROR("Load steamclient64.dll failed: {} (err={})", - SteamclientPath, OSTPlatform::DynamicLibrary::GetLastErrorCode()); - return false; + g_IsDiversionActive.store(false, std::memory_order_release); + client_hModule = OSTPlatform::DynamicLibrary::Load(std::filesystem::path(wideSteamclientPath)); + if (!client_hModule) { + LOG_ERROR("Load steamclient64.dll failed: {} (err={})", + SteamclientPath, OSTPlatform::DynamicLibrary::GetLastErrorCode()); + return false; + } + LOG_INFO("Loaded fallback steamclient64.dll from {} (Diversion inactive)", SteamclientPath); } - LOG_INFO("Loaded steamclient64.dll from {}", SteamclientPath); - - ui_hModule = OSTPlatform::DynamicLibrary::Load(SteamUIPath); + + ui_hModule = OSTPlatform::DynamicLibrary::Load(std::filesystem::path(wideSteamUIPath)); if(!ui_hModule) { LOG_ERROR("Load failed for steamui.dll: err={}", OSTPlatform::DynamicLibrary::GetLastErrorCode()); return false; @@ -56,8 +145,10 @@ static uint32_t InitThread(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) Log::Init(selfModule); LOG_INFO("OpenSteamTool init thread started"); - if (!InitializeSteamComponents()) { + if (!InitializeSteamComponents(selfModule)) { LOG_ERROR("InitializeSteamComponents failed"); + // Release the SteamUI LoadModuleWithPath waiters; nothing else will. + g_HooksInstalled.store(true, std::memory_order_release); return 1; } @@ -73,6 +164,10 @@ static uint32_t InitThread(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) PatternLoader::Load(ui_hModule, SteamUIPath, "steamui"); PatternLoader::Load(client_hModule, SteamclientPath, "steamclient"); + // Install SteamUI hooks early so LoadModuleWithPath can intercept + // and synchronize with client hook installation. + SteamUI::CoreHook(); + // IPC method metadata (funcHash, fencepost, argc, ...) IPCLoader::Load(SteamclientPath); @@ -84,7 +179,6 @@ static uint32_t InitThread(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) LuaFileWatcher::Start(watchDirs); ConfigFileWatcher::Start(ConfigPath, LuaDir); - SteamUI::CoreHook(); SteamClient::CoreHook(); // Surface any functions that FindPattern() could not locate. @@ -132,7 +226,11 @@ static uint32_t InitThread(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) LOG_INFO("Self-updater not compiled in (OST_ENABLE_UPDATER=OFF)"); #endif - LOG_INFO("OpenSteamTool init complete"); + g_HooksInstalled.store(true, std::memory_order_release); + LOG_INFO("OpenSteamTool init complete ({})", + g_IsDiversionActive.load(std::memory_order_acquire) + ? "Diversion active" + : "Diversion bypassed, using original steamclient64"); return 0; } @@ -156,6 +254,14 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved) DisableThreadLibraryCalls(hModule); if (!IsSteamHost()) return TRUE; // e.g. rundll32 bst:// handler — no injection here + + // Keep this module pinned so an explicit FreeLibrary cannot unload code + // while hooks and worker threads may still reference it. + HMODULE pinnedModule = nullptr; + GetModuleHandleExA( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + reinterpret_cast(&DllMain), &pinnedModule); + // Hand off all real work to a worker thread to avoid running file I/O, // module loading and detour transactions under the loader lock. OSTPlatform::Thread::StartDetached([module = reinterpret_cast(hModule)] { @@ -164,11 +270,19 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved) } else if (dwReason == DLL_PROCESS_DETACH && IsSteamHost()) { - ConfigFileWatcher::Stop(); - LuaFileWatcher::Stop(); - SteamUI::CoreUnhook(); - SteamClient::CoreUnhook(); - CloudRedirectHost::Shutdown(); + g_HooksInstalled.store(false, std::memory_order_release); + g_IsDiversionActive.store(false, std::memory_order_release); + + // When pvReserved != nullptr the process is terminating: all other + // threads are already gone and detour cleanup / thread joins under the + // loader lock can deadlock. Skip cleanup; the OS reclaims everything. + if (pvReserved == nullptr) { + ConfigFileWatcher::Stop(); + LuaFileWatcher::Stop(); + SteamUI::CoreUnhook(); + SteamClient::CoreUnhook(); + CloudRedirectHost::Shutdown(); + } } return TRUE; diff --git a/src/dllmain.h b/src/dllmain.h index f141f78d..b1250853 100644 --- a/src/dllmain.h +++ b/src/dllmain.h @@ -13,6 +13,8 @@ #include #include #include +#include +#include #include #include "Steam/Types.h" @@ -27,6 +29,13 @@ inline OSTPlatform::DynamicLibrary::ModuleHandle client_hModule = nullptr; inline OSTPlatform::DynamicLibrary::ModuleHandle ui_hModule = nullptr; +// Diversion/startup synchronization: g_HooksInstalled is +// set when hook installation has completed (or failed); the SteamUI +// LoadModuleWithPath hook waits on it. g_IsDiversionActive is true while the +// cloned steamclient64.dll (bin\diversion64.dll) is the loaded client module. +inline std::atomic g_HooksInstalled{false}; +inline std::atomic g_IsDiversionActive{false}; + inline constexpr size_t kRuntimePathCapacity = 260; inline char SteamInstallPath[kRuntimePathCapacity] = {}; @@ -35,6 +44,7 @@ inline char SteamUIPath[kRuntimePathCapacity] = {}; inline char DiversionPath[kRuntimePathCapacity] = {}; inline char LuaDir[kRuntimePathCapacity] = {}; inline char ConfigPath[kRuntimePathCapacity] = {}; +inline char DllDir[kRuntimePathCapacity] = {}; // The fake AppId used by -onlinefix (SpaceWar). constexpr AppId_t kOnlineFixAppId = 480;