From 8dc03cedf0995d8a74d0f834cfd6af7aa7266591 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Mon, 25 May 2026 09:14:44 +0800 Subject: [PATCH 01/30] fix: avoid loader-lock work in DllMain detach --- src/dllmain.cpp | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/dllmain.cpp b/src/dllmain.cpp index 1a080042..d5157a0e 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -83,22 +83,22 @@ static DWORD WINAPI InitThread(LPVOID param) { return 0; } -BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved) -{ - if (dwReason == DLL_PROCESS_ATTACH) - { - DisableThreadLibraryCalls(hModule); +BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved) +{ + if (dwReason == DLL_PROCESS_ATTACH) + { + DisableThreadLibraryCalls(hModule); // Hand off all real work to a worker thread to avoid running file I/O, // LoadLibrary, and detour transactions under the loader lock. HANDLE h = CreateThread(nullptr, 0, InitThread, hModule, 0, nullptr); if (h) CloseHandle(h); - } - else if (dwReason == DLL_PROCESS_DETACH) - { - FileWatcher::Stop(); - SteamUI::CoreUnhook(); - SteamClient::CoreUnhook(); - } - - return TRUE; -} + } + else if (dwReason == DLL_PROCESS_DETACH) + { + // DllMain runs under loader lock. Blocking joins or Detours transactions + // here can deadlock the process during shutdown/unload. + (void)pvReserved; + } + + return TRUE; +} From 08000572966dfb74e59a791e06a5522f3be5f064 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Mon, 25 May 2026 09:17:41 +0800 Subject: [PATCH 02/30] fix: guard WinHttp URL port parsing exceptions --- src/Utils/WinHttp.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Utils/WinHttp.cpp b/src/Utils/WinHttp.cpp index 21cce655..eb7d4f1d 100644 --- a/src/Utils/WinHttp.cpp +++ b/src/Utils/WinHttp.cpp @@ -31,7 +31,13 @@ namespace WinHttp { size_t colon = hostPart.find(':'); if (colon != std::string::npos) { out.host = std::wstring(hostPart.begin(), hostPart.begin() + colon); - out.port = static_cast(std::stoi(hostPart.substr(colon + 1))); + try { + long port = std::stol(hostPart.substr(colon + 1)); + if (port <= 0 || port > 65535) return out; + out.port = static_cast(port); + } catch (...) { + return out; + } } else { out.host = std::wstring(hostPart.begin(), hostPart.end()); } From 468d8f54c70fbc862df09ad2388de5489c98a51f Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Mon, 25 May 2026 09:30:45 +0800 Subject: [PATCH 03/30] fix: add non-blocking watcher cleanup on explicit unload --- src/Utils/FileWatcher.cpp | 7 +++++++ src/Utils/FileWatcher.h | 9 +++++---- src/dllmain.cpp | 10 +++++++--- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/Utils/FileWatcher.cpp b/src/Utils/FileWatcher.cpp index 1b5b0617..9e70a36e 100644 --- a/src/Utils/FileWatcher.cpp +++ b/src/Utils/FileWatcher.cpp @@ -214,4 +214,11 @@ namespace FileWatcher { g_watcherThread.join(); } } + + void StopNoJoin() { + g_running = false; + if (g_watcherThread.joinable()) { + g_watcherThread.detach(); + } + } } diff --git a/src/Utils/FileWatcher.h b/src/Utils/FileWatcher.h index e4ee75a9..3dd7fc90 100644 --- a/src/Utils/FileWatcher.h +++ b/src/Utils/FileWatcher.h @@ -4,7 +4,8 @@ #include #include -namespace FileWatcher { - void Start(const std::vector& directories); - void Stop(); -} \ No newline at end of file +namespace FileWatcher { + void Start(const std::vector& directories); + void Stop(); + void StopNoJoin(); +} diff --git a/src/dllmain.cpp b/src/dllmain.cpp index d5157a0e..a646995a 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -95,9 +95,13 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved) } else if (dwReason == DLL_PROCESS_DETACH) { - // DllMain runs under loader lock. Blocking joins or Detours transactions - // here can deadlock the process during shutdown/unload. - (void)pvReserved; + // DLL detach runs under loader lock. Never block here. + // For explicit FreeLibrary (pvReserved == nullptr), best-effort + // signal the watcher thread to exit and detach the std::thread object + // so module teardown does not hit std::thread destructor terminate. + if (pvReserved == nullptr) { + FileWatcher::StopNoJoin(); + } } return TRUE; From 7c43c3f7cdf9585f8450dff073c3e9c18da8549c Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Mon, 25 May 2026 09:35:34 +0800 Subject: [PATCH 04/30] fix: pin module and stop watcher on process detach --- src/Utils/FileWatcher.cpp | 7 ------- src/Utils/FileWatcher.h | 1 - src/dllmain.cpp | 24 ++++++++++++++---------- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/src/Utils/FileWatcher.cpp b/src/Utils/FileWatcher.cpp index 9e70a36e..1b5b0617 100644 --- a/src/Utils/FileWatcher.cpp +++ b/src/Utils/FileWatcher.cpp @@ -214,11 +214,4 @@ namespace FileWatcher { g_watcherThread.join(); } } - - void StopNoJoin() { - g_running = false; - if (g_watcherThread.joinable()) { - g_watcherThread.detach(); - } - } } diff --git a/src/Utils/FileWatcher.h b/src/Utils/FileWatcher.h index 3dd7fc90..488511bd 100644 --- a/src/Utils/FileWatcher.h +++ b/src/Utils/FileWatcher.h @@ -7,5 +7,4 @@ namespace FileWatcher { void Start(const std::vector& directories); void Stop(); - void StopNoJoin(); } diff --git a/src/dllmain.cpp b/src/dllmain.cpp index a646995a..cef2243f 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -88,19 +88,23 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved) if (dwReason == DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(hModule); - // Hand off all real work to a worker thread to avoid running file I/O, - // LoadLibrary, and detour transactions under the loader lock. - HANDLE h = CreateThread(nullptr, 0, InitThread, hModule, 0, nullptr); - if (h) CloseHandle(h); + // Keep this module pinned so 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, + // LoadLibrary, and detour transactions under the loader lock. + HANDLE h = CreateThread(nullptr, 0, InitThread, hModule, 0, nullptr); + if (h) CloseHandle(h); } else if (dwReason == DLL_PROCESS_DETACH) { - // DLL detach runs under loader lock. Never block here. - // For explicit FreeLibrary (pvReserved == nullptr), best-effort - // signal the watcher thread to exit and detach the std::thread object - // so module teardown does not hit std::thread destructor terminate. - if (pvReserved == nullptr) { - FileWatcher::StopNoJoin(); + // During process termination, join watcher thread object so CRT static + // teardown does not hit std::thread's joinable-guard terminate. + if (pvReserved != nullptr) { + FileWatcher::Stop(); } } From 37823b93ae698a2c233b75390607d42ae0d5d760 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 27 May 2026 07:54:02 +0800 Subject: [PATCH 05/30] fix: reject partial-numeric port strings in ParseUrl std::stol accepts strings like "12312abc" by parsing only the numeric prefix. Use the pos parameter to verify the entire port substring was consumed, and treat trailing garbage as invalid. --- src/Utils/WinHttp.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Utils/WinHttp.cpp b/src/Utils/WinHttp.cpp index eb7d4f1d..9b474cbe 100644 --- a/src/Utils/WinHttp.cpp +++ b/src/Utils/WinHttp.cpp @@ -31,9 +31,11 @@ namespace WinHttp { size_t colon = hostPart.find(':'); if (colon != std::string::npos) { out.host = std::wstring(hostPart.begin(), hostPart.begin() + colon); + auto portStr = hostPart.substr(colon + 1); try { - long port = std::stol(hostPart.substr(colon + 1)); - if (port <= 0 || port > 65535) return out; + size_t end = 0; + long port = std::stol(portStr, &end); + if (end != portStr.size() || port <= 0 || port > 65535) return out; out.port = static_cast(port); } catch (...) { return out; From 161e9b8d9d7b44c2963ec25ae31464dd2dc26933 Mon Sep 17 00:00:00 2001 From: TuruSudiro Date: Fri, 29 May 2026 02:55:47 +0700 Subject: [PATCH 06/30] add feature portable support --- src/CMakeLists.txt | 289 ++++++------ src/Utils/DllDirectory.cpp | 19 + src/Utils/DllDirectory.h | 7 + src/Utils/PatternLoader.cpp | 859 ++++++++++++++++++------------------ src/Utils/Utils.h | 12 + src/dllmain.cpp | 186 ++++---- src/dllmain.h | 81 ++-- 7 files changed, 748 insertions(+), 705 deletions(-) create mode 100644 src/Utils/DllDirectory.cpp create mode 100644 src/Utils/DllDirectory.h create mode 100644 src/Utils/Utils.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a27733d1..5cd969e0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,144 +1,145 @@ -cmake_minimum_required(VERSION 3.20) -project(OpenSteamTool VERSION 1.0.0 LANGUAGES C CXX) - -set(CMAKE_CXX_STANDARD 20) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_C_STANDARD 11) - -# Allow CMAKE_MSVC_RUNTIME_LIBRARY to control runtime selection for all targets, -# including dependencies pulled in via FetchContent. -if(POLICY CMP0091) - cmake_policy(SET CMP0091 NEW) -endif() - -# Static MSVC runtime everywhere, so the resulting DLL has no extra runtime -# dependencies. Must be set BEFORE FetchContent_MakeAvailable so the fetched -# deps (Lua, Detours, spdlog) inherit it. -set(CMAKE_CONFIGURATION_TYPES "Debug;Release" CACHE STRING "" FORCE) -set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") -set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) - -# --------------------------------------------------------------------------- -# Dependency recipes (FetchContent-backed, cached at /.deps). -# --------------------------------------------------------------------------- -list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") -include(Lua) -include(Detours) -include(Spdlog) -include(Protobuf) -include(Tomlplusplus) -include(LogMacros) - -# --------------------------------------------------------------------------- -# Protobuf code generation — two variants from the same .proto: -# -# Debug → full Message (protoc --cpp_out) → links libprotobuf -# Release → lite MessageLite (protoc --cpp_out=lite) → links libprotobuf-lite -# -# Both land in separate subdirectories of the build tree so the source -# directory stays clean and the right set is picked per configuration. -# --------------------------------------------------------------------------- -set(PROTO_SRC "${CMAKE_CURRENT_SOURCE_DIR}/proto/steam_messages.proto") -set(PROTO_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/proto") -set(PROTO_GEN_LITE_DIR "${CMAKE_CURRENT_BINARY_DIR}/proto_lite") - -# Full Message (Debug) -add_custom_command( - OUTPUT "${PROTO_GEN_DIR}/steam_messages.pb.cc" - "${PROTO_GEN_DIR}/steam_messages.pb.h" - COMMAND ${CMAKE_COMMAND} -E make_directory "${PROTO_GEN_DIR}" - COMMAND $ - "--cpp_out=${PROTO_GEN_DIR}" - "-I${CMAKE_CURRENT_SOURCE_DIR}/proto" - "${PROTO_SRC}" - DEPENDS "${PROTO_SRC}" protoc - COMMENT "Generating protobuf full-Message sources (Debug)" -) - -# Lite MessageLite (Release) -add_custom_command( - OUTPUT "${PROTO_GEN_LITE_DIR}/steam_messages.pb.cc" - "${PROTO_GEN_LITE_DIR}/steam_messages.pb.h" - COMMAND ${CMAKE_COMMAND} -E make_directory "${PROTO_GEN_LITE_DIR}" - COMMAND $ - "--cpp_out=lite:${PROTO_GEN_LITE_DIR}" - "-I${CMAKE_CURRENT_SOURCE_DIR}/proto" - "${PROTO_SRC}" - DEPENDS "${PROTO_SRC}" protoc - COMMENT "Generating protobuf lite-MessageLite sources (Release)" -) - -# --------------------------------------------------------------------------- -# OpenSteamTool — the hook DLL injected into Steam (always 64-bit). -# --------------------------------------------------------------------------- -add_library(OpenSteamTool SHARED - dllmain.cpp - - # Shared utilities - Utils/AppTicket.cpp - Utils/ByteSearch.cpp - Utils/PatternLoader.cpp - Utils/Log.cpp - Utils/Config.cpp - Utils/LuaConfig.cpp - Utils/VehCommon.cpp - Utils/WinHttp.cpp - Utils/FileWatcher.cpp - - # Per-category hook modules - Hook/HookManager.cpp - Hook/Hooks_CallBack.cpp - Hook/Hooks_Decryption.cpp - Hook/Hooks_IPC.cpp - Hook/Hooks_IPC_ISteamUser.cpp - Hook/Hooks_IPC_ISteamUtils.cpp - Hook/Hooks_KeyValues.cpp - Hook/Hooks_Manifest.cpp - Hook/Hooks_Misc.cpp - Hook/Hooks_NetPacket.cpp - Hook/Hooks_SteamUI.cpp - Hook/Hooks_Package.cpp - - # protobuf generated sources — per-config variant - $<$:${PROTO_GEN_DIR}/steam_messages.pb.cc> - $<$:${PROTO_GEN_LITE_DIR}/steam_messages.pb.cc> -) - -# Header search path — per-config include directory -target_include_directories(OpenSteamTool PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR} - ${CMAKE_CURRENT_BINARY_DIR}/generated - $<$:${PROTO_GEN_DIR}> - $<$:${PROTO_GEN_LITE_DIR}> -) - -target_link_libraries(OpenSteamTool PRIVATE - lua_static - detours - winhttp - Bcrypt - $<$:libprotobuf> - $<$:libprotobuf-lite> - tomlplusplus::tomlplusplus - $<$:spdlog::spdlog> -) - -# Logging is compiled in only for Debug; Release reduces LOG_* to no-ops. -target_compile_definitions(OpenSteamTool PRIVATE - $<$:OPENSTEAMTOOL_LOGGING_ENABLED> -) - -# --------------------------------------------------------------------------- -# dwmapi.dll hijack — small loader DLL placed alongside Steam. -# --------------------------------------------------------------------------- -add_library(dwmapi SHARED - dwmapi/dwmapi.cpp -) - -# --------------------------------------------------------------------------- -# xinput1_4.dll hijack — secondary loader DLL placed alongside Steam. -# --------------------------------------------------------------------------- -add_library(xinput1_4 SHARED - xinput1_4/xinput1_4.cpp - xinput1_4/xinput1_4.def -) +cmake_minimum_required(VERSION 3.20) +project(OpenSteamTool VERSION 1.0.0 LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_C_STANDARD 11) + +# Allow CMAKE_MSVC_RUNTIME_LIBRARY to control runtime selection for all targets, +# including dependencies pulled in via FetchContent. +if(POLICY CMP0091) + cmake_policy(SET CMP0091 NEW) +endif() + +# Static MSVC runtime everywhere, so the resulting DLL has no extra runtime +# dependencies. Must be set BEFORE FetchContent_MakeAvailable so the fetched +# deps (Lua, Detours, spdlog) inherit it. +set(CMAKE_CONFIGURATION_TYPES "Debug;Release" CACHE STRING "" FORCE) +set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + +# --------------------------------------------------------------------------- +# Dependency recipes (FetchContent-backed, cached at /.deps). +# --------------------------------------------------------------------------- +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") +include(Lua) +include(Detours) +include(Spdlog) +include(Protobuf) +include(Tomlplusplus) +include(LogMacros) + +# --------------------------------------------------------------------------- +# Protobuf code generation — two variants from the same .proto: +# +# Debug → full Message (protoc --cpp_out) → links libprotobuf +# Release → lite MessageLite (protoc --cpp_out=lite) → links libprotobuf-lite +# +# Both land in separate subdirectories of the build tree so the source +# directory stays clean and the right set is picked per configuration. +# --------------------------------------------------------------------------- +set(PROTO_SRC "${CMAKE_CURRENT_SOURCE_DIR}/proto/steam_messages.proto") +set(PROTO_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/proto") +set(PROTO_GEN_LITE_DIR "${CMAKE_CURRENT_BINARY_DIR}/proto_lite") + +# Full Message (Debug) +add_custom_command( + OUTPUT "${PROTO_GEN_DIR}/steam_messages.pb.cc" + "${PROTO_GEN_DIR}/steam_messages.pb.h" + COMMAND ${CMAKE_COMMAND} -E make_directory "${PROTO_GEN_DIR}" + COMMAND $ + "--cpp_out=${PROTO_GEN_DIR}" + "-I${CMAKE_CURRENT_SOURCE_DIR}/proto" + "${PROTO_SRC}" + DEPENDS "${PROTO_SRC}" protoc + COMMENT "Generating protobuf full-Message sources (Debug)" +) + +# Lite MessageLite (Release) +add_custom_command( + OUTPUT "${PROTO_GEN_LITE_DIR}/steam_messages.pb.cc" + "${PROTO_GEN_LITE_DIR}/steam_messages.pb.h" + COMMAND ${CMAKE_COMMAND} -E make_directory "${PROTO_GEN_LITE_DIR}" + COMMAND $ + "--cpp_out=lite:${PROTO_GEN_LITE_DIR}" + "-I${CMAKE_CURRENT_SOURCE_DIR}/proto" + "${PROTO_SRC}" + DEPENDS "${PROTO_SRC}" protoc + COMMENT "Generating protobuf lite-MessageLite sources (Release)" +) + +# --------------------------------------------------------------------------- +# OpenSteamTool — the hook DLL injected into Steam (always 64-bit). +# --------------------------------------------------------------------------- +add_library(OpenSteamTool SHARED + dllmain.cpp + + # Shared utilities + Utils/AppTicket.cpp + Utils/ByteSearch.cpp + Utils/PatternLoader.cpp + Utils/Log.cpp + Utils/Config.cpp + Utils/LuaConfig.cpp + Utils/VehCommon.cpp + Utils/WinHttp.cpp + Utils/FileWatcher.cpp + Utils/DllDirectory.cpp + + # Per-category hook modules + Hook/HookManager.cpp + Hook/Hooks_CallBack.cpp + Hook/Hooks_Decryption.cpp + Hook/Hooks_IPC.cpp + Hook/Hooks_IPC_ISteamUser.cpp + Hook/Hooks_IPC_ISteamUtils.cpp + Hook/Hooks_KeyValues.cpp + Hook/Hooks_Manifest.cpp + Hook/Hooks_Misc.cpp + Hook/Hooks_NetPacket.cpp + Hook/Hooks_SteamUI.cpp + Hook/Hooks_Package.cpp + + # protobuf generated sources — per-config variant + $<$:${PROTO_GEN_DIR}/steam_messages.pb.cc> + $<$:${PROTO_GEN_LITE_DIR}/steam_messages.pb.cc> +) + +# Header search path — per-config include directory +target_include_directories(OpenSteamTool PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_BINARY_DIR}/generated + $<$:${PROTO_GEN_DIR}> + $<$:${PROTO_GEN_LITE_DIR}> +) + +target_link_libraries(OpenSteamTool PRIVATE + lua_static + detours + winhttp + Bcrypt + $<$:libprotobuf> + $<$:libprotobuf-lite> + tomlplusplus::tomlplusplus + $<$:spdlog::spdlog> +) + +# Logging is compiled in only for Debug; Release reduces LOG_* to no-ops. +target_compile_definitions(OpenSteamTool PRIVATE + $<$:OPENSTEAMTOOL_LOGGING_ENABLED> +) + +# --------------------------------------------------------------------------- +# dwmapi.dll hijack — small loader DLL placed alongside Steam. +# --------------------------------------------------------------------------- +add_library(dwmapi SHARED + dwmapi/dwmapi.cpp +) + +# --------------------------------------------------------------------------- +# xinput1_4.dll hijack — secondary loader DLL placed alongside Steam. +# --------------------------------------------------------------------------- +add_library(xinput1_4 SHARED + xinput1_4/xinput1_4.cpp + xinput1_4/xinput1_4.def +) diff --git a/src/Utils/DllDirectory.cpp b/src/Utils/DllDirectory.cpp new file mode 100644 index 00000000..19584313 --- /dev/null +++ b/src/Utils/DllDirectory.cpp @@ -0,0 +1,19 @@ +#include "DllDirectory.h" +#include + +namespace Utils { + + std::filesystem::path GetDllDirectory() { + HMODULE hSelf = nullptr; + GetModuleHandleExA( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | + GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + reinterpret_cast(&GetDllDirectory), + &hSelf + ); + char dllPath[MAX_PATH] = { 0 }; + GetModuleFileNameA(hSelf, dllPath, MAX_PATH); + return std::filesystem::path(dllPath).parent_path(); + } + +} \ No newline at end of file diff --git a/src/Utils/DllDirectory.h b/src/Utils/DllDirectory.h new file mode 100644 index 00000000..24cbe9c4 --- /dev/null +++ b/src/Utils/DllDirectory.h @@ -0,0 +1,7 @@ +#pragma once +#include + +namespace Utils { + // Returns the directory where the current DLL is located. + std::filesystem::path GetDllDirectory(); +} \ No newline at end of file diff --git a/src/Utils/PatternLoader.cpp b/src/Utils/PatternLoader.cpp index c3ed8fc3..771ebf6d 100644 --- a/src/Utils/PatternLoader.cpp +++ b/src/Utils/PatternLoader.cpp @@ -1,429 +1,430 @@ -#include "PatternLoader.h" -#include "Config.h" -#include "Hash.h" -#include "Log.h" -#include "WinHttp.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -// ---- compile-time sanity checks for FNV-1a table keys ---- -// If the steam-monitor bot uses the same algorithm these must hold. -static_assert(Fnv1aHash("BBuildAndAsyncSendFrame") == 0x82428E37u, - "FNV-1a mismatch for BBuildAndAsyncSendFrame"); -static_assert(Fnv1aHash("BuildDepotDependency") == 0xC37F2D8Eu, - "FNV-1a mismatch for BuildDepotDependency"); - -namespace { - -// ---- per-function pattern record ---- -struct PatternEntry { - std::string name; - uintptr_t rva = 0; // 0 = not present in file - std::string sig; // empty = not present in file -}; - -// key = Fnv1aHash(funcName) -using PatternMap = std::unordered_map; - -// module → its pattern map -static std::unordered_map g_moduleMaps; - -// Modules whose Load() call failed (popup already shown). FindPattern -// silently returns nullptr for these — without re-logging or adding the -// function to g_missingFunctions — so we don't follow one "TOML missing" -// popup with a second popup listing every dependent hook. -static std::unordered_set g_failedModules; - -// functions whose names were not found during FindPattern -static std::vector g_missingFunctions; - -// Built-in fallback mirrors. Tried in this fixed order when [pattern] -// mirror is not configured: GitHub raw first (canonical source), jsDelivr -// (global CDN) on connection failure. -static constexpr const char* kGithubMirror = - "https://raw.githubusercontent.com/OpenSteam001/steam-monitor/pattern"; -static constexpr const char* kJsdelivrMirror = - "https://cdn.jsdelivr.net/gh/OpenSteam001/steam-monitor@pattern"; - -// ---- byte-pattern scanner (independent of old ByteSearch) ---- - -static bool ParseSig(const std::string& str, - std::vector& bytes, - std::vector& mask) -{ - bytes.clear(); - mask.clear(); - for (const char* p = str.c_str(); *p; ) { - if (*p == ' ' || *p == '\t' || *p == ',') { ++p; continue; } - if (p[0] == '?' && p[1] == '?') { - bytes.push_back(0); mask.push_back(0); p += 2; continue; - } - char hi = p[0], lo = p[1]; - if (!hi || !lo) return false; - auto nib = [](char c) -> int { - if (c >= '0' && c <= '9') return c - '0'; - if (c >= 'a' && c <= 'f') return c - 'a' + 10; - if (c >= 'A' && c <= 'F') return c - 'A' + 10; - return -1; - }; - int h = nib(hi), l = nib(lo); - if (h < 0 || l < 0) return false; - bytes.push_back(static_cast((h << 4) | l)); - mask.push_back(1); - p += 2; - } - return !bytes.empty(); -} - -static void* ScanModule(HMODULE module, - const std::vector& bytes, - const std::vector& mask) -{ - MODULEINFO mi{}; - if (!GetModuleInformation(GetCurrentProcess(), module, &mi, sizeof(mi))) - return nullptr; - - auto* base = static_cast(mi.lpBaseOfDll); - SIZE_T size = mi.SizeOfImage; - SIZE_T patLen = bytes.size(); - if (size < patLen) return nullptr; - - for (SIZE_T i = 0; i <= size - patLen; ++i) { - bool found = true; - for (SIZE_T j = 0; j < patLen; ++j) { - if (mask[j] && base[i + j] != bytes[j]) { found = false; break; } - } - if (found) return base + i; - } - return nullptr; -} - -// ---- TOML pattern parser ---- - -// Section keys are hex literals like "0x82428E37"; each section is a table -// with optional `name`, `rva` (hex string), and `sig` (IDA-style bytes). -static PatternMap TableToPatternMap(const toml::table& tbl) -{ - PatternMap map; - map.reserve(tbl.size()); - for (auto& [rawKey, val] : tbl) { - if (!val.is_table()) continue; - auto& sub = *val.as_table(); - - uint32_t hashKey = 0; - try { - hashKey = static_cast( - std::stoull(std::string(rawKey), nullptr, 16)); - } catch (...) { continue; } - - PatternEntry entry; - if (auto v = sub["name"].value()) entry.name = *v; - if (auto v = sub["rva"].value()) { - try { entry.rva = static_cast(std::stoull(*v, nullptr, 16)); } - catch (...) {} - } - if (auto v = sub["sig"].value()) entry.sig = *v; - - map[hashKey] = std::move(entry); - } - return map; -} - -static PatternMap ParsePatternFile(const std::filesystem::path& filePath) -{ - try { - return TableToPatternMap(toml::parse_file(filePath.string())); - } catch (const toml::parse_error& e) { - LOG_WARN("PatternLoader: TOML parse error in {}: {}", - filePath.string(), e.description()); - return {}; - } -} - -static PatternMap ParsePatternString(std::string_view body, - std::string* outError = nullptr) -{ - try { - return TableToPatternMap(toml::parse(body)); - } catch (const toml::parse_error& e) { - if (outError) *outError = e.description(); - return {}; - } -} - -// ---- popup helpers (detached threads so we never block Steam) ---- - -// Surface a missing pattern file to the user, with enough detail to either -// (a) drop a file in manually, (b) check the upstream repo, or (c) file -// an actionable bug report. We deliberately only disable hooks for the -// failing module — the rest of OpenSteamTool keeps working. -static void ShowDownloadFailedPopup(const std::string& dllName, - const std::string& sha256, - const std::string& ghSubdir) -{ - std::thread([dllName, sha256, ghSubdir]() { - std::string msg = - "OpenSteamTool: signature file not found for " + dllName + ".\n\n" - " Steam DLL: " + dllName + "\n" - " SHA-256: " + sha256 + "\n\n" - "Steam was likely just updated and the matching pattern file is " - "not yet published on the steam-monitor server. Hooks that depend " - "on " + dllName + " are disabled for this session; other modules " - "are unaffected.\n\n" - "You can:\n" - " 1. Wait for the next signature update (usually within hours of " - "a new Steam build), then restart Steam.\n" - " 2. Drop a matching TOML at:\n" - " \\opensteamtool\\pattern\\" + ghSubdir + "\\" + sha256 + ".toml\n" - " 3. Check upstream:\n" - " https://github.com/OpenSteam001/steam-monitor/tree/pattern/" + ghSubdir + "\n" - " 4. Report this hash so it gets prioritized:\n" - " https://github.com/OpenSteam001/OpenSteamTool/issues"; - MessageBoxA(nullptr, msg.c_str(), - "OpenSteamTool - Unsupported Steam Version", - MB_OK | MB_ICONWARNING | MB_TOPMOST); - }).detach(); -} - -} // namespace - -// ---- public API ---- - -namespace PatternLoader { - -bool Load(HMODULE module, const std::string& dllPath, const std::string& ghSubdir) -{ - namespace fs = std::filesystem; - - // 1. Compute SHA-256 of the DLL file on disk. - // Timed so we can see the cost in main.log — useful when triaging - // "Steam takes ages to start" reports from HDD users. - const auto hashStart = std::chrono::steady_clock::now(); - const std::string sha256 = Sha256OfFile(dllPath); - const auto hashMs = std::chrono::duration_cast( - std::chrono::steady_clock::now() - hashStart).count(); - - if (sha256.empty()) { - LOG_WARN("PatternLoader: Sha256OfFile failed for {} ({} ms)", dllPath, hashMs); - ShowDownloadFailedPopup(fs::path(dllPath).filename().string(), - "(hash failed)", ghSubdir); - g_failedModules.insert(module); - return false; - } - LOG_INFO("PatternLoader: {} sha256 = {} ({} ms)", ghSubdir, sha256, hashMs); - - // 2. Build local cache path and make sure the directory exists. - // Cache lives at: /opensteamtool/pattern//.toml - // dllPath is always inside the Steam root directory. - fs::path steamRoot = fs::path(dllPath).parent_path(); - fs::path cacheDir = steamRoot / "opensteamtool" / "pattern" / ghSubdir; - fs::path cachePath = cacheDir / (sha256 + ".toml"); - - std::error_code mkdirEc; - fs::create_directories(cacheDir, mkdirEc); - if (mkdirEc) { - // Non-fatal: we can still try to read an existing file or hold the - // downloaded TOML in memory. Log it so disk-permission issues surface. - LOG_WARN("PatternLoader: could not create cache dir {} ({})", - cacheDir.string(), mkdirEc.message()); - } - - // 3. Try remote first. Rationale: the upstream bot can re-publish the - // TOML for the same SHA-256 (adding new function signatures, fixing - // stale ones, etc.). Reading the local cache first would silently - // pin users to whatever version they downloaded on day 1. The cache - // is kept purely as an offline fallback below. - // - // Mirror selection: - // - If [pattern] mirror is configured, use only that URL. Explicit - // user choice wins — no automatic fallback. - // - Otherwise try GitHub raw, then jsDelivr on connection failure - // (helps users where raw.githubusercontent.com is blocked). - // - HTTP 404 stops the loop early: all mirrors serve the same data, - // so 404 means the upstream bot hasn't published this SHA yet. - std::vector mirrors; - if (!Config::patternMirror.empty()) { - mirrors.push_back(Config::patternMirror); - } else { - mirrors.emplace_back(kGithubMirror); - mirrors.emplace_back(kJsdelivrMirror); - } - - WinHttp::Result result; - std::string url; - for (size_t i = 0; i < mirrors.size(); ++i) { - url = mirrors[i] + "/" + ghSubdir + "/" + sha256 + ".toml"; - LOG_INFO("PatternLoader: downloading {}", url); - - result = WinHttp::Execute(L"GET", url.c_str(), - nullptr, 0, nullptr, - /*timeoutResolve=*/5000, - /*timeoutConnect=*/5000, - /*timeoutSend=*/10000, - /*timeoutRecv=*/15000); - - if (result.ok && result.status == 200) break; - - if (result.ok && result.status == 404) { - LOG_WARN("PatternLoader: mirror has no such file (HTTP 404): {}", url); - break; // all mirrors serve the same content — no point trying others - } - - // Connection error or 5xx — try next mirror if any - if (i + 1 < mirrors.size()) { - LOG_WARN("PatternLoader: mirror failed ({} ok={} HTTP={}), falling back", - mirrors[i], result.ok, result.status); - } - } - - // 4. Remote succeeded → parse, then update cache on disk so the next - // launch has an up-to-date offline fallback. - if (result.ok && result.status == 200) { - std::string parseErr; - PatternMap map = ParsePatternString(result.body, &parseErr); - if (!map.empty()) { - std::ofstream ofs(cachePath, std::ios::binary); - if (ofs) { - ofs.write(result.body.data(), - static_cast(result.body.size())); - LOG_INFO("PatternLoader: cached to {}", cachePath.string()); - } else { - LOG_WARN("PatternLoader: could not open {} for writing", - cachePath.string()); - } - LOG_INFO("PatternLoader: loaded {} patterns for {} (remote)", - map.size(), ghSubdir); - g_moduleMaps[module] = std::move(map); - return true; - } - LOG_WARN("PatternLoader: downloaded body unparseable ({}); " - "trying local cache", - parseErr.empty() ? "empty or no entries" : parseErr); - } - - // 5. Remote unreachable (or returned garbage) → fall back to whatever - // we previously cached for this exact SHA-256. Better stale-but- - // working than nothing at all. - if (fs::exists(cachePath)) { - LOG_WARN("PatternLoader: remote failed (last: {} HTTP {}); " - "falling back to local cache {}", - url, result.status, cachePath.string()); - PatternMap map = ParsePatternFile(cachePath); - if (!map.empty()) { - LOG_INFO("PatternLoader: loaded {} patterns for {} (cache fallback)", - map.size(), ghSubdir); - g_moduleMaps[module] = std::move(map); - return true; - } - LOG_WARN("PatternLoader: cache fallback also failed (file empty/invalid)"); - } - - // 6. Remote failed and no usable cache — give up. - LOG_WARN("PatternLoader: no source available for {} (last URL: {} HTTP {})", - ghSubdir, url, result.status); - std::string dllName = fs::path(dllPath).filename().string(); - ShowDownloadFailedPopup(dllName, sha256, ghSubdir); - g_failedModules.insert(module); - return false; -} - -void* FindPattern(HMODULE module, const char* funcName) -{ - // If the whole module's pattern file failed to load, stay quiet — the - // user already saw one popup and the main.log already has the warning. - // No point amplifying that into one log line per hook plus a second - // "missing functions" popup later. - if (g_failedModules.count(module)) { - return nullptr; - } - - uint32_t key = Fnv1aHash(funcName); - - auto mapIt = g_moduleMaps.find(module); - if (mapIt == g_moduleMaps.end()) { - // Load() was never called for this module. - LOG_WARN("PatternLoader: FindPattern called for module that was never loaded " - "('{}')", funcName); - g_missingFunctions.emplace_back(funcName); - return nullptr; - } - - auto& map = mapIt->second; - auto entryIt = map.find(key); - if (entryIt == map.end()) { - LOG_WARN("PatternLoader: no entry for '{}' (key=0x{:08X})", funcName, key); - g_missingFunctions.emplace_back(funcName); - return nullptr; - } - - const PatternEntry& entry = entryIt->second; - - // Priority 1: RVA direct offset - if (entry.rva != 0) { - void* addr = reinterpret_cast( - reinterpret_cast(module) + entry.rva); - LOG_DEBUG("PatternLoader: {} resolved via RVA 0x{:X}", funcName, entry.rva); - return addr; - } - - // Priority 2: byte-signature scan - if (!entry.sig.empty()) { - std::vector bytes, mask; - if (ParseSig(entry.sig, bytes, mask)) { - void* addr = ScanModule(module, bytes, mask); - if (addr) { - uintptr_t rva = reinterpret_cast(addr) - - reinterpret_cast(module); - LOG_DEBUG("PatternLoader: {} resolved via sig @ RVA 0x{:X}", - funcName, rva); - return addr; - } - LOG_WARN("PatternLoader: sig scan miss for '{}' (pattern parsed OK, " - "no match in module image)", funcName); - } else { - LOG_WARN("PatternLoader: malformed sig for '{}': '{}'", - funcName, entry.sig); - } - } else { - LOG_WARN("PatternLoader: entry for '{}' has neither rva nor sig", funcName); - } - - g_missingFunctions.emplace_back(funcName); - return nullptr; -} - -void ReportMissingFunctions() -{ - if (g_missingFunctions.empty()) return; - - // Build the list - std::string list; - for (const auto& name : g_missingFunctions) - list += " - " + name + "\n"; - g_missingFunctions.clear(); - - std::thread([list]() { - std::string msg = - "OpenSteamTool: some functions could not be located.\n\n" - "The following functions were not found in the signature file:\n" + - list + - "\nHooks for these functions are disabled for this session.\n\n" - "Please report this at:\n" - "https://github.com/OpenSteam001/OpenSteamTool/issues"; - MessageBoxA(nullptr, msg.c_str(), - "OpenSteamTool - Missing Signatures", - MB_OK | MB_ICONWARNING | MB_TOPMOST); - }).detach(); -} - -} // namespace PatternLoader +#include "PatternLoader.h" +#include "Config.h" +#include "Hash.h" +#include "Log.h" +#include "WinHttp.h" +#include "DllDirectory.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +// ---- compile-time sanity checks for FNV-1a table keys ---- +// If the steam-monitor bot uses the same algorithm these must hold. +static_assert(Fnv1aHash("BBuildAndAsyncSendFrame") == 0x82428E37u, + "FNV-1a mismatch for BBuildAndAsyncSendFrame"); +static_assert(Fnv1aHash("BuildDepotDependency") == 0xC37F2D8Eu, + "FNV-1a mismatch for BuildDepotDependency"); + +namespace { + +// ---- per-function pattern record ---- +struct PatternEntry { + std::string name; + uintptr_t rva = 0; // 0 = not present in file + std::string sig; // empty = not present in file +}; + +// key = Fnv1aHash(funcName) +using PatternMap = std::unordered_map; + +// module → its pattern map +static std::unordered_map g_moduleMaps; + +// Modules whose Load() call failed (popup already shown). FindPattern +// silently returns nullptr for these — without re-logging or adding the +// function to g_missingFunctions — so we don't follow one "TOML missing" +// popup with a second popup listing every dependent hook. +static std::unordered_set g_failedModules; + +// functions whose names were not found during FindPattern +static std::vector g_missingFunctions; + +// Built-in fallback mirrors. Tried in this fixed order when [pattern] +// mirror is not configured: GitHub raw first (canonical source), jsDelivr +// (global CDN) on connection failure. +static constexpr const char* kGithubMirror = + "https://raw.githubusercontent.com/OpenSteam001/steam-monitor/pattern"; +static constexpr const char* kJsdelivrMirror = + "https://cdn.jsdelivr.net/gh/OpenSteam001/steam-monitor@pattern"; + +// ---- byte-pattern scanner (independent of old ByteSearch) ---- + +static bool ParseSig(const std::string& str, + std::vector& bytes, + std::vector& mask) +{ + bytes.clear(); + mask.clear(); + for (const char* p = str.c_str(); *p; ) { + if (*p == ' ' || *p == '\t' || *p == ',') { ++p; continue; } + if (p[0] == '?' && p[1] == '?') { + bytes.push_back(0); mask.push_back(0); p += 2; continue; + } + char hi = p[0], lo = p[1]; + if (!hi || !lo) return false; + auto nib = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + int h = nib(hi), l = nib(lo); + if (h < 0 || l < 0) return false; + bytes.push_back(static_cast((h << 4) | l)); + mask.push_back(1); + p += 2; + } + return !bytes.empty(); +} + +static void* ScanModule(HMODULE module, + const std::vector& bytes, + const std::vector& mask) +{ + MODULEINFO mi{}; + if (!GetModuleInformation(GetCurrentProcess(), module, &mi, sizeof(mi))) + return nullptr; + + auto* base = static_cast(mi.lpBaseOfDll); + SIZE_T size = mi.SizeOfImage; + SIZE_T patLen = bytes.size(); + if (size < patLen) return nullptr; + + for (SIZE_T i = 0; i <= size - patLen; ++i) { + bool found = true; + for (SIZE_T j = 0; j < patLen; ++j) { + if (mask[j] && base[i + j] != bytes[j]) { found = false; break; } + } + if (found) return base + i; + } + return nullptr; +} + +// ---- TOML pattern parser ---- + +// Section keys are hex literals like "0x82428E37"; each section is a table +// with optional `name`, `rva` (hex string), and `sig` (IDA-style bytes). +static PatternMap TableToPatternMap(const toml::table& tbl) +{ + PatternMap map; + map.reserve(tbl.size()); + for (auto& [rawKey, val] : tbl) { + if (!val.is_table()) continue; + auto& sub = *val.as_table(); + + uint32_t hashKey = 0; + try { + hashKey = static_cast( + std::stoull(std::string(rawKey), nullptr, 16)); + } catch (...) { continue; } + + PatternEntry entry; + if (auto v = sub["name"].value()) entry.name = *v; + if (auto v = sub["rva"].value()) { + try { entry.rva = static_cast(std::stoull(*v, nullptr, 16)); } + catch (...) {} + } + if (auto v = sub["sig"].value()) entry.sig = *v; + + map[hashKey] = std::move(entry); + } + return map; +} + +static PatternMap ParsePatternFile(const std::filesystem::path& filePath) +{ + try { + return TableToPatternMap(toml::parse_file(filePath.string())); + } catch (const toml::parse_error& e) { + LOG_WARN("PatternLoader: TOML parse error in {}: {}", + filePath.string(), e.description()); + return {}; + } +} + +static PatternMap ParsePatternString(std::string_view body, + std::string* outError = nullptr) +{ + try { + return TableToPatternMap(toml::parse(body)); + } catch (const toml::parse_error& e) { + if (outError) *outError = e.description(); + return {}; + } +} + +// ---- popup helpers (detached threads so we never block Steam) ---- + +// Surface a missing pattern file to the user, with enough detail to either +// (a) drop a file in manually, (b) check the upstream repo, or (c) file +// an actionable bug report. We deliberately only disable hooks for the +// failing module — the rest of OpenSteamTool keeps working. +static void ShowDownloadFailedPopup(const std::string& dllName, + const std::string& sha256, + const std::string& ghSubdir) +{ + std::thread([dllName, sha256, ghSubdir]() { + std::string msg = + "OpenSteamTool: signature file not found for " + dllName + ".\n\n" + " Steam DLL: " + dllName + "\n" + " SHA-256: " + sha256 + "\n\n" + "Steam was likely just updated and the matching pattern file is " + "not yet published on the steam-monitor server. Hooks that depend " + "on " + dllName + " are disabled for this session; other modules " + "are unaffected.\n\n" + "You can:\n" + " 1. Wait for the next signature update (usually within hours of " + "a new Steam build), then restart Steam.\n" + " 2. Drop a matching TOML at:\n" + " \\opensteamtool\\pattern\\" + ghSubdir + "\\" + sha256 + ".toml\n" + " 3. Check upstream:\n" + " https://github.com/OpenSteam001/steam-monitor/tree/pattern/" + ghSubdir + "\n" + " 4. Report this hash so it gets prioritized:\n" + " https://github.com/OpenSteam001/OpenSteamTool/issues"; + MessageBoxA(nullptr, msg.c_str(), + "OpenSteamTool - Unsupported Steam Version", + MB_OK | MB_ICONWARNING | MB_TOPMOST); + }).detach(); +} + +} // namespace + +// ---- public API ---- + +namespace PatternLoader { + +bool Load(HMODULE module, const std::string& dllPath, const std::string& ghSubdir) +{ + namespace fs = std::filesystem; + + // 1. Compute SHA-256 of the DLL file on disk. + // Timed so we can see the cost in main.log — useful when triaging + // "Steam takes ages to start" reports from HDD users. + const auto hashStart = std::chrono::steady_clock::now(); + const std::string sha256 = Sha256OfFile(dllPath); + const auto hashMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - hashStart).count(); + + if (sha256.empty()) { + LOG_WARN("PatternLoader: Sha256OfFile failed for {} ({} ms)", dllPath, hashMs); + ShowDownloadFailedPopup(fs::path(dllPath).filename().string(), + "(hash failed)", ghSubdir); + g_failedModules.insert(module); + return false; + } + LOG_INFO("PatternLoader: {} sha256 = {} ({} ms)", ghSubdir, sha256, hashMs); + + // 2. Build local cache path and make sure the directory exists. + // Cache lives at: /opensteamtool/pattern//.toml + // dllPath is always inside the Steam root directory. + fs::path dllRoot = Utils::GetDllDirectory(); + fs::path cacheDir = dllRoot / "opensteamtool" / "pattern" / ghSubdir; + fs::path cachePath = cacheDir / (sha256 + ".toml"); + + std::error_code mkdirEc; + fs::create_directories(cacheDir, mkdirEc); + if (mkdirEc) { + // Non-fatal: we can still try to read an existing file or hold the + // downloaded TOML in memory. Log it so disk-permission issues surface. + LOG_WARN("PatternLoader: could not create cache dir {} ({})", + cacheDir.string(), mkdirEc.message()); + } + + // 3. Try remote first. Rationale: the upstream bot can re-publish the + // TOML for the same SHA-256 (adding new function signatures, fixing + // stale ones, etc.). Reading the local cache first would silently + // pin users to whatever version they downloaded on day 1. The cache + // is kept purely as an offline fallback below. + // + // Mirror selection: + // - If [pattern] mirror is configured, use only that URL. Explicit + // user choice wins — no automatic fallback. + // - Otherwise try GitHub raw, then jsDelivr on connection failure + // (helps users where raw.githubusercontent.com is blocked). + // - HTTP 404 stops the loop early: all mirrors serve the same data, + // so 404 means the upstream bot hasn't published this SHA yet. + std::vector mirrors; + if (!Config::patternMirror.empty()) { + mirrors.push_back(Config::patternMirror); + } else { + mirrors.emplace_back(kGithubMirror); + mirrors.emplace_back(kJsdelivrMirror); + } + + WinHttp::Result result; + std::string url; + for (size_t i = 0; i < mirrors.size(); ++i) { + url = mirrors[i] + "/" + ghSubdir + "/" + sha256 + ".toml"; + LOG_INFO("PatternLoader: downloading {}", url); + + result = WinHttp::Execute(L"GET", url.c_str(), + nullptr, 0, nullptr, + /*timeoutResolve=*/5000, + /*timeoutConnect=*/5000, + /*timeoutSend=*/10000, + /*timeoutRecv=*/15000); + + if (result.ok && result.status == 200) break; + + if (result.ok && result.status == 404) { + LOG_WARN("PatternLoader: mirror has no such file (HTTP 404): {}", url); + break; // all mirrors serve the same content — no point trying others + } + + // Connection error or 5xx — try next mirror if any + if (i + 1 < mirrors.size()) { + LOG_WARN("PatternLoader: mirror failed ({} ok={} HTTP={}), falling back", + mirrors[i], result.ok, result.status); + } + } + + // 4. Remote succeeded → parse, then update cache on disk so the next + // launch has an up-to-date offline fallback. + if (result.ok && result.status == 200) { + std::string parseErr; + PatternMap map = ParsePatternString(result.body, &parseErr); + if (!map.empty()) { + std::ofstream ofs(cachePath, std::ios::binary); + if (ofs) { + ofs.write(result.body.data(), + static_cast(result.body.size())); + LOG_INFO("PatternLoader: cached to {}", cachePath.string()); + } else { + LOG_WARN("PatternLoader: could not open {} for writing", + cachePath.string()); + } + LOG_INFO("PatternLoader: loaded {} patterns for {} (remote)", + map.size(), ghSubdir); + g_moduleMaps[module] = std::move(map); + return true; + } + LOG_WARN("PatternLoader: downloaded body unparseable ({}); " + "trying local cache", + parseErr.empty() ? "empty or no entries" : parseErr); + } + + // 5. Remote unreachable (or returned garbage) → fall back to whatever + // we previously cached for this exact SHA-256. Better stale-but- + // working than nothing at all. + if (fs::exists(cachePath)) { + LOG_WARN("PatternLoader: remote failed (last: {} HTTP {}); " + "falling back to local cache {}", + url, result.status, cachePath.string()); + PatternMap map = ParsePatternFile(cachePath); + if (!map.empty()) { + LOG_INFO("PatternLoader: loaded {} patterns for {} (cache fallback)", + map.size(), ghSubdir); + g_moduleMaps[module] = std::move(map); + return true; + } + LOG_WARN("PatternLoader: cache fallback also failed (file empty/invalid)"); + } + + // 6. Remote failed and no usable cache — give up. + LOG_WARN("PatternLoader: no source available for {} (last URL: {} HTTP {})", + ghSubdir, url, result.status); + std::string dllName = fs::path(dllPath).filename().string(); + ShowDownloadFailedPopup(dllName, sha256, ghSubdir); + g_failedModules.insert(module); + return false; +} + +void* FindPattern(HMODULE module, const char* funcName) +{ + // If the whole module's pattern file failed to load, stay quiet — the + // user already saw one popup and the main.log already has the warning. + // No point amplifying that into one log line per hook plus a second + // "missing functions" popup later. + if (g_failedModules.count(module)) { + return nullptr; + } + + uint32_t key = Fnv1aHash(funcName); + + auto mapIt = g_moduleMaps.find(module); + if (mapIt == g_moduleMaps.end()) { + // Load() was never called for this module. + LOG_WARN("PatternLoader: FindPattern called for module that was never loaded " + "('{}')", funcName); + g_missingFunctions.emplace_back(funcName); + return nullptr; + } + + auto& map = mapIt->second; + auto entryIt = map.find(key); + if (entryIt == map.end()) { + LOG_WARN("PatternLoader: no entry for '{}' (key=0x{:08X})", funcName, key); + g_missingFunctions.emplace_back(funcName); + return nullptr; + } + + const PatternEntry& entry = entryIt->second; + + // Priority 1: RVA direct offset + if (entry.rva != 0) { + void* addr = reinterpret_cast( + reinterpret_cast(module) + entry.rva); + LOG_DEBUG("PatternLoader: {} resolved via RVA 0x{:X}", funcName, entry.rva); + return addr; + } + + // Priority 2: byte-signature scan + if (!entry.sig.empty()) { + std::vector bytes, mask; + if (ParseSig(entry.sig, bytes, mask)) { + void* addr = ScanModule(module, bytes, mask); + if (addr) { + uintptr_t rva = reinterpret_cast(addr) - + reinterpret_cast(module); + LOG_DEBUG("PatternLoader: {} resolved via sig @ RVA 0x{:X}", + funcName, rva); + return addr; + } + LOG_WARN("PatternLoader: sig scan miss for '{}' (pattern parsed OK, " + "no match in module image)", funcName); + } else { + LOG_WARN("PatternLoader: malformed sig for '{}': '{}'", + funcName, entry.sig); + } + } else { + LOG_WARN("PatternLoader: entry for '{}' has neither rva nor sig", funcName); + } + + g_missingFunctions.emplace_back(funcName); + return nullptr; +} + +void ReportMissingFunctions() +{ + if (g_missingFunctions.empty()) return; + + // Build the list + std::string list; + for (const auto& name : g_missingFunctions) + list += " - " + name + "\n"; + g_missingFunctions.clear(); + + std::thread([list]() { + std::string msg = + "OpenSteamTool: some functions could not be located.\n\n" + "The following functions were not found in the signature file:\n" + + list + + "\nHooks for these functions are disabled for this session.\n\n" + "Please report this at:\n" + "https://github.com/OpenSteam001/OpenSteamTool/issues"; + MessageBoxA(nullptr, msg.c_str(), + "OpenSteamTool - Missing Signatures", + MB_OK | MB_ICONWARNING | MB_TOPMOST); + }).detach(); +} + +} // namespace PatternLoader diff --git a/src/Utils/Utils.h b/src/Utils/Utils.h new file mode 100644 index 00000000..4d4d0e56 --- /dev/null +++ b/src/Utils/Utils.h @@ -0,0 +1,12 @@ +#pragma once +#include +#include +#include + +namespace Utils { + inline std::filesystem::path GetDllDirectory() { + char dllPath[MAX_PATH]; + GetModuleFileNameA(g_hSelfModule, dllPath, MAX_PATH); + return std::filesystem::path(dllPath).parent_path(); + } +} \ No newline at end of file diff --git a/src/dllmain.cpp b/src/dllmain.cpp index 9db12a99..cbcbf2d1 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -1,92 +1,94 @@ -#include "dllmain.h" -#include "Hook/HookManager.h" -#include "Utils/FileWatcher.h" -#include "Utils/PatternLoader.h" - -// prepare key runtime paths. -bool InitializeSteamComponents() -{ - if (!GetCurrentDirectoryA(MAX_PATH, SteamInstallPath)) { - return false; - } - sprintf_s(SteamclientPath, MAX_PATH, "%s\\steamclient64.dll", SteamInstallPath); - sprintf_s(SteamUIPath, MAX_PATH, "%s\\steamui.dll", SteamInstallPath); - sprintf_s(DiversionPath, MAX_PATH, "%s\\bin\\diversion.dll", SteamInstallPath); - sprintf_s(LuaDir, MAX_PATH, "%s\\config\\lua", SteamInstallPath); - sprintf_s(ConfigPath, MAX_PATH, "%s\\opensteamtool.toml", SteamInstallPath); - - client_hModule = LoadLibraryA(SteamclientPath); - if (!client_hModule) { - LOG_ERROR("LoadLibraryA failed: {} (err={})", SteamclientPath, GetLastError()); - return false; - } - LOG_INFO("Loaded diversion.dll from {}", SteamclientPath); - - ui_hModule = GetModuleHandleA("steamui.dll"); - if(!ui_hModule) { - LOG_ERROR("GetModuleHandleA failed for steamui.dll: err={}", GetLastError()); - return false; - } - return true; -} - -// All initialisation that touches the filesystem, calls LoadLibrary, scans -// memory, or installs detours runs here on a worker thread — we MUST NOT do -// any of that from inside DllMain (loader lock). -static DWORD WINAPI InitThread(LPVOID param) { - HMODULE selfModule = static_cast(param); - Log::Init(selfModule); - LOG_INFO("OpenSteamTool init thread started"); - - if (!InitializeSteamComponents()) { - LOG_ERROR("InitializeSteamComponents failed"); - return 1; - } - - Config::Load(ConfigPath); - Log::InitModules(); - - // Load pattern files for steamclient64.dll and steamui.dll. - // Each call computes the SHA-256 of the DLL on disk, checks the local - // cache, and downloads from GitHub if needed. Both calls are synchronous - // but run on this worker thread, never under the loader lock. - PatternLoader::Load(ui_hModule, SteamUIPath, "steamui"); - PatternLoader::Load(client_hModule, SteamclientPath, "steamclient"); - - std::vector watchDirs = Config::luaPaths; - watchDirs.push_back(std::string(LuaDir)); - for (const auto& dir : watchDirs) - LuaConfig::ParseDirectory(dir); - - FileWatcher::Start(watchDirs); - - SteamUI::CoreHook(); - SteamClient::CoreHook(); - - // Surface any functions that FindPattern() could not locate. - PatternLoader::ReportMissingFunctions(); - - g_HooksInstalled.store(true); - LOG_INFO("OpenSteamTool init complete"); - return 0; -} - -BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved) -{ - if (dwReason == DLL_PROCESS_ATTACH) - { - DisableThreadLibraryCalls(hModule); - // Hand off all real work to a worker thread to avoid running file I/O, - // LoadLibrary, and detour transactions under the loader lock. - HANDLE h = CreateThread(nullptr, 0, InitThread, hModule, 0, nullptr); - if (h) CloseHandle(h); - } - else if (dwReason == DLL_PROCESS_DETACH) - { - FileWatcher::Stop(); - SteamUI::CoreUnhook(); - SteamClient::CoreUnhook(); - } - - return TRUE; -} +#include "dllmain.h" +#include "Hook/HookManager.h" +#include "Utils/FileWatcher.h" +#include "Utils/PatternLoader.h" +#include "Utils/DllDirectory.h" + +// prepare key runtime paths. +bool InitializeSteamComponents() +{ + if (!GetCurrentDirectoryA(MAX_PATH, SteamInstallPath)) { + return false; + } + sprintf_s(SteamclientPath, MAX_PATH, "%s\\steamclient64.dll", SteamInstallPath); + sprintf_s(SteamUIPath, MAX_PATH, "%s\\steamui.dll", SteamInstallPath); + sprintf_s(DiversionPath, MAX_PATH, "%s\\bin\\diversion.dll", SteamInstallPath); + sprintf_s(DllDir, MAX_PATH, "%s", Utils::GetDllDirectory().string().c_str()); + sprintf_s(LuaDir, MAX_PATH, "%s\\config\\lua", DllDir); + sprintf_s(ConfigPath, MAX_PATH, "%s\\opensteamtool.toml", DllDir); + + client_hModule = LoadLibraryA(SteamclientPath); + if (!client_hModule) { + LOG_ERROR("LoadLibraryA failed: {} (err={})", SteamclientPath, GetLastError()); + return false; + } + LOG_INFO("Loaded diversion.dll from {}", SteamclientPath); + + ui_hModule = GetModuleHandleA("steamui.dll"); + if(!ui_hModule) { + LOG_ERROR("GetModuleHandleA failed for steamui.dll: err={}", GetLastError()); + return false; + } + return true; +} + +// All initialisation that touches the filesystem, calls LoadLibrary, scans +// memory, or installs detours runs here on a worker thread — we MUST NOT do +// any of that from inside DllMain (loader lock). +static DWORD WINAPI InitThread(LPVOID param) { + HMODULE selfModule = static_cast(param); + Log::Init(selfModule); + LOG_INFO("OpenSteamTool init thread started"); + + if (!InitializeSteamComponents()) { + LOG_ERROR("InitializeSteamComponents failed"); + return 1; + } + + Config::Load(ConfigPath); + Log::InitModules(); + + // Load pattern files for steamclient64.dll and steamui.dll. + // Each call computes the SHA-256 of the DLL on disk, checks the local + // cache, and downloads from GitHub if needed. Both calls are synchronous + // but run on this worker thread, never under the loader lock. + PatternLoader::Load(ui_hModule, SteamUIPath, "steamui"); + PatternLoader::Load(client_hModule, SteamclientPath, "steamclient"); + + std::vector watchDirs = Config::luaPaths; + watchDirs.push_back(std::string(LuaDir)); + for (const auto& dir : watchDirs) + LuaConfig::ParseDirectory(dir); + + FileWatcher::Start(watchDirs); + + SteamUI::CoreHook(); + SteamClient::CoreHook(); + + // Surface any functions that FindPattern() could not locate. + PatternLoader::ReportMissingFunctions(); + + g_HooksInstalled.store(true); + LOG_INFO("OpenSteamTool init complete"); + return 0; +} + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved) +{ + if (dwReason == DLL_PROCESS_ATTACH) + { + DisableThreadLibraryCalls(hModule); + // Hand off all real work to a worker thread to avoid running file I/O, + // LoadLibrary, and detour transactions under the loader lock. + HANDLE h = CreateThread(nullptr, 0, InitThread, hModule, 0, nullptr); + if (h) CloseHandle(h); + } + else if (dwReason == DLL_PROCESS_DETACH) + { + FileWatcher::Stop(); + SteamUI::CoreUnhook(); + SteamClient::CoreUnhook(); + } + + return TRUE; +} diff --git a/src/dllmain.h b/src/dllmain.h index ec81dd62..528aea50 100644 --- a/src/dllmain.h +++ b/src/dllmain.h @@ -1,40 +1,41 @@ -#ifndef DLLMAIN_H -#define DLLMAIN_H - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "Steam/Types.h" -#include "Steam/Enums.h" -#include "Steam/Structs.h" -#include "Steam/Callback.h" -#include "Utils/LuaConfig.h" -#include "Utils/Log.h" -#include "Utils/Config.h" - - -inline HMODULE client_hModule = nullptr; -inline HMODULE ui_hModule = nullptr; - -inline std::atomic g_HooksInstalled{false}; -inline char SteamInstallPath[MAX_PATH] = {}; -inline char SteamclientPath[MAX_PATH] = {}; -inline char SteamUIPath[MAX_PATH] = {}; -inline char DiversionPath[MAX_PATH] = {}; -inline char LuaDir[MAX_PATH] = {}; -inline char ConfigPath[MAX_PATH] = {}; - -// The fake AppId used by -onlinefix (SpaceWar). -constexpr AppId_t kOnlineFixAppId = 480; - -#endif // DLLMAIN_H +#ifndef DLLMAIN_H +#define DLLMAIN_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Steam/Types.h" +#include "Steam/Enums.h" +#include "Steam/Structs.h" +#include "Steam/Callback.h" +#include "Utils/LuaConfig.h" +#include "Utils/Log.h" +#include "Utils/Config.h" + + +inline HMODULE client_hModule = nullptr; +inline HMODULE ui_hModule = nullptr; + +inline std::atomic g_HooksInstalled{false}; +inline char SteamInstallPath[MAX_PATH] = {}; +inline char SteamclientPath[MAX_PATH] = {}; +inline char SteamUIPath[MAX_PATH] = {}; +inline char DiversionPath[MAX_PATH] = {}; +inline char LuaDir[MAX_PATH] = {}; +inline char ConfigPath[MAX_PATH] = {}; +inline char DllDir[MAX_PATH] = {}; + +// The fake AppId used by -onlinefix (SpaceWar). +constexpr AppId_t kOnlineFixAppId = 480; + +#endif // DLLMAIN_H From 878fb753a2c527591d3920bcfad9dd10fd71a494 Mon Sep 17 00:00:00 2001 From: Tesla697 <96721065+Tesla697@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:55:21 +0530 Subject: [PATCH 07/30] ProtectionScan: detect protected entry blob via section flags + entropy Add a third detection method, ProtectedBlobSection, reached when the two existing methods (OEP DODENUVO pattern; legacy section + DENUVO string) both miss. A runtime-decrypting protector must carry a large code section that is simultaneously writable and executable (it decrypts itself in place) and encrypted at rest, so scan every section for one with IMAGE_SCN_MEM_EXECUTE | IMAGE_SCN_MEM_WRITE, rawSize >= 4 MiB, and Shannon entropy >= 7.0. This catches current Denuvo builds that ship no OEP pattern and no DENUVO string, which otherwise leave ProtectionScan empty and break Denuvo auth with 88500012. Measured on Sonic Forces (appid 637100): .arch is RWX, 103.9 MiB, entropy 7.247, while the OEP section is a clean read-only stub. Clean binaries have no W+X section, so false positives are near zero. Expose IMAGE_SECTION_HEADER::Characteristics on PE::Section (with IsExecutable/IsWritable helpers) to support the flag check. --- src/OSTPlatform/Windows/PE.cpp | 1 + src/OSTPlatform/include/PE.h | 6 ++ .../Features/DenuvoAuth/ProtectionScan.cpp | 79 ++++++++++++++++++- src/Pipe/Features/DenuvoAuth/ProtectionScan.h | 1 + 4 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/OSTPlatform/Windows/PE.cpp b/src/OSTPlatform/Windows/PE.cpp index 68166b28..165f3cb0 100644 --- a/src/OSTPlatform/Windows/PE.cpp +++ b/src/OSTPlatform/Windows/PE.cpp @@ -242,6 +242,7 @@ Image::Image(const std::filesystem::path& path) : path_(path) { section->Misc.VirtualSize, section->PointerToRawData, section->SizeOfRawData, + section->Characteristics, }); } diff --git a/src/OSTPlatform/include/PE.h b/src/OSTPlatform/include/PE.h index a7d50559..7405dfbd 100644 --- a/src/OSTPlatform/include/PE.h +++ b/src/OSTPlatform/include/PE.h @@ -43,8 +43,14 @@ struct Section { uint32_t virtualSize = 0; uint32_t rawOffset = 0; uint32_t rawSize = 0; + uint32_t characteristics = 0; // IMAGE_SECTION_HEADER::Characteristics bool ContainsRva(uint32_t rva) const; + // IMAGE_SCN_MEM_EXECUTE / IMAGE_SCN_MEM_WRITE — a section that is both is a + // W^X violation (self-modifying code), the hallmark of a runtime-decrypting + // protector. + bool IsExecutable() const { return (characteristics & 0x20000000u) != 0; } + bool IsWritable() const { return (characteristics & 0x80000000u) != 0; } }; struct Export { diff --git a/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp b/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp index bb46a6e5..5a037682 100644 --- a/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp +++ b/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -65,6 +66,35 @@ namespace { constexpr size_t kLegacyScanChunkBytes = 8ull * 1024ull * 1024ull; constexpr size_t kOepScanChunkBytes = 8ull * 1024ull * 1024ull; + // Structural fallback for Denuvo builds that ship NO OEP pattern and NO + // "DENUVO" string (both checks above return nothing). A runtime-decrypting + // protector must still carry a large code section that is simultaneously + // writable AND executable (it decrypts itself in place) and encrypted at + // rest (high entropy). That triad is version-independent and effectively + // absent from legitimately compiled binaries (which ship read-only code). + // Measured on Sonic Forces (637100): .arch is RWX, 103.9 MB, entropy 7.247, + // while its OEP section is a clean read-only stub — so this is the only + // method that fires on it. Clean binaries (steam/notepad/explorer) have no + // W+X section at all. + constexpr uint32 kProtectorBlobMinBytes = 4u * 1024u * 1024u; // skip small legit RWX thunks + constexpr double kProtectorBlobMinEntropy = 7.0; // encrypted/packed bits/byte + constexpr size_t kProtectorBlobEntropySampleBytes = 8ull * 1024ull * 1024ull; // cap per-section read + + double SectionEntropy(std::span bytes) { + if (bytes.empty()) return 0.0; + std::array counts{}; + for (uint8_t value : bytes) ++counts[value]; + const double inv = 1.0 / static_cast(bytes.size()); + double entropy = 0.0; + for (uint64 count : counts) { + if (count) { + const double p = static_cast(count) * inv; + entropy -= p * std::log2(p); + } + } + return entropy; // 0.0 .. 8.0 bits/byte + } + double BytesToMiB(uint64 bytes) { return static_cast(bytes) / (1024.0 * 1024.0); } @@ -227,6 +257,43 @@ namespace { return match; } + std::optional TryProtectedBlobSection( + const ModuleCandidate& module, + const OSTPlatform::PE::Image& image) { + for (const auto& section : image.Sections()) { + // The durable signal: a section that is BOTH writable and + // executable. This header flag is identical on disk and in the + // mapped image and is present before the protector decrypts. + if (!(section.IsExecutable() && section.IsWritable())) continue; + if (section.rawSize < kProtectorBlobMinBytes) continue; + + const size_t sampleSize = + (std::min)(static_cast(section.rawSize), kProtectorBlobEntropySampleBytes); + const OSTPlatform::PE::ByteBuffer sample = image.ReadRawBytes(section.rawOffset, sampleSize); + if (sample.empty()) continue; + + const double entropy = SectionEntropy(sample); + if (entropy < kProtectorBlobMinEntropy) { + LOG_PIPE_DEBUG("DenuvoAuth: RWX section below entropy floor path={} section={} raw_size={} ({:.2f} MB) entropy={:.3f}", + module.path, section.name, section.rawSize, + BytesToMiB(static_cast(section.rawSize)), entropy); + continue; + } + + DetectionMatch match{}; + match.method = DetectionMethod::ProtectedBlobSection; + match.sectionName = section.name; + match.entryPointRva = image.EntryPointRva(); + match.matchRawOffset = section.rawOffset; + match.matchRva = section.virtualAddress; + LOG_PIPE_INFO("DenuvoAuth: protector blob section path={} section={} raw_size={} ({:.2f} MB) entropy={:.3f} flags=RWX", + module.path, section.name, section.rawSize, + BytesToMiB(static_cast(section.rawSize)), entropy); + return match; + } + return std::nullopt; + } + std::optional DetectModule( const ModuleCandidate& module, const OSTPlatform::PE::Image& image) { @@ -236,7 +303,16 @@ namespace { } if (const auto* legacySection = FindLegacyDenuvoSection(image)) { - return TryLegacySectionString(module, image, *legacySection); + if (auto match = TryLegacySectionString(module, image, *legacySection)) { + return match; + } + } + + // Structural fallback: catches Denuvo builds that carry the legacy + // sections (or not) but ship no OEP pattern and no DENUVO string, so the + // two checks above come up empty (e.g. Sonic Forces 637100). + if (auto match = TryProtectedBlobSection(module, image)) { + return match; } return std::nullopt; } @@ -353,6 +429,7 @@ const char* ToString(DetectionMethod method) { case DetectionMethod::None: return "None"; case DetectionMethod::LegacySectionString: return "LegacySectionString"; case DetectionMethod::OepPattern: return "OepPattern"; + case DetectionMethod::ProtectedBlobSection: return "ProtectedBlobSection"; } return "Unknown"; } diff --git a/src/Pipe/Features/DenuvoAuth/ProtectionScan.h b/src/Pipe/Features/DenuvoAuth/ProtectionScan.h index 4d951607..412a7940 100644 --- a/src/Pipe/Features/DenuvoAuth/ProtectionScan.h +++ b/src/Pipe/Features/DenuvoAuth/ProtectionScan.h @@ -15,6 +15,7 @@ namespace PipeManager::DenuvoAuth { None, LegacySectionString, OepPattern, + ProtectedBlobSection, }; const char* ToString(DetectionMethod method); From 080d51b0fa5e491bcd4f70d3ec8a513d1a853eab Mon Sep 17 00:00:00 2001 From: Ran-Mewo <43445785+Ran-Mewo@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:52:51 +1000 Subject: [PATCH 08/30] Improve Injection API --- README.md | 555 +++++++++++----------- opensteamtool.example.toml | 14 +- src/Hook/Hooks_Misc.cpp | 2 +- src/OSTPlatform/Windows/NtAbi.h | 25 +- src/OSTPlatform/Windows/Process.cpp | 67 +++ src/OSTPlatform/include/Process.h | 1 + src/Pipe/Features/Injection/Injection.cpp | 101 ++-- src/Utils/Config/Config.cpp | 49 +- src/Utils/Config/Config.h | 44 +- src/Utils/Config/LuaFileWatcher.h | 2 +- src/Utils/Logging/Log.h | 120 ++--- src/Utils/Logging/LogModules.def | 1 + src/dllmain.h | 84 ++-- 13 files changed, 591 insertions(+), 474 deletions(-) diff --git a/README.md b/README.md index d62b9fa6..5aa71384 100644 --- a/README.md +++ b/README.md @@ -1,270 +1,285 @@ -
- OpenSteamTool logo - -

OpenSteamTool

- -

- Open-Source Steam Unlock Tool -

- -

- C++ 20+ - CMake 3.20+ - Windows only - - Ask DeepWiki - -

- -

- - United States flag - English - -  |  - - Spain flag - Español - -  |  - - China flag - 中文 - -

-
- -## Feature - -### Core Unlocks -- Unlock an unlimited number of unowned games. -- Unlock all DLCs for unowned games. -- Support auto load depot decryption keys from Lua config. -- Support auto manifest download via `opensteamtool` / `steamrun` / `wudrm` upstream APIs (default is `opensteamtool`), or a custom Lua endpoint (see [Manifest via Lua](#manifest-via-lua)). -- Support downloading protected games or DLCs that require an access token. -- Support binding manifest to prevent specific games from being updated. - -### Hot Reload -- Adding, modifying, deleting, or overwriting `.lua` files in any watched directory automatically triggers a reload. No restart, no offline/online toggle needed. - -### Injection -- Add optional game-process library injection through `[inject]` in `opensteamtool.toml`. -- Configure `enabled`, `library_x64`, and `library_x86`; the injected library must match the target process architecture.`library_x64` and `library_x86` may be absolute paths, or relative paths resolved from the Steam root directory. - -### Family Sharing and Remote Play -- Bypass Steam Family Sharing restrictions for games that have been added to the library with `addappid` in Lua. All accounts in the Steam Family that participate in sharing must use OpenSteamTool for this to work. - -### Compatible with games protected by Denuvo and SteamStub -- SteamStub-only games do not require configuring `AppTicket`. OpenSteamTool can reuse Steam's local ConfigStore ticket and forge the requested AppId through a SteamDRMP off-by-four ticket parsing vulnerability, without injecting into the game process. -- Denuvo-protected games still require explicit ticket data. OpenSteamTool stores `AppTicket` and `ETicket` through the platform credential store. -- Use `setAppTicket(appid, "hex")` and `setETicket(appid, "hex")` in Lua config to write these values to the platform credential store automatically. -- Denuvo verification has a 30-minute validity window. After this window expires, authorization may fail with Denuvo error code `88500005`; refresh the ticket data before retrying. -- AppTicket priority: explicit tickets have the highest priority, including tickets configured by `setAppTicket` and existing cached `AppTicket` credential values. If no explicit AppTicket is available, OpenSteamTool falls back to the forged local ConfigStore ticket path. -- SteamID priority: read cached `SteamID` first; if missing, parse from explicit `AppTicket`. On Windows, the credential store backend currently uses `HKCU\Software\Valve\Steam\Apps\`. The Linux backend is not implemented yet. - -#### Extracting tickets with `extract_tickets` - -The `extract_tickets` tool dumps the `AppTicket` and `ETicket` hex strings you need for `setAppTicket` / `setETicket`. Run it on a machine where Steam is running and logged into an account that **owns** the target game. - -1. Build the tools (see [Build](#build)); the binary lands in `build/tools/Release/extract_tickets.exe`. -2. Run it with the target AppId (or run it with no argument and type the AppId when prompted): - ```powershell - extract_tickets.exe 1361510 - ``` -3. It reads the Steam install path from the registry, loads `steamclient64.dll`, and writes everything into an `/` folder next to the executable: - - `appticket.bin` — raw app ownership ticket (binary) - - `eticket.bin` — raw encrypted app ticket (binary) - - `tickets.txt` — plain-text summary with the hex strings: - ``` - appid:1361510 - appticket(184 bytes):14000000... - eticket(143 bytes):... - ``` - A ticket that could not be obtained is reported as `appticket:null` / `eticket:null`. -4. Paste the hex strings from `tickets.txt` into your Lua config: - ```lua - setAppTicket(1361510, "14000000...") - setETicket(1361510, "...") - ``` - -> **Note:** Tickets are only valid when extracted from an account that **genuinely owns** the game. - -### Stats and Achievements -- Enable stats and achievements for unowned games. -- Uses `setStat(appid, "steamid")` to configure which SteamID's achievement data to pull. -- If no `setStat` is configured for an app, OpenSteamTool queries `https://stats.opensteamtool.com/{appid}` when `[stats] enable_api = true` (default). -- Priority: `setStat` > stats API when enabled and valid > hardcoded preset SteamID `76561198028121353`. - -### Online Fix -- Add `-onlinefix` to the Steam launch parameters to enable 480-based online play in games that use lobby matchmaking. The current limitation is that only one such game can run at a time.To revert, simply remove -onlinefix from the launch parameters — online play returns to normal on the next launch. - -## Future -- Steam Cloud synchronization support.(This is a huge project) - -## Usage -1. Run `build.bat` from the project root to build the project. -2. Copy generated `dwmapi.dll`, `xinput1_4.dll` and `OpenSteamTool.dll` to the Steam root directory. -3. Create Lua directory (for example `C:\steam\config\lua`) and place Lua scripts there. The DLL will automatically load and execute them. -4. Lua example: -```lua -addappid(1361510) -- unlock game with appid 1361510 - -addappid(1361511, 0,"5954562e7f5260400040a818bc29b60b335bb690066ff767e20d145a3b6b4af0") -- unlock game with appid 1361511 depotKey is "5954562e7f5260400040a818bc29b60b335bb690066ff767e20d145a3b6b4af0" - -addtoken(1361510,"2764735786934684318") -- add access token ("2764735786934684318") for game with appid 1361510 --- No Longer Supported: ---pinApp(1361510) -- pin game with appid 1361510 to prevent it from being updated - -setManifestid(1361511,"5656605350306673283") -- pin depotid:1361511 manifest_gid:5656605350306673283, size defaults to 0 -setManifestid(1361511,"5656605350306673283", 12345678) -- same but with explicit size - -setAppTicket(1361510,"0100000000000000...") -- write AppTicket to the credential store; on Windows: HKCU\Software\Valve\Steam\Apps\1361510\AppTicket - -setETicket(1361510,"0100000000000000...") -- write ETicket to the credential store; on Windows: HKCU\Software\Valve\Steam\Apps\1361510\ETicket - -setStat(1361510, "76561197960287930") -- use the specified SteamID's achievement data for appid 1361510 --- If not configured, the stats API is used when enabled; otherwise default SteamID 76561198028121353 is used. -``` - -All function names are **case-insensitive**. `setAppTicket`, `setappticket`, `SetAppticket`, `SETAPPTICKET` etc. are all equivalent. The same applies to every registered function (`addAppId`, `AddToken`, `SETManifestid`, etc.). - -### Configuration (optional) - -Rename `opensteamtool.example.toml` to `opensteamtool.toml` and place it in the Steam root directory (next to `steam.exe`). -If no config file is found, built-in defaults are used — no auto-creation. -The file is watched while Steam is running; valid changes are hot-reloaded without restarting Steam. - -```toml -[log] -# Debug build only. Level: trace, debug, info, warn, error -level = "info" - -[manifest] -# Upstream API for depot manifest request codes. Options: "opensteamtool", "steamrun", "wudrm" -url = "opensteamtool" - -# HTTP timeouts for manifest requests (milliseconds) -timeout_resolve_ms = 5000 -timeout_connect_ms = 5000 -timeout_send_ms = 10000 -timeout_recv_ms = 10000 - -[stats] -# Query https://stats.opensteamtool.com/{appid} when no Lua setStat override exists. -# Priority: setStat > stats API > hardcoded preset SteamID. -enable_api = true - -# Additional Lua config directories (optional). -# Files are loaded after the default /config/lua folder. -# The default folder is always loaded last so user files take priority. -[lua] -paths = [] - -[inject] -# Optional library injection into game processes. -# The injected library must match the target process architecture. -enabled = false -# library_x64 = "OpenSteamTool.GameHook.x64.dll" -# library_x86 = "OpenSteamTool.GameHook.x86.dll" - -# Optional metadata mirror. See "Steam version compatibility" below. -[remote] -# url_template = "https://your.server/{channel}/{component}/{sha256}.toml" -``` - -### Manifest via Lua - -Two manifest code functions are supported: - -#### `fetch_manifest_code(gid)` - -Basic function that receives only the manifest GID. - -#### `fetch_manifest_code_ex(app_id, depot_id, gid)` *(recommended)* - -Extended function that receives `app_id`, `depot_id`, and `gid`. Allows constructing API endpoints that require app identification. - -The C++ runtime provides two Lua helpers: - -| Function | Signature | Returns | -|----------|-----------|---------| -| `http_get` | `http_get(url [, headers])` | `body, status_code` | -| `http_post` | `http_post(url, body [, headers])` | `body, status_code` | - -`headers` is an optional table: `{["Key"]="Value", ...}`. - -### Steam version compatibility - -OpenSteamTool no longer ships byte-pattern signatures inside the DLL. Instead, on each launch it computes the SHA-256 of `steamclient64.dll` and `steamui.dll` on disk and looks up a matching pattern file from the upstream tracker at [`OpenSteam001/steam-monitor`](https://github.com/OpenSteam001/steam-monitor) (`pattern` branch). - -Lookup order (every launch): - -1. **GitHub raw** — `https://raw.githubusercontent.com/OpenSteam001/steam-monitor/pattern/...`. Canonical source. -2. **jsDelivr CDN** — automatic fallback if GitHub raw is unreachable (connection refused / timeout / 5xx). No configuration required. Useful in regions where `raw.githubusercontent.com` is blocked but jsDelivr is reachable (e.g. mainland China). -3. **Local cache** — `\opensteamtool\pattern\\.toml`. Used **only** when remote is unreachable. The cache is overwritten after every successful remote fetch. - -Remote is consulted on every launch so users automatically pick up upstream re-publications (e.g. the bot adding a new signature, or fixing an existing one) without having to clear any cache. - -If a step returns **HTTP 404** the mirror loop stops immediately — all mirrors serve the same content, so a 404 means the upstream bot has not yet published a TOML for this Steam build. The code then falls back to the local cache if one exists; otherwise a one-shot popup appears with the unmatched DLL name, its SHA-256, the expected cache path, and the upstream URL. Only the hooks tied to that DLL are disabled — the rest of OpenSteamTool keeps working. - -You can also drop a pattern TOML into the cache directory manually if you know the layout for a given build; the file name must be `.toml`. The cache fallback will pick it up the next time remote is unreachable. - -> A short outbound HTTPS request is performed at every launch (one per DLL: `steamclient64.dll`, `steamui.dll`). The downloaded bodies are tiny (~10 KB each) and the work runs on a worker thread, so it never blocks Steam's loader. - -#### Using a different mirror - -For most users, the built-in **GitHub -> jsDelivr** fallback is enough. To use a private mirror or intranet server, configure a full URL template. A custom mirror replaces the built-in remote sources; local cache fallback remains available. - -The template must include `{channel}`, `{component}`, and `{sha256}`. Channels currently used are `pattern` and `ipc`. - -```toml -[remote] -url_template = "https://your.server/{channel}/{component}/{sha256}.toml" -# url_template = "https://fast.jsdelivr.net/gh/OpenSteam001/steam-monitor@{channel}/{component}/{sha256}.toml" -``` - -### Debug logging - -Debug builds write per-module log files under `/opensteamtool/`: - -| File | Source | Content | -|------|--------|---------| -| `main.log` | General | Init, config loading, Lua parsing, utilities | -| `ipc.log` | `LOG_IPC_*` | IPC commands, InterfaceCall dispatch, spoofing | -| `netpacket.log` | `LOG_NETPACKET_*` | Network packet send/recv, eMsg dispatch | -| `manifest.log` | `LOG_MANIFEST_*` | Manifest download, `fetch_manifest_code`, manifest binding | -| `decryptionkey.log` | `LOG_DECRYPTIONKEY_*` | Depot decryption key injection | -| `keyvalue.log` | `LOG_KEYVALUE_*` | KeyValues patching (manifest binding) | -| `misc.log` | `LOG_MISC_*` | Engine pointer capture, AppId hints | -| `achievement.log` | `LOG_ACHIEVEMENT_*` | UserStats requests/responses, steamid spoofing | -| `pics.log` | `LOG_PICS_*` | PICS access token injection | -| `package.log` | `LOG_PACKAGE_*` | Package injection, FileWatcher events | -| `onlinefix.log` | `LOG_ONLINEFIX_*` | Online fix (480 AppId spoofing) | -| `richpresence.log` | `LOG_RICHPRESENCE_*` | Rich Presence packet construction and injection | -| `steamui.log` | `LOG_STEAMUI_*` | SteamUI hook diagnostics | -| `pipe.log` | `LOG_PIPE_*` | Pipe handshakes, process inspection, Denuvo authorization, library injection | -| `platform.log` | `LOG_PLATFORM_*` | Platform helper diagnostics, including remote-process operations | - -The log level is controlled by `[log] level` in `opensteamtool.toml`. - -## Build - -### Requirements -- Windows 10/11 -- CMake 3.20+ -- Visual Studio 2022 with MSVC (x64 toolchain) - -### Runtime requirements -- Outbound HTTPS access to `raw.githubusercontent.com` on first launch after a Steam update (see [Steam version compatibility](#steam-version-compatibility)). Cached afterwards. - -### Quick build -```powershell -build.bat -``` - -### Output -- Debug: `build/Debug/OpenSteamTool.dll`, `build/Debug/dwmapi.dll`, `build/Debug/xinput1_4.dll` -- Release: `build/Release/OpenSteamTool.dll`, `build/Release/dwmapi.dll`, `build/Release/xinput1_4.dll` - -## Disclaimer -This project is provided for research and educational purposes only. You are responsible for complying with local laws, platform terms of service, and software licenses. +
+ OpenSteamTool logo + +

OpenSteamTool

+ +

+ Open-Source Steam Unlock Tool +

+ +

+ C++ 20+ + CMake 3.20+ + Windows only + + Ask DeepWiki + +

+ +

+ + United States flag + English + +  |  + + Spain flag + Español + +  |  + + China flag + 中文 + +

+
+ +## Feature + +### Core Unlocks +- Unlock an unlimited number of unowned games. +- Unlock all DLCs for unowned games. +- Support auto load depot decryption keys from Lua config. +- Support auto manifest download via `opensteamtool` / `steamrun` / `wudrm` upstream APIs (default is `opensteamtool`), or a custom Lua endpoint (see [Manifest via Lua](#manifest-via-lua)). +- Support downloading protected games or DLCs that require an access token. +- Support binding manifest to prevent specific games from being updated. + +### Hot Reload +- Adding, modifying, deleting, or overwriting `.lua` files in any watched directory automatically triggers a reload. No restart, no offline/online toggle needed. + +### Injection +- Load third-party DLLs into game processes through `[[inject]]` in `opensteamtool.toml`. See [Third-party DLL injection](#third-party-dll-injection). + +### Family Sharing and Remote Play +- Bypass Steam Family Sharing restrictions for games that have been added to the library with `addappid` in Lua. All accounts in the Steam Family that participate in sharing must use OpenSteamTool for this to work. + +### Compatible with games protected by Denuvo and SteamStub +- SteamStub-only games do not require configuring `AppTicket`. OpenSteamTool can reuse Steam's local ConfigStore ticket and forge the requested AppId through a SteamDRMP off-by-four ticket parsing vulnerability, without injecting into the game process. +- Denuvo-protected games still require explicit ticket data. OpenSteamTool stores `AppTicket` and `ETicket` through the platform credential store. +- Use `setAppTicket(appid, "hex")` and `setETicket(appid, "hex")` in Lua config to write these values to the platform credential store automatically. +- Denuvo verification has a 30-minute validity window. After this window expires, authorization may fail with Denuvo error code `88500005`; refresh the ticket data before retrying. +- AppTicket priority: explicit tickets have the highest priority, including tickets configured by `setAppTicket` and existing cached `AppTicket` credential values. If no explicit AppTicket is available, OpenSteamTool falls back to the forged local ConfigStore ticket path. +- SteamID priority: read cached `SteamID` first; if missing, parse from explicit `AppTicket`. On Windows, the credential store backend currently uses `HKCU\Software\Valve\Steam\Apps\`. The Linux backend is not implemented yet. + +#### Extracting tickets with `extract_tickets` + +The `extract_tickets` tool dumps the `AppTicket` and `ETicket` hex strings you need for `setAppTicket` / `setETicket`. Run it on a machine where Steam is running and logged into an account that **owns** the target game. + +1. Build the tools (see [Build](#build)); the binary lands in `build/tools/Release/extract_tickets.exe`. +2. Run it with the target AppId (or run it with no argument and type the AppId when prompted): + ```powershell + extract_tickets.exe 1361510 + ``` +3. It reads the Steam install path from the registry, loads `steamclient64.dll`, and writes everything into an `/` folder next to the executable: + - `appticket.bin` — raw app ownership ticket (binary) + - `eticket.bin` — raw encrypted app ticket (binary) + - `tickets.txt` — plain-text summary with the hex strings: + ``` + appid:1361510 + appticket(184 bytes):14000000... + eticket(143 bytes):... + ``` + A ticket that could not be obtained is reported as `appticket:null` / `eticket:null`. +4. Paste the hex strings from `tickets.txt` into your Lua config: + ```lua + setAppTicket(1361510, "14000000...") + setETicket(1361510, "...") + ``` + +> **Note:** Tickets are only valid when extracted from an account that **genuinely owns** the game. + +### Stats and Achievements +- Enable stats and achievements for unowned games. +- Uses `setStat(appid, "steamid")` to configure which SteamID's achievement data to pull. +- If no `setStat` is configured for an app, OpenSteamTool queries `https://stats.opensteamtool.com/{appid}` when `[stats] enable_api = true` (default). +- Priority: `setStat` > stats API when enabled and valid > hardcoded preset SteamID `76561198028121353`. + +### Online Fix +- Add `-onlinefix` to the Steam launch parameters to enable 480-based online play in games that use lobby matchmaking. The current limitation is that only one such game can run at a time.To revert, simply remove -onlinefix from the launch parameters — online play returns to normal on the next launch. + +## Future +- Steam Cloud synchronization support.(This is a huge project) + +## Usage +1. Run `build.bat` from the project root to build the project. +2. Copy generated `dwmapi.dll`, `xinput1_4.dll` and `OpenSteamTool.dll` to the Steam root directory. +3. Create Lua directory (for example `C:\steam\config\lua`) and place Lua scripts there. The DLL will automatically load and execute them. +4. Lua example: +```lua +addappid(1361510) -- unlock game with appid 1361510 + +addappid(1361511, 0,"5954562e7f5260400040a818bc29b60b335bb690066ff767e20d145a3b6b4af0") -- unlock game with appid 1361511 depotKey is "5954562e7f5260400040a818bc29b60b335bb690066ff767e20d145a3b6b4af0" + +addtoken(1361510,"2764735786934684318") -- add access token ("2764735786934684318") for game with appid 1361510 +-- No Longer Supported: +--pinApp(1361510) -- pin game with appid 1361510 to prevent it from being updated + +setManifestid(1361511,"5656605350306673283") -- pin depotid:1361511 manifest_gid:5656605350306673283, size defaults to 0 +setManifestid(1361511,"5656605350306673283", 12345678) -- same but with explicit size + +setAppTicket(1361510,"0100000000000000...") -- write AppTicket to the credential store; on Windows: HKCU\Software\Valve\Steam\Apps\1361510\AppTicket + +setETicket(1361510,"0100000000000000...") -- write ETicket to the credential store; on Windows: HKCU\Software\Valve\Steam\Apps\1361510\ETicket + +setStat(1361510, "76561197960287930") -- use the specified SteamID's achievement data for appid 1361510 +-- If not configured, the stats API is used when enabled; otherwise default SteamID 76561198028121353 is used. +``` + +All function names are **case-insensitive**. `setAppTicket`, `setappticket`, `SetAppticket`, `SETAPPTICKET` etc. are all equivalent. The same applies to every registered function (`addAppId`, `AddToken`, `SETManifestid`, etc.). + +### Configuration (optional) + +Rename `opensteamtool.example.toml` to `opensteamtool.toml` and place it in the Steam root directory (next to `steam.exe`). +If no config file is found, built-in defaults are used — no auto-creation. +The file is watched while Steam is running; valid changes are hot-reloaded without restarting Steam. + +```toml +[log] +# Debug build only. Level: trace, debug, info, warn, error +level = "info" + +[manifest] +# Upstream API for depot manifest request codes. Options: "opensteamtool", "steamrun", "wudrm" +url = "opensteamtool" + +# HTTP timeouts for manifest requests (milliseconds) +timeout_resolve_ms = 5000 +timeout_connect_ms = 5000 +timeout_send_ms = 10000 +timeout_recv_ms = 10000 + +[stats] +# Query https://stats.opensteamtool.com/{appid} when no Lua setStat override exists. +# Priority: setStat > stats API > hardcoded preset SteamID. +enable_api = true + +# Additional Lua config directories (optional). +# Files are loaded after the default /config/lua folder. +# The default folder is always loaded last so user files take priority. +[lua] +paths = [] + +[inject] +# Optional DLL injection into game processes. See "Third-party DLL injection" below. +# [[inject]] +# path = "{your_dll}.dll" + +# Optional metadata mirror. See "Steam version compatibility" below. +[remote] +# url_template = "https://your.server/{channel}/{component}/{sha256}.toml" +``` + +### Third-party DLL injection + +OpenSteamTool can load third-party DLLs into game processes. Each `[[inject]]` entry is injected when every condition it sets matches the launch; matching entries are injected in listed order. + +| Key | Explanation | +|-----|---------| +| `path` | DLL to load. A bare file name resolves next to `steam.exe`; an absolute path is used as-is. Missing files are skipped. | +| `when_cmdline` | Substring that must appear in the launch command line. Omit to match any. | +| `when_appids` | AppIds to restrict to. Omit/leave empty to match any. | +| `all_games` | `false` (default) injects only into games added by the manifest; `true` injects into every game you launch. | + +```toml +[[inject]] +path = "OnlineFix.dll" +when_cmdline = "-onlinefix" +``` + +### Manifest via Lua + +Two manifest code functions are supported: + +#### `fetch_manifest_code(gid)` + +Basic function that receives only the manifest GID. + +#### `fetch_manifest_code_ex(app_id, depot_id, gid)` *(recommended)* + +Extended function that receives `app_id`, `depot_id`, and `gid`. Allows constructing API endpoints that require app identification. + +The C++ runtime provides two Lua helpers: + +| Function | Signature | Returns | +|----------|-----------|---------| +| `http_get` | `http_get(url [, headers])` | `body, status_code` | +| `http_post` | `http_post(url, body [, headers])` | `body, status_code` | + +`headers` is an optional table: `{["Key"]="Value", ...}`. + +### Steam version compatibility + +OpenSteamTool no longer ships byte-pattern signatures inside the DLL. Instead, on each launch it computes the SHA-256 of `steamclient64.dll` and `steamui.dll` on disk and looks up a matching pattern file from the upstream tracker at [`OpenSteam001/steam-monitor`](https://github.com/OpenSteam001/steam-monitor) (`pattern` branch). + +Lookup order (every launch): + +1. **GitHub raw** — `https://raw.githubusercontent.com/OpenSteam001/steam-monitor/pattern/...`. Canonical source. +2. **jsDelivr CDN** — automatic fallback if GitHub raw is unreachable (connection refused / timeout / 5xx). No configuration required. Useful in regions where `raw.githubusercontent.com` is blocked but jsDelivr is reachable (e.g. mainland China). +3. **Local cache** — `\opensteamtool\pattern\\.toml`. Used **only** when remote is unreachable. The cache is overwritten after every successful remote fetch. + +Remote is consulted on every launch so users automatically pick up upstream re-publications (e.g. the bot adding a new signature, or fixing an existing one) without having to clear any cache. + +If a step returns **HTTP 404** the mirror loop stops immediately — all mirrors serve the same content, so a 404 means the upstream bot has not yet published a TOML for this Steam build. The code then falls back to the local cache if one exists; otherwise a one-shot popup appears with the unmatched DLL name, its SHA-256, the expected cache path, and the upstream URL. Only the hooks tied to that DLL are disabled — the rest of OpenSteamTool keeps working. + +You can also drop a pattern TOML into the cache directory manually if you know the layout for a given build; the file name must be `.toml`. The cache fallback will pick it up the next time remote is unreachable. + +> A short outbound HTTPS request is performed at every launch (one per DLL: `steamclient64.dll`, `steamui.dll`). The downloaded bodies are tiny (~10 KB each) and the work runs on a worker thread, so it never blocks Steam's loader. + +#### Using a different mirror + +For most users, the built-in **GitHub -> jsDelivr** fallback is enough. To use a private mirror or intranet server, configure a full URL template. A custom mirror replaces the built-in remote sources; local cache fallback remains available. + +The template must include `{channel}`, `{component}`, and `{sha256}`. Channels currently used are `pattern` and `ipc`. + +```toml +[remote] +url_template = "https://your.server/{channel}/{component}/{sha256}.toml" +# url_template = "https://fast.jsdelivr.net/gh/OpenSteam001/steam-monitor@{channel}/{component}/{sha256}.toml" +``` + +### Debug logging + +Debug builds write per-module log files under `/opensteamtool/`: + +| File | Source | Content | +|------|--------|---------| +| `main.log` | General | Init, config loading, Lua parsing, utilities | +| `ipc.log` | `LOG_IPC_*` | IPC commands, InterfaceCall dispatch, spoofing | +| `netpacket.log` | `LOG_NETPACKET_*` | Network packet send/recv, eMsg dispatch | +| `manifest.log` | `LOG_MANIFEST_*` | Manifest download, `fetch_manifest_code`, manifest binding | +| `decryptionkey.log` | `LOG_DECRYPTIONKEY_*` | Depot decryption key injection | +| `keyvalue.log` | `LOG_KEYVALUE_*` | KeyValues patching (manifest binding) | +| `misc.log` | `LOG_MISC_*` | Engine pointer capture, AppId hints | +| `achievement.log` | `LOG_ACHIEVEMENT_*` | UserStats requests/responses, steamid spoofing | +| `pics.log` | `LOG_PICS_*` | PICS access token injection | +| `package.log` | `LOG_PACKAGE_*` | Package injection, FileWatcher events | +| `onlinefix.log` | `LOG_ONLINEFIX_*` | Online fix (480 AppId spoofing) | +| `richpresence.log` | `LOG_RICHPRESENCE_*` | Rich Presence packet construction and injection | +| `steamui.log` | `LOG_STEAMUI_*` | SteamUI hook diagnostics | +| `inject.log` | `LOG_INJECT_*` | Third-party DLL injection (`[[inject]]`) matching and results | +| `pipe.log` | `LOG_PIPE_*` | Pipe handshakes, process inspection, Denuvo authorization, library injection | +| `platform.log` | `LOG_PLATFORM_*` | Platform helper diagnostics, including remote-process operations | + +The log level is controlled by `[log] level` in `opensteamtool.toml`. + +## Build + +### Requirements +- Windows 10/11 +- CMake 3.20+ +- Visual Studio 2022 with MSVC (x64 toolchain) + +### Runtime requirements +- Outbound HTTPS access to `raw.githubusercontent.com` on first launch after a Steam update (see [Steam version compatibility](#steam-version-compatibility)). Cached afterwards. + +### Quick build +```powershell +build.bat +``` + +### Output +- Debug: `build/Debug/OpenSteamTool.dll`, `build/Debug/dwmapi.dll`, `build/Debug/xinput1_4.dll` +- Release: `build/Release/OpenSteamTool.dll`, `build/Release/dwmapi.dll`, `build/Release/xinput1_4.dll` + +## Disclaimer +This project is provided for research and educational purposes only. You are responsible for complying with local laws, platform terms of service, and software licenses. diff --git a/opensteamtool.example.toml b/opensteamtool.example.toml index 762c3dcd..55ce53ba 100644 --- a/opensteamtool.example.toml +++ b/opensteamtool.example.toml @@ -71,12 +71,14 @@ enable_api = true [lua] # paths = [] -[inject] -# Optional library injection into game processes. -# The injected library must match the target process architecture. -enabled = false -# library_x64 = "OpenSteamTool.GameHook.x64.dll" -# library_x86 = "OpenSteamTool.GameHook.x86.dll" +# Optional library injection into game processes. Each [[inject]] entry is loaded +# when every condition it sets matches the launch. +# Example: +# [[inject]] +# path = "OpenSteamToolHook.dll" # bare name resolves next to steam.exe; absolute path used as-is +# when_cmdline = "-my_special_hook" # optional: require this substring in the launch command (default: any) +# when_appids = [1361510] # optional: restrict to these appids (default: any) +# all_games = false # optional: true injects into every game, false only into Lua-added games (default: false) [remote] # Optional metadata mirror. Leave unset to use GitHub with jsDelivr fallback. diff --git a/src/Hook/Hooks_Misc.cpp b/src/Hook/Hooks_Misc.cpp index 367d8513..0cf9db09 100644 --- a/src/Hook/Hooks_Misc.cpp +++ b/src/Hook/Hooks_Misc.cpp @@ -28,7 +28,7 @@ namespace { AppId_t appId = static_cast(pGameID->AppID(true)); const char* cmdLine = VehCommon::GetArg(ctx, 3); - if (LuaConfig::HasDepot(appId) && cmdLine && strstr(cmdLine, "-onlinefix")) + if (LuaConfig::HasDepot(appId) && cmdLine && strstr(cmdLine, "-onlinefix")) { g_OnlineFixRealAppId = appId; pGameID->SetAppID(kOnlineFixAppId); diff --git a/src/OSTPlatform/Windows/NtAbi.h b/src/OSTPlatform/Windows/NtAbi.h index a8338614..12287159 100644 --- a/src/OSTPlatform/Windows/NtAbi.h +++ b/src/OSTPlatform/Windows/NtAbi.h @@ -49,9 +49,23 @@ namespace OSTPlatform::Windows::NtAbi { PVOID processParameters; }; + struct UnicodeString { + uint16_t length; + uint16_t maximumLength; + uint32_t padding; + PVOID buffer; + }; + + struct UnicodeString32 { + uint16_t length; + uint16_t maximumLength; + uint32_t buffer; + }; + struct RtlUserProcessParameters { - BYTE reserved0[0x80]; - PVOID environment; + BYTE reserved0[0x70]; + UnicodeString commandLine; // 0x70 + PVOID environment; // 0x80 }; struct Peb32 { @@ -60,13 +74,16 @@ namespace OSTPlatform::Windows::NtAbi { }; struct RtlUserProcessParameters32 { - BYTE reserved0[0x48]; - uint32_t environment; + BYTE reserved0[0x40]; + UnicodeString32 commandLine; // 0x40 + uint32_t environment; // 0x48 }; static_assert(offsetof(Peb, processParameters) == 0x20); + static_assert(offsetof(RtlUserProcessParameters, commandLine) == 0x70); static_assert(offsetof(RtlUserProcessParameters, environment) == 0x80); static_assert(offsetof(Peb32, processParameters) == 0x10); + static_assert(offsetof(RtlUserProcessParameters32, commandLine) == 0x40); static_assert(offsetof(RtlUserProcessParameters32, environment) == 0x48); } // namespace OSTPlatform::Windows::NtAbi diff --git a/src/OSTPlatform/Windows/Process.cpp b/src/OSTPlatform/Windows/Process.cpp index b4fb6639..7b5a4f20 100644 --- a/src/OSTPlatform/Windows/Process.cpp +++ b/src/OSTPlatform/Windows/Process.cpp @@ -147,6 +147,62 @@ std::optional QueryWow64EnvironmentAddress(HANDLE process) { return reinterpret_cast(static_cast(*environment32)); } +std::optional ReadCommandLineNative(HANDLE process) { + const auto pebAddress = QueryNativePebAddress(process); + if (!pebAddress) return std::nullopt; + + const auto processParameters = ReadRemoteValue( + process, + AddOffset(*pebAddress, offsetof(NtAbi::Peb, processParameters))); + if (!processParameters || !*processParameters) return std::nullopt; + + const auto commandLine = ReadRemoteValue( + process, + AddOffset(*processParameters, offsetof(NtAbi::RtlUserProcessParameters, commandLine))); + if (!commandLine || !commandLine->buffer || commandLine->length == 0) return std::nullopt; + + const size_t chars = commandLine->length / sizeof(wchar_t); + if (chars == 0 || chars > kMaxEnvironmentBytes / sizeof(wchar_t)) return std::nullopt; + + std::wstring value(chars, L'\0'); + size_t bytesRead = 0; + if (!TryReadProcessMemory(process, commandLine->buffer, value.data(), + chars * sizeof(wchar_t), &bytesRead)) { + return std::nullopt; + } + value.resize(bytesRead / sizeof(wchar_t)); + return value; +} + +std::optional ReadCommandLineWow64(HANDLE process) { + const auto peb32 = QueryWow64PebAddress(process); + if (!peb32) return std::nullopt; + + const auto processParameters32 = ReadRemoteValue( + process, + AddOffset(reinterpret_cast(*peb32), offsetof(NtAbi::Peb32, processParameters))); + if (!processParameters32 || *processParameters32 == 0) return std::nullopt; + + const auto commandLine = ReadRemoteValue( + process, + AddOffset(reinterpret_cast(static_cast(*processParameters32)), + offsetof(NtAbi::RtlUserProcessParameters32, commandLine))); + if (!commandLine || commandLine->buffer == 0 || commandLine->length == 0) return std::nullopt; + + const size_t chars = commandLine->length / sizeof(wchar_t); + if (chars == 0 || chars > kMaxEnvironmentBytes / sizeof(wchar_t)) return std::nullopt; + + std::wstring value(chars, L'\0'); + size_t bytesRead = 0; + if (!TryReadProcessMemory(process, + reinterpret_cast(static_cast(commandLine->buffer)), + value.data(), chars * sizeof(wchar_t), &bytesRead)) { + return std::nullopt; + } + value.resize(bytesRead / sizeof(wchar_t)); + return value; +} + std::optional QueryReadableRegionBytes(HANDLE process, PVOID address) { const auto ntQueryVirtualMemory = NtQueryVirtualMemoryProc(); if (!ntQueryVirtualMemory) return std::nullopt; @@ -310,6 +366,17 @@ std::optional GetEnvironmentVariableValue(uint32_t pid, std::wstrin return FindEnvironmentVariable(*environment, name); } +std::optional GetProcessCommandLine(uint32_t pid) { + Windows::UniqueHandle process = + OpenProcessHandle(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ); + if (!process) return std::nullopt; + + auto commandLine = ReadCommandLineWow64(process.get()); + if (!commandLine) commandLine = ReadCommandLineNative(process.get()); + if (!commandLine) return std::nullopt; + return Encoding::WideToUtf8(*commandLine); +} + std::vector EnumerateModules(uint32_t pid) { std::vector modules; Windows::UniqueFileHandle snapshot( diff --git a/src/OSTPlatform/include/Process.h b/src/OSTPlatform/include/Process.h index 42a41cae..452ff0cd 100644 --- a/src/OSTPlatform/include/Process.h +++ b/src/OSTPlatform/include/Process.h @@ -20,6 +20,7 @@ namespace OSTPlatform::Process { std::string FormatCreationTime(uint64_t fileTime); std::optional GetImagePath(uint32_t pid); std::optional GetEnvironmentVariableValue(uint32_t pid, std::wstring_view name); + std::optional GetProcessCommandLine(uint32_t pid); std::vector EnumerateModules(uint32_t pid); // True when `path` lives under the OS system directory tree (on Windows, diff --git a/src/Pipe/Features/Injection/Injection.cpp b/src/Pipe/Features/Injection/Injection.cpp index 454d3644..2bffb15d 100644 --- a/src/Pipe/Features/Injection/Injection.cpp +++ b/src/Pipe/Features/Injection/Injection.cpp @@ -1,79 +1,84 @@ #include "Pipe/Features/Injection/Injection.h" +#include "OSTPlatform/include/Process.h" #include "OSTPlatform/include/RemoteProcess.h" -#include "OSTPlatform/include/Encoding.h" #include "Utils/Config/Config.h" #include "Utils/Logging/Log.h" -#include "dllmain.h" - #include #include +#include #include #include namespace PipeManager::Injection { namespace { - std::mutex g_mutex; - std::unordered_set g_injected; + // Keyed on (process, path) so each DLL injects at most once per process + // while several [[inject]] entries can still target the same game. + struct InjectedKey { + ProcessKey process; + std::string path; + bool operator==(const InjectedKey&) const = default; + }; + struct InjectedKeyHash { + std::size_t operator()(const InjectedKey& key) const noexcept { + return ProcessKeyHash{}(key.process) ^ std::hash{}(key.path); + } + }; - bool WasInjected(const ProcessKey& key) { - std::scoped_lock lock(g_mutex); - return g_injected.contains(key); - } + std::mutex g_mutex; + std::unordered_set g_injected; - void MarkInjected(const ProcessKey& key) { + bool ClaimInjection(const InjectedKey& key) { std::scoped_lock lock(g_mutex); - g_injected.insert(key); + return g_injected.insert(key).second; } - std::filesystem::path ResolveLibraryPath(const std::string& configured) { - std::filesystem::path path(OSTPlatform::Encoding::Utf8ToWide(configured)); - if (path.is_absolute()) return path; - - std::filesystem::path base(OSTPlatform::Encoding::Utf8ToWide(SteamInstallPath)); - return base / path; - } - - const std::string* ConfiguredLibraryFor(const Config::InjectionSettings& settings, - OSTPlatform::RemoteProcess::Architecture architecture) { - // Unknown architecture means we cannot choose a safe library path. - switch (architecture) { - case OSTPlatform::RemoteProcess::Architecture::X64: - return settings.libraryX64.empty() ? nullptr : &settings.libraryX64; - case OSTPlatform::RemoteProcess::Architecture::X86: - return settings.libraryX86.empty() ? nullptr : &settings.libraryX86; - case OSTPlatform::RemoteProcess::Architecture::Unknown: - return nullptr; + bool Matches(const Config::InjectDll& dll, const PipeContext& ctx, + const std::optional& cmdLine) { + if (!dll.allGames && !ctx.trackedApp) return false; + if (!dll.whenAppids.empty() && !dll.whenAppids.count(ctx.appId)) return false; + if (!dll.whenCmdline.empty() && + (!cmdLine || cmdLine->find(dll.whenCmdline) == std::string::npos)) { + return false; } - return nullptr; + return true; } } // namespace void Apply(const PipeContext& ctx) { - const Config::InjectionSettings settings = Config::GetInjectionSettings(); - if (!settings.enabled) return; + if (Config::injectDlls.empty()) return; if (!ctx.gameProcess) return; - const auto architecture = OSTPlatform::RemoteProcess::GetArchitecture(ctx.process.pid); - const std::string* configuredLibrary = ConfiguredLibraryFor(settings, architecture); - if (!configuredLibrary) return; - if (WasInjected(ctx.process)) return; + // Read the command line lazily: only if an injection entry uses it. + std::optional cmdLine; + bool cmdLineResolved = false; + auto commandLine = [&]() -> const std::optional& { + if (!cmdLineResolved) { + cmdLine = OSTPlatform::Process::GetProcessCommandLine(ctx.process.pid); + cmdLineResolved = true; + } + return cmdLine; + }; + + for (const auto& dll : Config::injectDlls) { + const std::optional& cmd = dll.whenCmdline.empty() ? cmdLine : commandLine(); + if (!Matches(dll, ctx, cmd)) continue; + if (!ClaimInjection({ctx.process, dll.path})) continue; - const std::filesystem::path libraryPath = ResolveLibraryPath(*configuredLibrary); - const auto status = OSTPlatform::RemoteProcess::InjectLibrary(ctx.process.pid, libraryPath); - if (status == OSTPlatform::RemoteProcess::InjectStatus::Ok) { - MarkInjected(ctx.process); - LOG_PIPE_INFO("Injection: injected {} library into pid={} path={}", - OSTPlatform::RemoteProcess::ToString(architecture), ctx.process.pid, libraryPath.string()); - } else { - LOG_PIPE_WARN("Injection: failed pid={} arch={} status={} path={}", - ctx.process.pid, - OSTPlatform::RemoteProcess::ToString(architecture), - OSTPlatform::RemoteProcess::ToString(status), - libraryPath.string()); + const std::filesystem::path path(dll.path); + const auto status = OSTPlatform::RemoteProcess::InjectLibrary(ctx.process.pid, path); + if (status == OSTPlatform::RemoteProcess::InjectStatus::Ok) { + LOG_INJECT_INFO("injected pid={} appid={} dll=\"{}\"", + ctx.process.pid, ctx.appId, path.filename().string()); + } else { + LOG_INJECT_WARN("inject failed pid={} appid={} status={} dll=\"{}\"", + ctx.process.pid, ctx.appId, + OSTPlatform::RemoteProcess::ToString(status), + path.filename().string()); + } } } diff --git a/src/Utils/Config/Config.cpp b/src/Utils/Config/Config.cpp index a7790532..677143f2 100644 --- a/src/Utils/Config/Config.cpp +++ b/src/Utils/Config/Config.cpp @@ -18,7 +18,7 @@ namespace { std::vector luaPaths; std::string remoteUrlTemplate; bool statsEnableApi = true; - InjectionSettings injection; + std::vector injectDlls; }; std::mutex g_mutex; @@ -52,9 +52,7 @@ namespace { luaPaths = snapshot.luaPaths; remoteUrlTemplate = snapshot.remoteUrlTemplate; statsEnableApi = snapshot.statsEnableApi; - injectEnabled = snapshot.injection.enabled; - injectLibraryX86 = snapshot.injection.libraryX86; - injectLibraryX64 = snapshot.injection.libraryX64; + injectDlls = snapshot.injectDlls; } void ApplyManifestProvider(const std::string& provider) { @@ -145,14 +143,32 @@ namespace { } } - // [inject] - if (auto inject = tbl["inject"].as_table()) { - if (auto val = (*inject)["enabled"].value()) - snapshot.injection.enabled = *val; - if (auto val = (*inject)["library_x86"].value()) - snapshot.injection.libraryX86 = *val; - if (auto val = (*inject)["library_x64"].value()) - snapshot.injection.libraryX64 = *val; + // [[inject]] + if (auto arr = tbl["inject"].as_array()) { + std::filesystem::path steamDir = std::filesystem::path(configPath).parent_path(); + for (auto& node : *arr) { + auto t = node.as_table(); + if (!t) continue; + auto path = (*t)["path"].value(); + if (!path || path->empty()) continue; + + // Bare names resolve next to steam.exe. + std::filesystem::path full = *path; + if (full.is_relative()) full = steamDir / full; + if (!std::filesystem::exists(full)) { + LOG_WARN("inject dll not found: {}", full.string()); + continue; + } + + InjectDll dll; + dll.path = full.string(); + if (auto val = (*t)["when_cmdline"].value()) dll.whenCmdline = *val; + if (auto val = (*t)["all_games"].value()) dll.allGames = *val; + if (auto ids = (*t)["when_appids"].as_array()) + for (auto& id : *ids) + if (auto v = id.value()) dll.whenAppids.insert(static_cast(*v)); + snapshot.injectDlls.push_back(std::move(dll)); + } } ApplyManifestProvider(snapshot.manifestProvider); @@ -216,15 +232,6 @@ namespace { return remoteUrlTemplate; } - InjectionSettings GetInjectionSettings() { - std::lock_guard lock(g_mutex); - return { - injectEnabled, - injectLibraryX86, - injectLibraryX64, - }; - } - bool GetStatsEnableApi() { std::lock_guard lock(g_mutex); return statsEnableApi; diff --git a/src/Utils/Config/Config.h b/src/Utils/Config/Config.h index d69e764e..734cc59b 100644 --- a/src/Utils/Config/Config.h +++ b/src/Utils/Config/Config.h @@ -2,8 +2,11 @@ #include #include +#include #include +#include "Steam/Types.h" + namespace Config { enum class LogLevel { Trace, Debug, Info, Warn, Error }; @@ -15,10 +18,12 @@ namespace Config { uint32_t recv = 10000; }; - struct InjectionSettings { - bool enabled = false; - std::string libraryX86; - std::string libraryX64; + // [[inject]] entry: a DLL loaded into a matching game process at the IPC handshake. + struct InjectDll { + std::string path; // resolved absolute path + std::string whenCmdline; // substring required in the game command line + std::unordered_set whenAppids; // appids this entry applies to + bool allGames = false; // false: only Lua-unlocked games }; struct LoadResult { @@ -33,33 +38,30 @@ namespace Config { std::string GetLogDir(); std::vector GetLuaPaths(); std::string GetRemoteUrlTemplate(); - InjectionSettings GetInjectionSettings(); 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; // [stats] inline bool statsEnableApi = true; - // [inject] - optional library injection into game processes. - inline bool injectEnabled = false; - inline std::string injectLibraryX86; - inline std::string injectLibraryX64; + // [[inject]] - optional DLL injection into matching game processes. + inline std::vector injectDlls; } diff --git a/src/Utils/Config/LuaFileWatcher.h b/src/Utils/Config/LuaFileWatcher.h index 321d66c1..5e7602c6 100644 --- a/src/Utils/Config/LuaFileWatcher.h +++ b/src/Utils/Config/LuaFileWatcher.h @@ -2,7 +2,7 @@ #include #include - + namespace LuaFileWatcher { void Start(const std::vector& directories); void Stop(); diff --git a/src/Utils/Logging/Log.h b/src/Utils/Logging/Log.h index bf0f58c8..81254b57 100644 --- a/src/Utils/Logging/Log.h +++ b/src/Utils/Logging/Log.h @@ -1,73 +1,73 @@ -#pragma once - -// Multi-file logger backed by spdlog (Debug only; Release → no-ops). -// -// Log::Init() — creates main.log at trace level (before Config). -// Log::InitModules() — creates per-module loggers + applies Config level -// to all loggers. Call after Config::Load(). -// -// General macros → /opensteamtool/main.log -// Module macros → /opensteamtool/.log -// -// Adding a new module logger: +#pragma once + +// Multi-file logger backed by spdlog (Debug only; Release → no-ops). +// +// Log::Init() — creates main.log at trace level (before Config). +// Log::InitModules() — creates per-module loggers + applies Config level +// to all loggers. Call after Config::Load(). +// +// General macros → /opensteamtool/main.log +// Module macros → /opensteamtool/.log +// +// Adding a new module logger: // 1. Add OST_MOD(NewMod, "newmod") in LogModules.def. -// 2. Run CMake configure (the LOG_NEWMOD_* macros are auto-generated). - -#ifdef OPENSTEAMTOOL_LOGGING_ENABLED - -#ifndef SPDLOG_ACTIVE_LEVEL - #define SPDLOG_ACTIVE_LEVEL SPDLOG_LEVEL_TRACE -#endif - -#include -#include -#include -#include "OSTPlatform/include/DynamicLibrary.h" -#include - -namespace Log { +// 2. Run CMake configure (the LOG_NEWMOD_* macros are auto-generated). + +#ifdef OPENSTEAMTOOL_LOGGING_ENABLED + +#ifndef SPDLOG_ACTIVE_LEVEL + #define SPDLOG_ACTIVE_LEVEL SPDLOG_LEVEL_TRACE +#endif + +#include +#include +#include +#include "OSTPlatform/include/DynamicLibrary.h" +#include + +namespace Log { void Init(OSTPlatform::DynamicLibrary::ModuleHandle selfModule); void InitModules(); void ApplyConfigLevel(); - - // Route OSTPlatform's logging facade into the host's "platform" logger. - // Call once after InitModules() (the Platform logger must exist first). - void InstallPlatformLogSink(); - - inline std::shared_ptr Main; - + + // Route OSTPlatform's logging facade into the host's "platform" logger. + // Call once after InitModules() (the Platform logger must exist first). + void InstallPlatformLogSink(); + + inline std::shared_ptr Main; + // Module loggers — auto-generated from LogModules.def #define OST_MOD(v, f) inline std::shared_ptr v; #include "LogModules.def" - #undef OST_MOD -} - -// ── General-purpose (main.log) ────────────────────────────────────── -#define LOG_TRACE(...) SPDLOG_LOGGER_TRACE(Log::Main, __VA_ARGS__) -#define LOG_DEBUG(...) SPDLOG_LOGGER_DEBUG(Log::Main, __VA_ARGS__) -#define LOG_INFO(...) SPDLOG_LOGGER_INFO(Log::Main, __VA_ARGS__) -#define LOG_WARN(...) SPDLOG_LOGGER_WARN(Log::Main, __VA_ARGS__) -#define LOG_ERROR(...) SPDLOG_LOGGER_ERROR(Log::Main, __VA_ARGS__) - -#else // OPENSTEAMTOOL_LOGGING_ENABLED - -#include "OSTPlatform/include/DynamicLibrary.h" - + #undef OST_MOD +} + +// ── General-purpose (main.log) ────────────────────────────────────── +#define LOG_TRACE(...) SPDLOG_LOGGER_TRACE(Log::Main, __VA_ARGS__) +#define LOG_DEBUG(...) SPDLOG_LOGGER_DEBUG(Log::Main, __VA_ARGS__) +#define LOG_INFO(...) SPDLOG_LOGGER_INFO(Log::Main, __VA_ARGS__) +#define LOG_WARN(...) SPDLOG_LOGGER_WARN(Log::Main, __VA_ARGS__) +#define LOG_ERROR(...) SPDLOG_LOGGER_ERROR(Log::Main, __VA_ARGS__) + +#else // OPENSTEAMTOOL_LOGGING_ENABLED + +#include "OSTPlatform/include/DynamicLibrary.h" + namespace Log { inline void Init(OSTPlatform::DynamicLibrary::ModuleHandle) {} inline void InitModules() {} inline void ApplyConfigLevel() {} inline void InstallPlatformLogSink() {} } - -#define LOG_TRACE(...) ((void)0) -#define LOG_DEBUG(...) ((void)0) -#define LOG_INFO(...) ((void)0) -#define LOG_WARN(...) ((void)0) -#define LOG_ERROR(...) ((void)0) - -#endif // OPENSTEAMTOOL_LOGGING_ENABLED - -// ── Per-module macros (auto-generated by cmake/LogMacros.cmake) ──── -// Generated header has its own #ifdef OPENSTEAMTOOL_LOGGING_ENABLED guard. -#include "ost_log_macros.h" + +#define LOG_TRACE(...) ((void)0) +#define LOG_DEBUG(...) ((void)0) +#define LOG_INFO(...) ((void)0) +#define LOG_WARN(...) ((void)0) +#define LOG_ERROR(...) ((void)0) + +#endif // OPENSTEAMTOOL_LOGGING_ENABLED + +// ── Per-module macros (auto-generated by cmake/LogMacros.cmake) ──── +// Generated header has its own #ifdef OPENSTEAMTOOL_LOGGING_ENABLED guard. +#include "ost_log_macros.h" diff --git a/src/Utils/Logging/LogModules.def b/src/Utils/Logging/LogModules.def index e353c8a1..64c0de89 100644 --- a/src/Utils/Logging/LogModules.def +++ b/src/Utils/Logging/LogModules.def @@ -25,5 +25,6 @@ OST_MOD(OnlineFix, "onlinefix") OST_MOD(RichPresence, "richpresence") OST_MOD(Package, "package") OST_MOD(SteamUI, "steamui") +OST_MOD(Inject, "inject") OST_MOD(Pipe, "pipe") OST_MOD(Platform, "platform") diff --git a/src/dllmain.h b/src/dllmain.h index f45507aa..f141f78d 100644 --- a/src/dllmain.h +++ b/src/dllmain.h @@ -1,42 +1,42 @@ -#ifndef DLLMAIN_H -#define DLLMAIN_H - -#include "OSTPlatform/include/DynamicLibrary.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "Steam/Types.h" -#include "Steam/Enums.h" -#include "Steam/Structs.h" -#include "Steam/Callback.h" -#include "Utils/Config/LuaConfig.h" -#include "Utils/Logging/Log.h" -#include "Utils/Config/Config.h" - - -inline OSTPlatform::DynamicLibrary::ModuleHandle client_hModule = nullptr; -inline OSTPlatform::DynamicLibrary::ModuleHandle ui_hModule = nullptr; - -inline constexpr size_t kRuntimePathCapacity = 260; - -inline char SteamInstallPath[kRuntimePathCapacity] = {}; -inline char SteamclientPath[kRuntimePathCapacity] = {}; -inline char SteamUIPath[kRuntimePathCapacity] = {}; -inline char DiversionPath[kRuntimePathCapacity] = {}; -inline char LuaDir[kRuntimePathCapacity] = {}; -inline char ConfigPath[kRuntimePathCapacity] = {}; - -// The fake AppId used by -onlinefix (SpaceWar). -constexpr AppId_t kOnlineFixAppId = 480; - -#endif // DLLMAIN_H +#ifndef DLLMAIN_H +#define DLLMAIN_H + +#include "OSTPlatform/include/DynamicLibrary.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Steam/Types.h" +#include "Steam/Enums.h" +#include "Steam/Structs.h" +#include "Steam/Callback.h" +#include "Utils/Config/LuaConfig.h" +#include "Utils/Logging/Log.h" +#include "Utils/Config/Config.h" + + +inline OSTPlatform::DynamicLibrary::ModuleHandle client_hModule = nullptr; +inline OSTPlatform::DynamicLibrary::ModuleHandle ui_hModule = nullptr; + +inline constexpr size_t kRuntimePathCapacity = 260; + +inline char SteamInstallPath[kRuntimePathCapacity] = {}; +inline char SteamclientPath[kRuntimePathCapacity] = {}; +inline char SteamUIPath[kRuntimePathCapacity] = {}; +inline char DiversionPath[kRuntimePathCapacity] = {}; +inline char LuaDir[kRuntimePathCapacity] = {}; +inline char ConfigPath[kRuntimePathCapacity] = {}; + +// The fake AppId used by -onlinefix (SpaceWar). +constexpr AppId_t kOnlineFixAppId = 480; + +#endif // DLLMAIN_H From f54e36a40f6a3cc5c112437310b0f0bf9fd22565 Mon Sep 17 00:00:00 2001 From: Ran-Mewo <43445785+Ran-Mewo@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:23:51 +1000 Subject: [PATCH 09/30] improve comment spacing --- opensteamtool.example.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/opensteamtool.example.toml b/opensteamtool.example.toml index 0169cb91..7097caa9 100644 --- a/opensteamtool.example.toml +++ b/opensteamtool.example.toml @@ -75,10 +75,10 @@ enable_api = true # when every condition it sets matches the launch. # Example: # [[inject]] -# path = "OpenSteamToolHook.dll" # bare name resolves next to steam.exe; absolute path used as-is +# path = "OpenSteamToolHook.dll" # bare name resolves next to steam.exe; absolute path used as-is # when_cmdline = "-my_special_hook" # optional: require this substring in the launch command (default: any) -# when_appids = [1361510] # optional: restrict to these appids (default: any) -# all_games = false # optional: true injects into every game, false only into Lua-added games (default: false) +# when_appids = [1361510] # optional: restrict to these appids (default: any) +# all_games = false # optional: true injects into every game, false only into Lua-added games (default: false) [cloud] # Optional Steam Cloud save redirection for unlocked ("lua") games, powered by From fa40b33de9fe101dcde67816846bb902ac750899 Mon Sep 17 00:00:00 2001 From: Ran-Mewo <43445785+Ran-Mewo@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:43:49 +1000 Subject: [PATCH 10/30] Ensure owned games allows -onlinefix queueing --- src/Hook/Hooks_Misc.cpp | 2 +- src/Hook/Hooks_NetPacket.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Hook/Hooks_Misc.cpp b/src/Hook/Hooks_Misc.cpp index 0cf9db09..083f8bfc 100644 --- a/src/Hook/Hooks_Misc.cpp +++ b/src/Hook/Hooks_Misc.cpp @@ -28,7 +28,7 @@ namespace { AppId_t appId = static_cast(pGameID->AppID(true)); const char* cmdLine = VehCommon::GetArg(ctx, 3); - if (LuaConfig::HasDepot(appId) && cmdLine && strstr(cmdLine, "-onlinefix")) + if (cmdLine && strstr(cmdLine, "-onlinefix")) { g_OnlineFixRealAppId = appId; pGameID->SetAppID(kOnlineFixAppId); diff --git a/src/Hook/Hooks_NetPacket.cpp b/src/Hook/Hooks_NetPacket.cpp index a5022d5a..0b12d7ca 100644 --- a/src/Hook/Hooks_NetPacket.cpp +++ b/src/Hook/Hooks_NetPacket.cpp @@ -882,7 +882,7 @@ namespace Hooks_NetPacket_OnlineFix { // Fill game_extra_info with the real game name. if (appid == kOnlineFixAppId) { AppId_t realAppId = Hooks_Misc::ResolveAppId(); - if (realAppId && LuaConfig::HasDepot(realAppId)) { + if (realAppId && realAppId != kOnlineFixAppId) { std::string name = Hooks_Misc::GetGameNameByAppID(realAppId); if (!name.empty()) { game->set_game_extra_info(name); From 42b7a7d7a0e22aac2600bab3a7170766c8b3097a Mon Sep 17 00:00:00 2001 From: Tesla697 <96721065+Tesla697@users.noreply.github.com> Date: Fri, 26 Jun 2026 16:35:27 +0530 Subject: [PATCH 11/30] Track env-less Denuvo games + add forcedenuvo / seteticketurl config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent fixes that together let strict-Denuvo titles boot when they currently fail with 88500012 — all opt-in via Lua config; default behaviour for existing games is unchanged. 1. Env-less appid resolution (PipeManager) Games launched as a child of a third-party launcher (e.g. Suicide Squad: KTJL, NBA 2K26) come up with SteamAppId=0, so the existing env-derived resolution returns invalid → trackedApp=false → DenuvoAuth::Apply bails forever → 012. ResolveAppIdWithRetry adds two fallbacks after the env check: - GetAppIDForCurrentPipe with a brief 10×20ms retry (steamclient can take a few ms to bind the pipe's appid past the literal handshake instant); - LuaConfig::GetAppIdForProcess(imageName), populated by the new addprocess(appid, "Exe.exe") Lua function. gameProcess is now (likelyGameProcess || trackedApp) so a configured depot counts as a game even without the env vars that drive likelyGameProcess. 2. forcedenuvo(appid) Lua function (LuaConfig + DenuvoAuth) Some Denuvo builds fire neither the OEP pattern nor the structural RWX+entropy heuristic (no W+X section at all, or below entropy floor). For those games auth.denuvo stays false → the authorization window never opens → GetSteamID is never spoofed → 012. forcedenuvo() lets the user mark a known-Denuvo appid; EnsureScanned then skips the scan and forces denuvo=true. No effect on any appid the user doesn't list. 3. On-demand nonce-bound eticket + 858 ownership spoof Strict Denuvo titles bind their encrypted-app-ticket to a per-launch nonce passed into RequestEncryptedAppTicket; a static credential-store ticket can never carry that nonce → 012. EticketClient POSTs {app_id, nonce(hex)} to a user-configured backend (seteticketurl) and serves the fresh response from the IPC GetEncryptedAppTicket handler. Hooks_NetPacket also now intercepts ClientGetAppOwnershipTicketResponse (eMsg 858, protobuf {eresult, app_id, ticket}) and replaces a not-owned response with the matching owner ticket from the same mint — required for games that gate ownership over the CM network rather than via IPC. Both paths share one per-app cache so eticket and ownership ticket always align to the same account. Empty URL (the default) disables the whole feature; the DLL then serves the static credential-store ticket exactly as a stock build does. Also includes a ProtectionScan refinement: lower the protector-blob entropy floor from 7.0 → 6.0 after observing Demon Slayer (Unreal Shipping .bss, RWX, 405 MB) shows uniform entropy 6.651 across the whole section. The decisive signal remains W+X-on-disk + ≥4 MB; the entropy floor is now a sparse/zero guard, not a "looks encrypted" test. A separate high-confidence label is logged when entropy ≥ 7.0. Verified working end-to-end: Sonic Forces (637100) — structural section, entropy 7.247 Demon Slayer (1490890) — structural section, entropy 6.651 Suicide Squad: KTJL (315210) — addprocess + forcedenuvo, env=0 --- src/CMakeLists.txt | 1 + src/Hook/Hooks_IPC_ISteamUser.cpp | 55 ++++++- src/Hook/Hooks_NetPacket.cpp | 72 ++++++++ src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp | 11 +- .../Features/DenuvoAuth/ProtectionScan.cpp | 32 ++-- src/Pipe/PipeManager.cpp | 75 ++++++++- src/Utils/Config/LuaConfig.cpp | 64 ++++++++ src/Utils/Config/LuaConfig.h | 14 ++ src/Utils/Tickets/EticketClient.cpp | 155 ++++++++++++++++++ src/Utils/Tickets/EticketClient.h | 38 +++++ src/proto/steam_messages.proto | 14 ++ 11 files changed, 510 insertions(+), 21 deletions(-) create mode 100644 src/Utils/Tickets/EticketClient.cpp create mode 100644 src/Utils/Tickets/EticketClient.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8a979d9d..0dbf7491 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -103,6 +103,7 @@ add_library(OpenSteamTool SHARED # Shared utilities Utils/Tickets/AppTicket.cpp + Utils/Tickets/EticketClient.cpp Utils/Config/Config.cpp Utils/Config/ConfigFileWatcher.cpp Utils/Config/LuaConfig.cpp diff --git a/src/Hook/Hooks_IPC_ISteamUser.cpp b/src/Hook/Hooks_IPC_ISteamUser.cpp index f0ac67e6..fcf976d5 100644 --- a/src/Hook/Hooks_IPC_ISteamUser.cpp +++ b/src/Hook/Hooks_IPC_ISteamUser.cpp @@ -2,14 +2,26 @@ #include "Hooks_IPC_ISteamUser.h" #include "PendingAPICalls.h" #include "Utils/Tickets/AppTicket.h" +#include "Utils/Tickets/EticketClient.h" #include "Pipe/PipeManager.h" #include "Pipe/Features/DenuvoAuth/DenuvoAuth.h" #include "Utils/Logging/Log.h" #include "Hooks_Misc.h" +#include +#include +#include + namespace { using namespace IPCMessages::IClientUser; + // Fresh, nonce-bound etickets minted on-demand in RequestEncryptedAppTicket + // (keyed by appId) and consumed by GetEncryptedAppTicket on the same launch. + // Lets the strict-Denuvo path serve a ticket matching the launch nonce while + // keeping GetEncryptedAppTicket's credential-store serve as the fallback. + std::mutex g_freshEticketMutex; + std::unordered_map> g_freshEticket; + // [Post-Handler]: IClientUser::GetSteamID void HandlerPost_IClientUser_GetSteamID(CPipeClient* pipe,CUtlBuffer* pRead, CUtlBuffer* pWrite) { @@ -83,27 +95,62 @@ namespace { if (!resp.ok()) return; AppId_t appId = Hooks_Misc::ResolveAppId(); + + // Strict Denuvo passes a per-launch nonce (pData) here and rejects a + // stale/cached ticket (88500012). Try an on-demand mint bound to that + // exact nonce; cache it for GetEncryptedAppTicket. Any failure falls + // through to the static credential store below. + { + RequestEncryptedAppTicketReq req{pRead}; + std::span nonce; + if (req.ok()) nonce = req.pData(); + if (auto fresh = EticketClient::FetchFreshEticket(appId, nonce)) { + std::lock_guard lock(g_freshEticketMutex); + g_freshEticket[appId] = std::move(*fresh); + } + } + + bool haveFresh; + { + std::lock_guard lock(g_freshEticketMutex); + haveFresh = g_freshEticket.find(appId) != g_freshEticket.end(); + } + std::vector ticket = AppTicket::GetEncryptedTicketFromCredentialStore(appId); - if (ticket.empty()) { + if (ticket.empty() && !haveFresh) { LOG_IPC_DEBUG("RequestEncryptedAppTicket: AppId={} - no cached eticket, skip", appId); return; } const SteamAPICall_t hAsyncCall = resp.returnValue(); PendingAPICalls::RecordEncryptedTicket(hAsyncCall, appId); - LOG_IPC_DEBUG("RequestEncryptedAppTicket: AppId={} hAsyncCall=0x{:X} - recorded", - appId, hAsyncCall); + LOG_IPC_DEBUG("RequestEncryptedAppTicket: AppId={} hAsyncCall=0x{:X} - recorded (fresh={})", + appId, hAsyncCall, haveFresh); } // [Post-Handler]: IClientUser::GetEncryptedAppTicket void HandlerPost_IClientUser_GetEncryptedAppTicket(CPipeClient* pipe, CUtlBuffer* pRead, CUtlBuffer* pWrite) { AppId_t appId = Hooks_Misc::ResolveAppId(); - std::vector ticket = AppTicket::GetEncryptedTicketFromCredentialStore(appId); + + // Prefer a fresh nonce-bound ticket minted in RequestEncryptedAppTicket; + // fall back to the static credential-store ticket (titles that don't + // need the on-demand path keep working unchanged). + std::vector ticket; + { + std::lock_guard lock(g_freshEticketMutex); + auto it = g_freshEticket.find(appId); + if (it != g_freshEticket.end()) ticket = it->second; + } + const bool fromFresh = !ticket.empty(); + if (ticket.empty()) { + ticket = AppTicket::GetEncryptedTicketFromCredentialStore(appId); + } if (ticket.empty()) { LOG_IPC_DEBUG("GetEncryptedAppTicket: AppId={} - no cached eticket, skip", appId); return; } + LOG_IPC_DEBUG("GetEncryptedAppTicket: AppId={} serving source={}", appId, fromFresh ? "fresh" : "store"); uint32 ticketSize = static_cast(ticket.size()); uint32 newCapacity = pWrite->Capacity() + ticketSize; diff --git a/src/Hook/Hooks_NetPacket.cpp b/src/Hook/Hooks_NetPacket.cpp index a5022d5a..fb593cce 100644 --- a/src/Hook/Hooks_NetPacket.cpp +++ b/src/Hook/Hooks_NetPacket.cpp @@ -4,11 +4,14 @@ #include "HookMacros.h" #include "dllmain.h" #include "Utils/Tickets/AppTicket.h" +#include "Utils/Tickets/EticketClient.h" #include "Utils/Support/FnvHash.h" #include "Utils/CloudRedirect/CloudRedirectHost.h" #include +#include #include #include +#include #include #include #include @@ -434,6 +437,71 @@ namespace Hooks_NetPacket_ETicket { } // namespace Hooks_NetPacket_ETicket +// ════════════════════════════════════════════════════════════════ +// Hooks_NetPacket_OwnershipTicket +// +// Incoming: MsgClientGetAppOwnershipTicketResponse (eMsg 858). +// Some Denuvo titles (e.g. Suicide Squad: KTJL) verify ownership via this +// network message instead of the IPC GetAppOwnershipTicketExtendedData hook, +// so OST's IPC ownership spoof never engages and the real (non-owning) account +// leaks through -> 88500012. 858 is a legacy NON-protobuf message with no +// schema in-tree and responses of varying size, so log the raw layout first; +// the spoof (inject the owner's signed ticket from the credential store) is +// wired once the exact field offsets are confirmed from a live capture. +// ════════════════════════════════════════════════════════════════ +namespace Hooks_NetPacket_OwnershipTicket { + + void HandleRecv(const uint8* pBody, uint32 cbBody) + { + CMsgClientGetAppOwnershipTicketResponse resp; + if (!resp.ParseFromArray(pBody, cbBody)) { + LOG_NETPACKET_WARN("OwnershipTicketResponse[858]: failed to ParseFromArray (cbBody={})", cbBody); + return; + } + + // Steam already returned a valid ticket (account owns it) — leave it. + if (resp.eresult() == k_EResultOK) return; + if (!LuaConfig::HasDepot(resp.app_id())) return; + + const int32 origEresult = resp.eresult(); + + // Owner's signed ownership ticket, from the SAME mint as the eticket + // (one /eticket call → both tickets → one account). Ownership tickets are + // not nonce-bound, so pass an empty nonce. Fall back to the credential + // store (redeemed account) if the backend is unavailable. + auto owner = EticketClient::FetchOwnershipTicket(resp.app_id(), {}); + if (!owner) { + auto stored = AppTicket::GetAppOwnershipTicketFromCredentialStore(resp.app_id()); + if (stored.empty()) { + LOG_NETPACKET_WARN("OwnershipTicketResponse[858]: appid={} eresult={} but no owner ticket available", + resp.app_id(), origEresult); + return; + } + owner = std::move(stored); + } + + resp.set_ticket(owner->data(), owner->size()); + resp.set_eresult(k_EResultOK); + + const auto encSize = resp.ByteSizeLong(); + if (encSize > sizeof(g_NewBody)) { + LOG_NETPACKET_WARN("OwnershipTicketResponse[858]: modified message too large ({})", encSize); + return; + } + if (!resp.SerializeToArray(g_NewBody, sizeof(g_NewBody))) { + LOG_NETPACKET_WARN("OwnershipTicketResponse[858]: failed to SerializeToArray"); + return; + } + + g_cbNewBody = static_cast(encSize); + g_NeedReplaceBody = true; + LOG_NETPACKET_INFO("OwnershipTicketResponse[858]: spoofed appid={} ticket_bytes={} (orig eresult={} -> OK)", + resp.app_id(), owner->size(), origEresult); + } + +} // namespace Hooks_NetPacket_OwnershipTicket + + // ════════════════════════════════════════════════════════════════ // Hooks_NetPacket_FamilySharing // ════════════════════════════════════════════════════════════════ @@ -1224,6 +1292,10 @@ namespace { g_NeedReplaceBody = Hooks_NetPacket_RichPresence::HandleRecv(pBody, cbBody, pHdr, cbHdr); return; + case k_EMsgClientGetAppOwnershipTicketResponse: // 858 + Hooks_NetPacket_OwnershipTicket::HandleRecv(pBody, cbBody); + return; + default: return; } diff --git a/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp b/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp index cabeab91..21951bcd 100644 --- a/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp +++ b/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp @@ -151,7 +151,7 @@ namespace { return authIt == g_processAuth.end() ? nullptr : &authIt->second; } - void EnsureScanned(ProcessAuth& auth, const ProcessKey& process) { + void EnsureScanned(ProcessAuth& auth, const ProcessKey& process, AppId_t appId) { if (auth.scanned) { LOG_PIPE_TRACE("DenuvoAuth: reusing cached protection result {} denuvo={}", process.DebugString(), auth.denuvo); @@ -159,7 +159,12 @@ namespace { } auth.scanned = true; - auth.denuvo = ScanProtection(process.pid).denuvoDetected; + if (LuaConfig::IsForcedDenuvo(appId)) { + auth.denuvo = true; + LOG_PIPE_INFO("DenuvoAuth: forcedenuvo appid={} — skipping ProtectionScan", appId); + } else { + auth.denuvo = ScanProtection(process.pid).denuvoDetected; + } if (!auth.denuvo) auth.stage = Stage::None; } @@ -174,7 +179,7 @@ void Apply(const PipeContext& ctx) { ProcessAuth& auth = g_processAuth[ctx.process]; g_pipeProcess[pipeKey] = ctx.process; - EnsureScanned(auth, ctx.process); + EnsureScanned(auth, ctx.process, ctx.appId); auth.OnHandshake(ctx, pipeKey); } diff --git a/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp b/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp index 5a037682..5d1cc898 100644 --- a/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp +++ b/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp @@ -69,15 +69,25 @@ namespace { // Structural fallback for Denuvo builds that ship NO OEP pattern and NO // "DENUVO" string (both checks above return nothing). A runtime-decrypting // protector must still carry a large code section that is simultaneously - // writable AND executable (it decrypts itself in place) and encrypted at - // rest (high entropy). That triad is version-independent and effectively - // absent from legitimately compiled binaries (which ship read-only code). - // Measured on Sonic Forces (637100): .arch is RWX, 103.9 MB, entropy 7.247, - // while its OEP section is a clean read-only stub — so this is the only - // method that fires on it. Clean binaries (steam/notepad/explorer) have no - // W+X section at all. + // writable AND executable (it decrypts itself in place). That W+X-on-disk + // flag is the decisive, version-independent signal: it is effectively + // absent from legitimately compiled binaries, which ship read-only code + // (R-X) and non-executable data (RW-). No modern toolchain emits a multi-MB + // section that is both writable and executable on disk. + // + // Entropy is only a weak sanity floor here, NOT the discriminator — it + // rejects sparse / zero-filled / trivially-compressible blobs while the + // W+X + size condition carries the decision. Protector blobs vary widely: + // Sonic Forces (637100): .arch RWX, 103.9 MB, entropy 7.247 + // APK (Unreal Shipping): .bss RWX, 405.6 MB, entropy 6.651 (uniform + // across the whole section — not a sample fluke) + // A 7.0 floor false-negatived the second one despite it carrying the literal + // "DENUVO" string, so the floor is set just above normal x64 code (~6.0-6.4) + // rather than at "looks encrypted". We do NOT key off the section name + // (.arch/.bss) — Denuvo renames sections freely. constexpr uint32 kProtectorBlobMinBytes = 4u * 1024u * 1024u; // skip small legit RWX thunks - constexpr double kProtectorBlobMinEntropy = 7.0; // encrypted/packed bits/byte + constexpr double kProtectorBlobMinEntropy = 6.0; // sparse/zero guard, not "is encrypted" + constexpr double kProtectorBlobHighConfidenceEntropy = 7.0; // looks encrypted/packed at rest constexpr size_t kProtectorBlobEntropySampleBytes = 8ull * 1024ull * 1024ull; // cap per-section read double SectionEntropy(std::span bytes) { @@ -286,9 +296,11 @@ namespace { match.entryPointRva = image.EntryPointRva(); match.matchRawOffset = section.rawOffset; match.matchRva = section.virtualAddress; - LOG_PIPE_INFO("DenuvoAuth: protector blob section path={} section={} raw_size={} ({:.2f} MB) entropy={:.3f} flags=RWX", + const char* confidence = + entropy >= kProtectorBlobHighConfidenceEntropy ? "high(encrypted)" : "elevated"; + LOG_PIPE_INFO("DenuvoAuth: protector blob section path={} section={} raw_size={} ({:.2f} MB) entropy={:.3f} flags=RWX confidence={}", module.path, section.name, section.rawSize, - BytesToMiB(static_cast(section.rawSize)), entropy); + BytesToMiB(static_cast(section.rawSize)), entropy, confidence); return match; } return std::nullopt; diff --git a/src/Pipe/PipeManager.cpp b/src/Pipe/PipeManager.cpp index 7c0a52fd..68f6b321 100644 --- a/src/Pipe/PipeManager.cpp +++ b/src/Pipe/PipeManager.cpp @@ -6,8 +6,11 @@ #include "Pipe/Features/Injection/Injection.h" #include "Utils/Logging/Log.h" #include "Utils/Config/LuaConfig.h" +#include "Hook/Hooks_Misc.h" +#include #include +#include #include namespace PipeManager { @@ -16,6 +19,17 @@ namespace { // OnHandshake runs single-threaded, so this cache needs no lock. std::unordered_map g_processes; + // steamclient doesn't always finish binding a brand-new pipe to its appid by + // the literal handshake instant — observed empirically: GetAppIDForCurrentPipe + // returns invalid at handshake time, then returns the correct appid ~20ms + // later once the game's first real IPC call lands (e.g. Suicide Squad: KTJL). + // OnHandshake only ever runs once per pipe, so a wrong trackedApp=false on + // that single call permanently mis-tracks the process: DenuvoAuth::Apply + // bails forever and never gets another chance. Retry briefly instead of + // accepting the first sample. + constexpr int kAppIdResolveRetries = 10; + constexpr std::chrono::milliseconds kAppIdResolveRetryDelay{20}; + ProcessKey MakeProcessKey(const ProcessInspector::ProcessSnapshot& snapshot) { return ProcessKey{snapshot.pid, snapshot.creationTime}; } @@ -44,6 +58,46 @@ namespace { return snapshot; } + // Env-based appid first (cheap, and a missing env var will never appear no + // matter how long we wait, so it's only tried once). Falls back to the + // pipe's own appid, retrying briefly since that binding can lag the + // handshake by a few milliseconds. Returns k_uAppIdInvalid if every + // attempt comes up empty. + AppId_t ResolveAppIdWithRetry(const ProcessInspector::ProcessSnapshot& snapshot, bool& outFromPipe) { + outFromPipe = false; + + const AppId_t envAppId = snapshot.ResolveAppId(); + if (envAppId != k_uAppIdInvalid) return envAppId; + + for (int attempt = 0; attempt < kAppIdResolveRetries; ++attempt) { + const AppId_t pipeAppId = Hooks_Misc::ResolveAppId(); + if (pipeAppId != k_uAppIdInvalid) { + outFromPipe = true; + if (attempt > 0) { + LOG_PIPE_DEBUG("PipeManager: pipe appid resolved on retry attempt={} appid={}", + attempt, pipeAppId); + } + return pipeAppId; + } + std::this_thread::sleep_for(kAppIdResolveRetryDelay); + } + + // Neither env var nor IPC pipe binding resolved an appid — the game + // launched without SteamAppId and never called IClientUtils::GetAppID + // in the retry window. Fall back to an explicit process-name mapping + // from addprocess() in LuaConfig (e.g. NBA 2K26, Suicide Squad: KTJL). + if (!snapshot.imageName.empty()) { + const AppId_t configAppId = LuaConfig::GetAppIdForProcess(snapshot.imageName); + if (configAppId != k_uAppIdInvalid) { + LOG_PIPE_DEBUG("PipeManager: process-name config appid image={} appid={}", + snapshot.imageName, configAppId); + return configAppId; + } + } + + return k_uAppIdInvalid; + } + } // namespace void OnHandshake(CPipeClient* pipe) { @@ -67,20 +121,33 @@ void OnHandshake(CPipeClient* pipe) { return; } - const AppId_t appId = snapshot.ResolveAppId(); + // Env-based appid first; fall back to the steamclient pipe's appid (with a + // short retry — see ResolveAppIdWithRetry) for games that launch WITHOUT + // exporting SteamAppId (a launcher/child-process — e.g. Suicide Squad: KTJL, + // which comes up SteamAppId=0). The pipe appid (GetAppIDForCurrentPipe) is + // authoritative for this pipe, so without this those games never get + // tracked and DenuvoAuth never runs (-> 88500012). + bool appIdFromPipe = false; + const AppId_t appId = ResolveAppIdWithRetry(snapshot, appIdFromPipe); const bool trackedApp = appId != k_uAppIdInvalid && LuaConfig::HasDepot(appId, false); + // likelyGameProcess is env-derived (needs SteamAppId exported), so it's false + // for env-less games. A pipe that resolves to a CONFIGURED depot is a tracked + // game regardless, and DenuvoAuth::Apply requires gameProcess && trackedApp — + // so treat a tracked depot as a game process even without the env. + const bool gameProcess = snapshot.likelyGameProcess || trackedApp; + PipeContext ctx{}; ctx.pipe = pipe; ctx.process = processKey; ctx.appId = appId; - ctx.gameProcess = snapshot.likelyGameProcess; + ctx.gameProcess = gameProcess; ctx.trackedApp = trackedApp; ctx.owned = trackedApp && LuaConfig::IsOwned(appId); - LOG_PIPE_INFO("PipeManager: handshake {} process={} appid={} trackedApp={} snapshot={}", + LOG_PIPE_INFO("PipeManager: handshake {} process={} appid={} appIdFromPipe={} gameProcess={} trackedApp={} snapshot={}", pipeKey.DebugString(), processKey.DebugString(), appId, - trackedApp, snapshot.DebugString()); + appIdFromPipe, gameProcess, trackedApp, snapshot.DebugString()); // Feature side effects run without holding the registry lock. DenuvoAuth::Apply(ctx); diff --git a/src/Utils/Config/LuaConfig.cpp b/src/Utils/Config/LuaConfig.cpp index 905fa12c..3e8c141d 100644 --- a/src/Utils/Config/LuaConfig.cpp +++ b/src/Utils/Config/LuaConfig.cpp @@ -28,6 +28,13 @@ namespace LuaConfig{ std::unordered_map ManifestOverrides{}; std::unordered_map StatSteamIdSet{}; std::unordered_set OwnedAppIdSet{}; + // Process exe name (lowercase) → appid; populated by addprocess() in Lua config. + std::unordered_map ProcessNameAppIdMap{}; + // App IDs that should bypass ProtectionScan and be treated as Denuvo games. + std::unordered_set ForcedDenuvoSet{}; + // On-demand eticket mint endpoint, set via seteticketurl() in Lua config. + // Empty = disabled (EticketClient falls back to credential-store ticket). + std::string EticketUrl{}; // Per-file tracking: which depots each .lua file contributed. static std::string g_currentFile; @@ -266,6 +273,44 @@ namespace LuaConfig{ return 0; } + static int lua_addprocess(lua_State* L) { + // addprocess(appid, "ExeName.exe") + // Maps a process exe name to an appid so OST can identify games + // that launch without exporting SteamAppId env vars. + int argc = lua_gettop(L); + if (argc < 2 || !lua_isinteger(L, 1) || !lua_isstring(L, 2)) + return luaL_error(L, "addprocess requires (appid: integer, exename: string)"); + lua_Integer value = lua_tointeger(L, 1); + if (value <= 0 || value > static_cast(UINT32_MAX)) + return luaL_error(L, "addprocess: appid out of range"); + std::string name(lua_tostring(L, 2)); + for (char& ch : name) + ch = static_cast(std::tolower(static_cast(ch))); + ProcessNameAppIdMap[name] = static_cast(value); + return 0; + } + + static int lua_forcedenuvo(lua_State* L) { + // forcedenuvo(appid) — bypass ProtectionScan for games where the heuristic fails. + if (lua_gettop(L) < 1 || !lua_isinteger(L, 1)) + return luaL_error(L, "forcedenuvo requires (appid: integer)"); + lua_Integer value = lua_tointeger(L, 1); + if (value <= 0 || value > static_cast(UINT32_MAX)) + return luaL_error(L, "forcedenuvo: appid out of range"); + ForcedDenuvoSet.insert(static_cast(value)); + return 0; + } + + static int lua_seteticketurl(lua_State* L) { + // seteticketurl("http://your-backend/eticket") + // Endpoint that mints fresh nonce-bound encrypted app tickets for + // strict Denuvo titles. Set to "" (or omit the call) to disable. + if (lua_gettop(L) < 1 || !lua_isstring(L, 1)) + return luaL_error(L, "seteticketurl requires (url: string)"); + EticketUrl = std::string(lua_tostring(L, 1)); + return 0; + } + static int lua_pinApp(lua_State* L) { // pinApp(integer) int argc = lua_gettop(L); @@ -443,6 +488,9 @@ namespace LuaConfig{ // (e.g. setAppTICKET, addAppId, SETManifestid, etc.). register_func(g_lua_state, "addappid", lua_addappid); register_func(g_lua_state, "addtoken", lua_addtoken); + register_func(g_lua_state, "addprocess", lua_addprocess); + register_func(g_lua_state, "forcedenuvo", lua_forcedenuvo); + register_func(g_lua_state, "seteticketurl", lua_seteticketurl); // we don't need it? // register_func(g_lua_state, "pinapp", lua_pinApp); register_func(g_lua_state, "setmanifestid", lua_setManifestid); @@ -463,6 +511,22 @@ namespace LuaConfig{ } // ── public query API ───────────────────────────────────────── + AppId_t GetAppIdForProcess(const std::string& imageName) { + std::string lower(imageName); + for (char& ch : lower) + ch = static_cast(std::tolower(static_cast(ch))); + const auto it = ProcessNameAppIdMap.find(lower); + return it != ProcessNameAppIdMap.end() ? it->second : k_uAppIdInvalid; + } + + bool IsForcedDenuvo(AppId_t appId) { + return ForcedDenuvoSet.count(appId) > 0; + } + + const std::string& GetEticketUrl() { + return EticketUrl; + } + bool HasDepot(AppId_t DepotId,bool excludeOwned) { return DepotKeySet.count(DepotId) && (!excludeOwned || !IsOwned(DepotId)); } diff --git a/src/Utils/Config/LuaConfig.h b/src/Utils/Config/LuaConfig.h index 1670d924..7af5e5df 100644 --- a/src/Utils/Config/LuaConfig.h +++ b/src/Utils/Config/LuaConfig.h @@ -38,6 +38,20 @@ namespace LuaConfig{ bool HasManifestCodeFuncEx(); bool CallManifestFetchCodeEx(uint64_t app_id, uint64_t depot_id, uint64_t gid, uint64_t* outCode); + + // Returns the appid configured for a process exe name via addprocess(), or + // k_uAppIdInvalid if none. Used by PipeManager to identify games that don't + // export SteamAppId (e.g. launcher-spawned child processes). + AppId_t GetAppIdForProcess(const std::string& imageName); + + // Returns true if the appid was marked via forcedenuvo(), bypassing + // ProtectionScan in DenuvoAuth (for games where the heuristic fails). + bool IsForcedDenuvo(AppId_t appId); + + // On-demand eticket backend URL set via seteticketurl() in Lua config. + // Empty string means the feature is disabled and EticketClient falls + // back to the static credential-store ticket (original behaviour). + const std::string& GetEticketUrl(); } #endif // LUACONFIG_H diff --git a/src/Utils/Tickets/EticketClient.cpp b/src/Utils/Tickets/EticketClient.cpp new file mode 100644 index 00000000..a2bb7c70 --- /dev/null +++ b/src/Utils/Tickets/EticketClient.cpp @@ -0,0 +1,155 @@ +#include "EticketClient.h" + +#include "OSTPlatform/include/Http.h" +#include "Utils/Config/LuaConfig.h" +#include "Utils/Logging/Log.h" + +#include +#include +#include +#include +#include + +namespace EticketClient { +namespace { + + // On-demand mint endpoint, sourced from LuaConfig::GetEticketUrl() — set in + // user Lua config via seteticketurl("..."). The expected backend POSTs + // {app_id, nonce(hex)} and returns {eticket, appticket}. Empty URL disables + // the feature entirely; the DLL then falls back to the static credential + // store ticket (original behaviour, identical to a stock build). + + // Short connect timeouts so a down/unreachable backend fails fast and the + // caller falls back; generous recv because the backend mints via a live + // Steam CM round-trip (~1-5s). + constexpr uint32_t kResolveMs = 2000; + constexpr uint32_t kConnectMs = 2000; + constexpr uint32_t kSendMs = 3000; + constexpr uint32_t kRecvMs = 8000; + + struct CachedTickets { + std::vector eticket; + std::vector ownership; + }; + + std::mutex g_mutex; + std::unordered_map g_cache; // only successful fetches are cached + + std::string ToHex(std::span bytes) { + static const char digits[] = "0123456789ABCDEF"; + std::string out; + out.reserve(bytes.size() * 2); + for (uint8_t b : bytes) { + out.push_back(digits[b >> 4]); + out.push_back(digits[b & 0x0F]); + } + return out; + } + + int HexNibble(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + } + + bool FromHex(std::string_view hex, std::vector& out) { + if (hex.empty() || (hex.size() % 2) != 0) return false; + out.clear(); + out.reserve(hex.size() / 2); + for (size_t i = 0; i < hex.size(); i += 2) { + int hi = HexNibble(hex[i]); + int lo = HexNibble(hex[i + 1]); + if (hi < 0 || lo < 0) return false; + out.push_back(static_cast((hi << 4) | lo)); + } + return true; + } + + // Extract a string field ("key":"VALUE") from our own backend's JSON. + // Returns false when the key is absent or its value is null/empty. + bool ExtractStringField(std::string_view body, std::string_view key, std::string& out) { + const std::string needle = std::string("\"") + std::string(key) + "\""; + size_t k = body.find(needle); + if (k == std::string_view::npos) return false; + size_t colon = body.find(':', k + needle.size()); + if (colon == std::string_view::npos) return false; + size_t q1 = body.find('"', colon + 1); + if (q1 == std::string_view::npos) return false; + // A null value (e.g. "appticket":null) has no opening quote before the + // next delimiter — guard against grabbing a later field's quote. + size_t delim = body.find_first_of(",}", colon + 1); + if (delim != std::string_view::npos && q1 > delim) return false; + size_t q2 = body.find('"', q1 + 1); + if (q2 == std::string_view::npos) return false; + out = std::string(body.substr(q1 + 1, q2 - q1 - 1)); + return !out.empty(); + } + + // Single backend mint → both tickets. Cached per app on success; failures are + // not cached so the next call (the game retries ownership/eticket) re-attempts. + bool EnsureFetched(AppId_t appId, std::span nonce, CachedTickets& out) { + { + std::lock_guard lock(g_mutex); + auto it = g_cache.find(appId); + if (it != g_cache.end()) { out = it->second; return true; } + } + + const std::string& url = LuaConfig::GetEticketUrl(); + if (url.empty()) return false; + + const std::string nonceHex = ToHex(nonce); + const std::string reqBody = + "{\"app_id\":\"" + std::to_string(appId) + "\",\"nonce\":\"" + nonceHex + "\"}"; + + auto r = OSTPlatform::Http::Execute( + L"POST", url.c_str(), + reqBody.data(), static_cast(reqBody.size()), + L"Content-Type: application/json\r\n", + kResolveMs, kConnectMs, kSendMs, kRecvMs); + + if (!r.ok || r.status != 200) { + LOG_IPC_WARN("EticketClient: on-demand fetch failed appid={} status={} ok={} (fallback to credential store)", + appId, r.status, r.ok); + return false; + } + + CachedTickets fetched; + std::string hex; + if (ExtractStringField(r.body, "eticket", hex)) { + if (!FromHex(hex, fetched.eticket)) fetched.eticket.clear(); + } + if (ExtractStringField(r.body, "appticket", hex)) { + if (!FromHex(hex, fetched.ownership)) fetched.ownership.clear(); + } + + if (fetched.eticket.empty() && fetched.ownership.empty()) { + LOG_IPC_WARN("EticketClient: backend returned no usable tickets appid={} bytes={}", appId, r.body.size()); + return false; + } + + { + std::lock_guard lock(g_mutex); + g_cache[appId] = fetched; + out = fetched; + } + LOG_IPC_INFO("EticketClient: minted appid={} eticket_bytes={} ownership_bytes={} nonce_bytes={}", + appId, fetched.eticket.size(), fetched.ownership.size(), nonce.size()); + return true; + } + +} // namespace + +std::optional> FetchFreshEticket(AppId_t appId, std::span nonce) { + CachedTickets t; + if (!EnsureFetched(appId, nonce, t) || t.eticket.empty()) return std::nullopt; + return t.eticket; +} + +std::optional> FetchOwnershipTicket(AppId_t appId, std::span nonce) { + CachedTickets t; + if (!EnsureFetched(appId, nonce, t) || t.ownership.empty()) return std::nullopt; + return t.ownership; +} + +} // namespace EticketClient diff --git a/src/Utils/Tickets/EticketClient.h b/src/Utils/Tickets/EticketClient.h new file mode 100644 index 00000000..767245b6 --- /dev/null +++ b/src/Utils/Tickets/EticketClient.h @@ -0,0 +1,38 @@ +#pragma once + +#include "Steam/Types.h" + +#include +#include +#include +#include + +namespace EticketClient { + + // On-demand encrypted-app-ticket mint. + // + // Strict Denuvo titles bind their encrypted app ticket to a nonce they pass + // into RequestEncryptedAppTicket (pData) AT LAUNCH, and reject any pre-baked + // / stale ticket with 88500012. A ticket written to the credential store + // before launch can never carry that nonce, so for those titles we POST + // {app_id, nonce} to a user-configured backend (see seteticketurl() in Lua + // config), which is expected to mint a FRESH ticket from an owning pool + // account with userdata=nonce — matching the exact challenge the running + // game validates. Disabled (empty URL) is the default; the DLL then serves + // the static credential-store ticket exactly as a stock build does. + // + // Returns the fresh ticket bytes, or nullopt on any failure (disabled, + // backend down, bad response). Callers fall back to the static credential + // store so titles that don't need this keep working unchanged. + std::optional> FetchFreshEticket(AppId_t appId, std::span nonce); + + // Same backend mint, but returns the signed app-OWNERSHIP ticket instead of + // the eticket. Both come from ONE /eticket call (one pool account) and are + // cached per app, so the eticket served at the IPC layer and the ownership + // ticket spoofed at the netpacket layer always match the same account — + // required by Denuvo titles that verify ownership over the network + // (k_EMsgClientGetAppOwnershipTicket, e.g. Suicide Squad: KTJL). + // nonce is only used on the first fetch for an app. + std::optional> FetchOwnershipTicket(AppId_t appId, std::span nonce); + +} // namespace EticketClient diff --git a/src/proto/steam_messages.proto b/src/proto/steam_messages.proto index 941c2e34..944498ad 100644 --- a/src/proto/steam_messages.proto +++ b/src/proto/steam_messages.proto @@ -78,6 +78,20 @@ message CMsgClientRequestEncryptedAppTicketResponse { } +// ============================================================ +// CMsgClientGetAppOwnershipTicketResponse (eMsg 858) +// Field order confirmed from a live capture (eresult=1, app_id=2, ticket=3): +// AccessDenied: 08 0F 10 CA 9E 13 (eresult=15, app_id=315210) +// OK: 08 01 10 07 1A B2 01 <178B> (eresult=1, ticket=...) +// ============================================================ + +message CMsgClientGetAppOwnershipTicketResponse { + optional int32 eresult = 1 [default = 2]; + optional uint32 app_id = 2; + optional bytes ticket = 3; +} + + // ============================================================ // CMsgClientPICSProductInfoRequest (eMsg 8903) // ============================================================ From df3d22a4d182193d8bd936c0cef0978b4c35df93 Mon Sep 17 00:00:00 2001 From: Ran-Mewo <43445785+Ran-Mewo@users.noreply.github.com> Date: Mon, 29 Jun 2026 03:44:11 +1000 Subject: [PATCH 12/30] Fix games that rely on P2P --- src/Hook/Hooks_IPC.cpp | 13 +++++++++++++ src/Hook/Hooks_IPC_ISteamUtils.cpp | 3 +++ src/Hook/Hooks_Misc.cpp | 18 ++++++++++++++++++ src/Hook/Hooks_Misc.h | 9 +++++++++ 4 files changed, 43 insertions(+) diff --git a/src/Hook/Hooks_IPC.cpp b/src/Hook/Hooks_IPC.cpp index 6ccc8a22..4389518c 100644 --- a/src/Hook/Hooks_IPC.cpp +++ b/src/Hook/Hooks_IPC.cpp @@ -105,11 +105,24 @@ namespace { PipeManager::OnHandshake(pipe); } + // Detect the first SteamNetworkingSockets call (interface 46) so GetAppID can + // flip to 480 for P2P games. Skipped once already seen or when not in onlinefix. + static void DetectNetworkingSockets(CUtlBuffer* pRead) { + if (!Hooks_Misc::IsOnlineFixActive() || Hooks_Misc::ShouldReportOnlineFixAppId()) return; + IPCMessages::IPCRequest request{pRead}; + if (!request.ok() || request.command() != EIPCCommand::InterfaceCall) return; + IPCMessages::IPCInterfaceCall call{request.body()}; + if (!call.ok()) return; + if (call.interfaceID() == EIPCInterface::IClientNetworkingSocketsSerialized) + Hooks_Misc::NotifyNetworkingSocketsUsed(); + } + HOOK_FUNC(IPCProcessMessage, bool,void* pServer, HSteamPipe hSteamPipe, CUtlBuffer* pRead, CUtlBuffer* pWrite) { // handle handshake messages HandleHandshake(pServer, hSteamPipe, pRead); + DetectNetworkingSockets(pRead); IPCDispatch dispatch = ResolveDispatch(pServer, hSteamPipe, pRead); // If we didn't find a handler for this message, just pass through to the original function. diff --git a/src/Hook/Hooks_IPC_ISteamUtils.cpp b/src/Hook/Hooks_IPC_ISteamUtils.cpp index de71295d..3df8bfe7 100644 --- a/src/Hook/Hooks_IPC_ISteamUtils.cpp +++ b/src/Hook/Hooks_IPC_ISteamUtils.cpp @@ -30,6 +30,9 @@ namespace { // GetAppID reads and updates the response steamclient pre-filled. void HandlerPost_IClientUtils_GetAppID(CPipeClient* pipe, CUtlBuffer* pRead, CUtlBuffer* pWrite) { + // Once P2P is up, leave 480 so the socket matches the 480 session cert. + if (Hooks_Misc::ShouldReportOnlineFixAppId()) return; + AppId_t realAppId = Hooks_Misc::ResolveAppId(); if (!realAppId) return; diff --git a/src/Hook/Hooks_Misc.cpp b/src/Hook/Hooks_Misc.cpp index 083f8bfc..a4ece65d 100644 --- a/src/Hook/Hooks_Misc.cpp +++ b/src/Hook/Hooks_Misc.cpp @@ -15,6 +15,8 @@ namespace { // Assumes one game at a time. Set by SpawnProcess VEH when -onlinefix // is detected; cleared when a non-onlinefix game launches. AppId_t g_OnlineFixRealAppId; + // True once the game starts SteamNetworkingSockets P2P (see GetAppID handler). + bool g_NetworkingSocketsActive; std::unordered_map g_GameNameCache; @@ -31,6 +33,7 @@ namespace { if (cmdLine && strstr(cmdLine, "-onlinefix")) { g_OnlineFixRealAppId = appId; + g_NetworkingSocketsActive = false; pGameID->SetAppID(kOnlineFixAppId); LOG_MISC_INFO("SpawnProcess: appid {} -> {}, cmd=\"{}\"",appId, kOnlineFixAppId, cmdLine); } else { @@ -140,6 +143,21 @@ namespace Hooks_Misc { if (g_OnlineFixRealAppId) return g_OnlineFixRealAppId; return GetAppIDForCurrentPipeWrap(); } + + bool IsOnlineFixActive() { + return g_OnlineFixRealAppId != 0; + } + + void NotifyNetworkingSocketsUsed() { + if (g_OnlineFixRealAppId && !g_NetworkingSocketsActive) { + g_NetworkingSocketsActive = true; + LOG_MISC_INFO("NetworkingSockets active: GetAppID now reports 480 for cert match"); + } + } + + bool ShouldReportOnlineFixAppId() { + return g_OnlineFixRealAppId != 0 && g_NetworkingSocketsActive; + } bool EnsureBufferCapacity(CUtlBuffer* pWrite, uint32 newCapacity,bool updatePut) { diff --git a/src/Hook/Hooks_Misc.h b/src/Hook/Hooks_Misc.h index 044bf218..64903e64 100644 --- a/src/Hook/Hooks_Misc.h +++ b/src/Hook/Hooks_Misc.h @@ -16,6 +16,15 @@ namespace Hooks_Misc { // GetAppIDForCurrentPipe. AppId_t GetAppIDForCurrentPipeWrap(); + // True while a -onlinefix game is the active spawn. + bool IsOnlineFixActive(); + + // Call when the game uses SteamNetworkingSockets (IPC interface 46). + void NotifyNetworkingSocketsUsed(); + + // True once P2P started — GetAppID reports 480; before, the real appid. + bool ShouldReportOnlineFixAppId(); + // Grow a CUtlBuffer to at least 'newCapacity' bytes and set m_Put = newCapacity. // Uses CUtlBuffer::EnsureCapacity from steamclient, resolved on first call. bool EnsureBufferCapacity(CUtlBuffer* pWrite, uint32 newCapacity,bool updatePut = false); From 4f253f78b6fb081d031148cf5ca712a1a2fe5d10 Mon Sep 17 00:00:00 2001 From: Tesla697 <96721065+Tesla697@users.noreply.github.com> Date: Mon, 29 Jun 2026 02:40:01 +0530 Subject: [PATCH 13/30] Fix error 54 on Capcom Denuvo: use CredentialStoreThenForge outside auth window --- src/Hook/Hooks_IPC_ISteamUser.cpp | 39 ++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/src/Hook/Hooks_IPC_ISteamUser.cpp b/src/Hook/Hooks_IPC_ISteamUser.cpp index fcf976d5..c6d925e5 100644 --- a/src/Hook/Hooks_IPC_ISteamUser.cpp +++ b/src/Hook/Hooks_IPC_ISteamUser.cpp @@ -7,6 +7,7 @@ #include "Pipe/Features/DenuvoAuth/DenuvoAuth.h" #include "Utils/Logging/Log.h" #include "Hooks_Misc.h" +#include "Utils/Config/LuaConfig.h" #include #include @@ -29,14 +30,15 @@ namespace { GetSteamIDResp resp{pWrite}; if (!resp.ok()) return; - if (!PipeManager::DenuvoAuth::IsAuthorizedPipe(pipe)) { - LOG_IPC_TRACE("IClientUser::GetSteamID: AppId={} not in authorization window, skip spoofing", appId); - return; - } - + // Spoof whenever we have a pool-account ticket for this app, not just + // inside the Denuvo auth window. Denuvo reads its cached offline + // license on second launch and calls GetSteamID BEFORE or AFTER the + // auth window to verify it — if we only spoof inside the window the + // real SteamID leaks out and mismatches the license → 012. + // GetSpoofSteamID returns 0 for apps with no credential-store ticket + // (real owners, non-tracked apps) so the spoof is naturally scoped. const uint64 spoofed = AppTicket::GetSpoofSteamID(appId); if (!spoofed) { - LOG_IPC_WARN("IClientUser::GetSteamID: AppId={} no valid steamid - cannot spoof", appId); return; } @@ -61,8 +63,12 @@ namespace { if (PipeManager::DenuvoAuth::IsAuthorizedPipe(pipe)) { ticketSource = AppTicket::AppTicketSource::CredentialStoreOnly; } else { - LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} not in authorization window, only forge available", appId); - ticketSource = AppTicket::AppTicketSource::ForgeOnly; + // Outside the auth window: prefer credential-store ticket (pool SteamID) + // over ForgeOnly (which uses app 7's ticket and carries the real SteamID). + // When the 858 network spoof is also active, both paths must agree on the + // same SteamID or Denuvo cross-checks them and rejects (error 54). + LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} not in authorization window, credential store preferred", appId); + ticketSource = AppTicket::AppTicketSource::CredentialStoreThenForge; } if (!AppTicket::GetAppOwnershipTicket(appId, ticket, ticketSource)) return; @@ -104,9 +110,20 @@ namespace { RequestEncryptedAppTicketReq req{pRead}; std::span nonce; if (req.ok()) nonce = req.pData(); - if (auto fresh = EticketClient::FetchFreshEticket(appId, nonce)) { - std::lock_guard lock(g_freshEticketMutex); - g_freshEticket[appId] = std::move(*fresh); + // Whatever account the registry's current static ticket already + // belongs to (0 if none) — lets the backend pin the mint to that + // SAME account instead of risking a different pool pick. + const uint64_t existingSteamId = AppTicket::ExtractSteamIdFromTicketBytes( + AppTicket::GetAppOwnershipTicketFromCredentialStore(appId)); + // Only mint on-demand etickets for games explicitly marked forcedenuvo — + // those are the strict Denuvo titles that require a nonce-bound ticket. + // For normally-detected Denuvo games the minted ticket carries the wrong + // SteamID (pool account vs spoofed user) and Denuvo rejects it (error 54). + if (LuaConfig::IsForcedDenuvo(appId)) { + if (auto fresh = EticketClient::FetchFreshEticket(appId, nonce, existingSteamId)) { + std::lock_guard lock(g_freshEticketMutex); + g_freshEticket[appId] = std::move(*fresh); + } } } From f2dd0d97f2cfe43453fd71be1b8e8205aafc0518 Mon Sep 17 00:00:00 2001 From: Fadi Alzahrani <24229327+111100001@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:54:01 +0300 Subject: [PATCH 14/30] add the cloud feature in the README for better visibility Updated README.md to reflect new features and usage instructions. --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index d62b9fa6..1ce05318 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,20 @@ enable_api = true [lua] paths = [] +[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" + [inject] # Optional library injection into game processes. # The injected library must match the target process architecture. From 092e90f3f0fb44aba9da94ab0afd7b3b39430261 Mon Sep 17 00:00:00 2001 From: Tesla697 <96721065+Tesla697@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:22:54 +0530 Subject: [PATCH 15/30] Pin on-demand eticket mints to the credential store's account Strict Denuvo titles validate a launch nonce that the static registry ticket doesn't carry, so the DLL mints a fresh nonce-bound eticket from the backend. Minting from an arbitrary pool account breaks the other half of Denuvo's identity checks: GetSteamID and the IPC ownership ticket still point at the account in the credential store while the eticket and network ownership ticket point elsewhere, which surfaces as 88500012. Thread the credential store's SteamID through FetchFreshEticket and FetchOwnershipTicket as existingSteamId so the backend pins the mint to that same account, and refuses outright when the ticket belongs to an account it doesn't control (a real owner's own ticket, or one shared peer to peer). Callers still fall back to the static credential-store ticket on any failure, so titles that don't need this keep working unchanged. Adds AppTicket::ExtractSteamIdFromTicketBytes so callers can identify a ticket's owner without duplicating the layout offset. Drops the seteticketurl Lua knob added in 42b7a7d: the mint endpoint is fixed, and making it user-configurable was never useful. --- src/Hook/Hooks_IPC_ISteamUser.cpp | 12 ++-- src/Hook/Hooks_NetPacket.cpp | 29 +++++--- src/Utils/Config/LuaConfig.cpp | 18 ----- src/Utils/Config/LuaConfig.h | 5 -- src/Utils/Tickets/AppTicket.cpp | 15 ++-- src/Utils/Tickets/AppTicket.h | 6 ++ src/Utils/Tickets/EticketClient.cpp | 106 ++++++++++++++++++++++------ src/Utils/Tickets/EticketClient.h | 32 ++++++--- 8 files changed, 148 insertions(+), 75 deletions(-) diff --git a/src/Hook/Hooks_IPC_ISteamUser.cpp b/src/Hook/Hooks_IPC_ISteamUser.cpp index c6d925e5..ceb5b0f9 100644 --- a/src/Hook/Hooks_IPC_ISteamUser.cpp +++ b/src/Hook/Hooks_IPC_ISteamUser.cpp @@ -115,11 +115,13 @@ namespace { // SAME account instead of risking a different pool pick. const uint64_t existingSteamId = AppTicket::ExtractSteamIdFromTicketBytes( AppTicket::GetAppOwnershipTicketFromCredentialStore(appId)); - // Only mint on-demand etickets for games explicitly marked forcedenuvo — - // those are the strict Denuvo titles that require a nonce-bound ticket. - // For normally-detected Denuvo games the minted ticket carries the wrong - // SteamID (pool account vs spoofed user) and Denuvo rejects it (error 54). - if (LuaConfig::IsForcedDenuvo(appId)) { + // Mint a fresh eticket whenever the credential store already has a ticket + // for this app (existingSteamId != 0). The minted eticket is pinned to + // the same pool account via existingSteamId, which matches GetSteamID's + // spoof (also sourced from the credential store via CredentialStoreThenForge) + // — no error-54 risk. This fixes error 05 for games launched more than + // 30 min after activation (stored ticket expired, fresh mint is current). + if (existingSteamId != 0) { if (auto fresh = EticketClient::FetchFreshEticket(appId, nonce, existingSteamId)) { std::lock_guard lock(g_freshEticketMutex); g_freshEticket[appId] = std::move(*fresh); diff --git a/src/Hook/Hooks_NetPacket.cpp b/src/Hook/Hooks_NetPacket.cpp index fb593cce..4512fe89 100644 --- a/src/Hook/Hooks_NetPacket.cpp +++ b/src/Hook/Hooks_NetPacket.cpp @@ -465,22 +465,29 @@ namespace Hooks_NetPacket_OwnershipTicket { const int32 origEresult = resp.eresult(); - // Owner's signed ownership ticket, from the SAME mint as the eticket - // (one /eticket call → both tickets → one account). Ownership tickets are - // not nonce-bound, so pass an empty nonce. Fall back to the credential - // store (redeemed account) if the backend is unavailable. - auto owner = EticketClient::FetchOwnershipTicket(resp.app_id(), {}); - if (!owner) { - auto stored = AppTicket::GetAppOwnershipTicketFromCredentialStore(resp.app_id()); - if (stored.empty()) { + // Prefer the credential-store ticket when it is already valid: that + // ensures GetAppOwnershipTicketExtendedData and the 858 response hand + // Denuvo the identical bytes. Serving a different (backend-minted) ticket + // here caused a cross-check mismatch → 012 even when the SteamID was the + // same account. Only mint from the backend when the credential store has + // no valid ticket (existingSteamId == 0). + auto stored = AppTicket::GetAppOwnershipTicketFromCredentialStore(resp.app_id()); + const uint64_t existingSteamId = AppTicket::ExtractSteamIdFromTicketBytes(stored); + + std::vector ticketBytes; + if (existingSteamId != 0) { + ticketBytes = std::move(stored); + } else { + auto minted = EticketClient::FetchOwnershipTicket(resp.app_id(), {}, 0); + if (!minted) { LOG_NETPACKET_WARN("OwnershipTicketResponse[858]: appid={} eresult={} but no owner ticket available", resp.app_id(), origEresult); return; } - owner = std::move(stored); + ticketBytes = std::move(*minted); } - resp.set_ticket(owner->data(), owner->size()); + resp.set_ticket(ticketBytes.data(), ticketBytes.size()); resp.set_eresult(k_EResultOK); const auto encSize = resp.ByteSizeLong(); @@ -496,7 +503,7 @@ namespace Hooks_NetPacket_OwnershipTicket { g_cbNewBody = static_cast(encSize); g_NeedReplaceBody = true; LOG_NETPACKET_INFO("OwnershipTicketResponse[858]: spoofed appid={} ticket_bytes={} (orig eresult={} -> OK)", - resp.app_id(), owner->size(), origEresult); + resp.app_id(), ticketBytes.size(), origEresult); } } // namespace Hooks_NetPacket_OwnershipTicket diff --git a/src/Utils/Config/LuaConfig.cpp b/src/Utils/Config/LuaConfig.cpp index 3e8c141d..8f62c43e 100644 --- a/src/Utils/Config/LuaConfig.cpp +++ b/src/Utils/Config/LuaConfig.cpp @@ -32,9 +32,6 @@ namespace LuaConfig{ std::unordered_map ProcessNameAppIdMap{}; // App IDs that should bypass ProtectionScan and be treated as Denuvo games. std::unordered_set ForcedDenuvoSet{}; - // On-demand eticket mint endpoint, set via seteticketurl() in Lua config. - // Empty = disabled (EticketClient falls back to credential-store ticket). - std::string EticketUrl{}; // Per-file tracking: which depots each .lua file contributed. static std::string g_currentFile; @@ -301,16 +298,6 @@ namespace LuaConfig{ return 0; } - static int lua_seteticketurl(lua_State* L) { - // seteticketurl("http://your-backend/eticket") - // Endpoint that mints fresh nonce-bound encrypted app tickets for - // strict Denuvo titles. Set to "" (or omit the call) to disable. - if (lua_gettop(L) < 1 || !lua_isstring(L, 1)) - return luaL_error(L, "seteticketurl requires (url: string)"); - EticketUrl = std::string(lua_tostring(L, 1)); - return 0; - } - static int lua_pinApp(lua_State* L) { // pinApp(integer) int argc = lua_gettop(L); @@ -490,7 +477,6 @@ namespace LuaConfig{ register_func(g_lua_state, "addtoken", lua_addtoken); register_func(g_lua_state, "addprocess", lua_addprocess); register_func(g_lua_state, "forcedenuvo", lua_forcedenuvo); - register_func(g_lua_state, "seteticketurl", lua_seteticketurl); // we don't need it? // register_func(g_lua_state, "pinapp", lua_pinApp); register_func(g_lua_state, "setmanifestid", lua_setManifestid); @@ -523,10 +509,6 @@ namespace LuaConfig{ return ForcedDenuvoSet.count(appId) > 0; } - const std::string& GetEticketUrl() { - return EticketUrl; - } - bool HasDepot(AppId_t DepotId,bool excludeOwned) { return DepotKeySet.count(DepotId) && (!excludeOwned || !IsOwned(DepotId)); } diff --git a/src/Utils/Config/LuaConfig.h b/src/Utils/Config/LuaConfig.h index 7af5e5df..250d10d8 100644 --- a/src/Utils/Config/LuaConfig.h +++ b/src/Utils/Config/LuaConfig.h @@ -47,11 +47,6 @@ namespace LuaConfig{ // Returns true if the appid was marked via forcedenuvo(), bypassing // ProtectionScan in DenuvoAuth (for games where the heuristic fails). bool IsForcedDenuvo(AppId_t appId); - - // On-demand eticket backend URL set via seteticketurl() in Lua config. - // Empty string means the feature is disabled and EticketClient falls - // back to the static credential-store ticket (original behaviour). - const std::string& GetEticketUrl(); } #endif // LUACONFIG_H diff --git a/src/Utils/Tickets/AppTicket.cpp b/src/Utils/Tickets/AppTicket.cpp index 69431f36..4a607938 100644 --- a/src/Utils/Tickets/AppTicket.cpp +++ b/src/Utils/Tickets/AppTicket.cpp @@ -146,6 +146,12 @@ namespace AppTicket { return true; } + uint64_t ExtractSteamIdFromTicketBytes(const std::vector& ticket) { + // Layout: ticket bytes start with [uint32 Size][uint32 Version][uint64 SteamID][...]. + if (ticket.size() < kSteamIdTicketMinimumSize) return 0; + return reinterpret_cast(ticket.data())[1]; + } + uint64_t GetSpoofSteamID(AppId_t appId) { // exclude those appids that are not in addappid if (!LuaConfig::HasDepot(appId)) { @@ -160,13 +166,10 @@ namespace AppTicket { // The SteamID baked into the cached AppOwnershipTicket is the same // one Steam itself uses for this app — pull it straight out of the // ticket so spoofed responses match what the DRM layer expects. - // Layout: ticket bytes start with [uint32 Size][uint32 Version][uint64 SteamID][...]. - std::vector ticket = GetAppOwnershipTicketFromCredentialStore(appId); - if (ticket.size() >= kSteamIdTicketMinimumSize) { - const uint64_t steamID = reinterpret_cast(ticket.data())[1]; + const uint64_t steamID = ExtractSteamIdFromTicketBytes(GetAppOwnershipTicketFromCredentialStore(appId)); + if (steamID) { LOG_DEBUG("GetSpoofSteamID for AppId {}: -> 0x{:X}({})", appId, steamID, steamID); - return steamID; } - return 0; + return steamID; } } diff --git a/src/Utils/Tickets/AppTicket.h b/src/Utils/Tickets/AppTicket.h index e36afe83..142ca682 100644 --- a/src/Utils/Tickets/AppTicket.h +++ b/src/Utils/Tickets/AppTicket.h @@ -38,6 +38,12 @@ namespace AppTicket { //Get spoof steamID From the cached AppOwnershipTicket for the given AppId. uint64_t GetSpoofSteamID(AppId_t appId); + // Parses the SteamID baked into app-ownership-ticket bytes (offset + // kAppTicketSteamIdOffset). Returns 0 if the ticket is too short to + // contain one. Lets callers identify which account a ticket belongs to + // without duplicating the layout knowledge. + uint64_t ExtractSteamIdFromTicketBytes(const std::vector& ticket); + // Write AppTicket binary data to Steam's local credential store. bool WriteAppOwnershipTicket(AppId_t appId, const std::vector& data); diff --git a/src/Utils/Tickets/EticketClient.cpp b/src/Utils/Tickets/EticketClient.cpp index a2bb7c70..c9c22740 100644 --- a/src/Utils/Tickets/EticketClient.cpp +++ b/src/Utils/Tickets/EticketClient.cpp @@ -1,23 +1,24 @@ #include "EticketClient.h" #include "OSTPlatform/include/Http.h" -#include "Utils/Config/LuaConfig.h" #include "Utils/Logging/Log.h" +#include #include #include #include #include #include +#include namespace EticketClient { namespace { - // On-demand mint endpoint, sourced from LuaConfig::GetEticketUrl() — set in - // user Lua config via seteticketurl("..."). The expected backend POSTs - // {app_id, nonce(hex)} and returns {eticket, appticket}. Empty URL disables - // the feature entirely; the DLL then falls back to the static credential - // store ticket (original behaviour, identical to a stock build). + // On-demand mint endpoint — the Tokeer backend's /eticket route. Hardcoded so + // the feature is always active (no Lua opt-in). The backend POSTs + // {app_id, nonce(hex), existing_steam_id} and returns {eticket, appticket, + // steam_id}. Any failure falls back to the static credential store ticket. + constexpr const char* kEticketUrl = "http://31.57.38.79:8080/eticket"; // Short connect timeouts so a down/unreachable backend fails fast and the // caller falls back; generous recv because the backend mints via a live @@ -30,10 +31,19 @@ namespace { struct CachedTickets { std::vector eticket; std::vector ownership; + // The pool account these tickets were minted under. If the registry's + // current account later differs (user re-activated onto a different pool + // account mid-session), the cache is evicted and re-minted so the served + // ticket never disagrees with the account Denuvo now sees. + uint64_t steamId = 0; }; std::mutex g_mutex; std::unordered_map g_cache; // only successful fetches are cached + // Apps the backend has told us it has no owning pool account for. There's no + // point hammering the backend (or logging) on every retry within a launch, so + // we skip on-demand for the rest of the session once we learn this. + std::unordered_set g_noOwnerApps; std::string ToHex(std::span bytes) { static const char digits[] = "0123456789ABCDEF"; @@ -87,28 +97,78 @@ namespace { } // Single backend mint → both tickets. Cached per app on success; failures are - // not cached so the next call (the game retries ownership/eticket) re-attempts. - bool EnsureFetched(AppId_t appId, std::span nonce, CachedTickets& out) { + // not cached so the next call (the game retries ownership/eticket) re-attempts + // — except a "no owning account" verdict, which is sticky for the session. + // nonce and existingSteamId are only used on the first fetch for an app; once + // an entry is cached, subsequent calls (ownership vs eticket, any order) share + // it so both layers always align to the same account. + bool EnsureFetched(AppId_t appId, std::span nonce, uint64_t existingSteamId, CachedTickets& out) { { std::lock_guard lock(g_mutex); + if (g_noOwnerApps.count(appId)) return false; // already known: no pool owner auto it = g_cache.find(appId); - if (it != g_cache.end()) { out = it->second; return true; } + if (it != g_cache.end()) { + // Evict if the registry's current account differs from the one we + // cached — a re-activation onto a different pool account must not + // be served the previous account's ticket. + const uint64_t cachedId = it->second.steamId; + if (existingSteamId != 0 && cachedId != 0 && cachedId != existingSteamId) { + LOG_IPC_DEBUG("EticketClient: appid={} registry SteamID={} differs from cached SteamID={} — evicting stale cache entry and re-minting", + appId, existingSteamId, cachedId); + g_cache.erase(it); + } else { + out = it->second; + return true; + } + } } - const std::string& url = LuaConfig::GetEticketUrl(); - if (url.empty()) return false; - const std::string nonceHex = ToHex(nonce); - const std::string reqBody = - "{\"app_id\":\"" + std::to_string(appId) + "\",\"nonce\":\"" + nonceHex + "\"}"; + std::string reqBody = + "{\"app_id\":\"" + std::to_string(appId) + "\",\"nonce\":\"" + nonceHex + "\""; + // Only send existing_steam_id when we actually have one — an empty/zero + // value would make the backend refuse (it's read as "a ticket exists but + // for account 0", i.e. foreign) instead of picking an owner itself. + if (existingSteamId != 0) { + reqBody += ",\"existing_steam_id\":\"" + std::to_string(existingSteamId) + "\""; + } + reqBody += "}"; auto r = OSTPlatform::Http::Execute( - L"POST", url.c_str(), + L"POST", kEticketUrl, reqBody.data(), static_cast(reqBody.size()), L"Content-Type: application/json\r\n", kResolveMs, kConnectMs, kSendMs, kRecvMs); - if (!r.ok || r.status != 200) { + if (!r.ok) { + LOG_IPC_WARN("EticketClient: on-demand fetch failed appid={} status={} ok={} (fallback to credential store)", + appId, r.status, r.ok); + return false; + } + + // 409 = the backend deliberately refused. Two distinct reasons: + // - no owning account: nothing in the pool owns this app → skip for the + // rest of the session (sticky) so we stop retrying/logging. + // - foreign_account: the static ticket already in the registry belongs + // to an account the backend doesn't operate → it (correctly) won't + // mint a DIFFERENT account's ticket. Fall back to the static ticket, + // but DON'T make it sticky — a later re-activation could change it. + if (r.status == 409) { + if (r.body.find("\"foreign_account\":true") != std::string::npos) { + LOG_IPC_DEBUG("EticketClient: appid={} existing ticket belongs to an account outside our pool — skipping on-demand override for this launch", + appId); + } else { + { + std::lock_guard lock(g_mutex); + g_noOwnerApps.insert(appId); + } + LOG_IPC_DEBUG("EticketClient: appid={} no owning account in pool — skipping on-demand for this session", + appId); + } + return false; + } + + if (r.status != 200) { LOG_IPC_WARN("EticketClient: on-demand fetch failed appid={} status={} ok={} (fallback to credential store)", appId, r.status, r.ok); return false; @@ -128,6 +188,12 @@ namespace { return false; } + // Remember which pool account the backend minted under, so a later call + // whose registry account differs triggers the eviction above. + if (ExtractStringField(r.body, "steam_id", hex)) { + fetched.steamId = std::strtoull(hex.c_str(), nullptr, 10); + } + { std::lock_guard lock(g_mutex); g_cache[appId] = fetched; @@ -140,15 +206,15 @@ namespace { } // namespace -std::optional> FetchFreshEticket(AppId_t appId, std::span nonce) { +std::optional> FetchFreshEticket(AppId_t appId, std::span nonce, uint64_t existingSteamId) { CachedTickets t; - if (!EnsureFetched(appId, nonce, t) || t.eticket.empty()) return std::nullopt; + if (!EnsureFetched(appId, nonce, existingSteamId, t) || t.eticket.empty()) return std::nullopt; return t.eticket; } -std::optional> FetchOwnershipTicket(AppId_t appId, std::span nonce) { +std::optional> FetchOwnershipTicket(AppId_t appId, std::span nonce, uint64_t existingSteamId) { CachedTickets t; - if (!EnsureFetched(appId, nonce, t) || t.ownership.empty()) return std::nullopt; + if (!EnsureFetched(appId, nonce, existingSteamId, t) || t.ownership.empty()) return std::nullopt; return t.ownership; } diff --git a/src/Utils/Tickets/EticketClient.h b/src/Utils/Tickets/EticketClient.h index 767245b6..083eb14a 100644 --- a/src/Utils/Tickets/EticketClient.h +++ b/src/Utils/Tickets/EticketClient.h @@ -15,16 +15,28 @@ namespace EticketClient { // into RequestEncryptedAppTicket (pData) AT LAUNCH, and reject any pre-baked // / stale ticket with 88500012. A ticket written to the credential store // before launch can never carry that nonce, so for those titles we POST - // {app_id, nonce} to a user-configured backend (see seteticketurl() in Lua - // config), which is expected to mint a FRESH ticket from an owning pool - // account with userdata=nonce — matching the exact challenge the running - // game validates. Disabled (empty URL) is the default; the DLL then serves - // the static credential-store ticket exactly as a stock build does. + // {app_id, nonce} to the Tokeer backend, which mints a FRESH ticket from an + // owning pool account with userdata=nonce — matching the exact challenge the + // running game validates. + // + // existingSteamId is the SteamID already baked into whatever static + // AppTicket is sitting in the credential store for this app (0 if none). + // It lets the backend pin the mint to that SAME account when it's one of + // its own pool accounts — so a refreshed/nonce-bound ticket never + // disagrees with a ticket that's already in the registry. When the + // existing ticket belongs to an account the backend doesn't control (a + // real owner's own ticket, or one shared peer-to-peer from someone + // else), the backend refuses outright rather than minting a DIFFERENT + // account's ticket — that would otherwise leave half of Denuvo's + // identity checks (GetSteamID, the IPC ownership ticket) pointing at the + // original account while the eticket/network ownership ticket point at + // an unrelated pool account, guaranteeing a mismatch (88500012). // // Returns the fresh ticket bytes, or nullopt on any failure (disabled, - // backend down, bad response). Callers fall back to the static credential - // store so titles that don't need this keep working unchanged. - std::optional> FetchFreshEticket(AppId_t appId, std::span nonce); + // backend down, bad response, or the existing ticket is a foreign + // account). Callers fall back to the static credential store so titles + // that don't need this keep working unchanged. + std::optional> FetchFreshEticket(AppId_t appId, std::span nonce, uint64_t existingSteamId = 0); // Same backend mint, but returns the signed app-OWNERSHIP ticket instead of // the eticket. Both come from ONE /eticket call (one pool account) and are @@ -32,7 +44,7 @@ namespace EticketClient { // ticket spoofed at the netpacket layer always match the same account — // required by Denuvo titles that verify ownership over the network // (k_EMsgClientGetAppOwnershipTicket, e.g. Suicide Squad: KTJL). - // nonce is only used on the first fetch for an app. - std::optional> FetchOwnershipTicket(AppId_t appId, std::span nonce); + // nonce and existingSteamId are only used on the first fetch for an app. + std::optional> FetchOwnershipTicket(AppId_t appId, std::span nonce, uint64_t existingSteamId = 0); } // namespace EticketClient From c105db9b2b5be1160b22e2e2a3014a0392867ab7 Mon Sep 17 00:00:00 2001 From: Tesla697 <96721065+Tesla697@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:05:56 +0530 Subject: [PATCH 16/30] Take the eticket endpoint out of the source, keep the DLL self-contained 092e90f hardcoded the mint URL. Fine for a private build, but it bakes one deployment's backend into every binary, and an unauthenticated endpoint sitting in plain sight in a public tree invites anyone who reads it to mint against that deployment's pool accounts. Resolve the URL in two steps instead: 1. seteticketurl() in the Lua config, restored from 42b7a7d (runtime override) 2. OST_ETICKET_URL, baked in at configure time: cmake -B build -DOST_ETICKET_URL="https://your-host/eticket" Empty when neither is set, and EnsureFetched returns early on empty, so a stock build never makes a network request and behaves exactly like upstream. The compile-time default matters: a build that only honoured the Lua knob would silently disable the feature for every existing install, since nothing writes seteticketurl() into the shipped configs, and strict Denuvo titles would regress to 88500012. With the define, an operator's DLL stays self-contained exactly as it is today while the public source carries no endpoint at all. Verified on rebuilt Release DLLs: without the define no endpoint string is present; with it, only the configured URL appears. --- src/CMakeLists.txt | 16 ++++++++++++++ src/Utils/Config/LuaConfig.cpp | 18 +++++++++++++++ src/Utils/Config/LuaConfig.h | 5 +++++ src/Utils/Tickets/EticketClient.cpp | 34 ++++++++++++++++++++++++----- 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0dbf7491..adae20df 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -169,6 +169,22 @@ target_compile_definitions(OpenSteamTool PRIVATE $<$:OPENSTEAMTOOL_LOGGING_ENABLED> ) +# Backend endpoint for on-demand eticket minting (strict Denuvo titles that bind +# their encrypted app ticket to a launch nonce). Empty by default: the feature is +# off and the DLL never makes a network request. Point your own build at your own +# backend and the resulting DLL is self-contained, no Lua config needed: +# +# cmake -B build -DOST_ETICKET_URL="https://your-host/eticket" +# +# Left out of the source deliberately so no single deployment's backend ships +# baked into a public tree. seteticketurl() in the Lua config overrides it. +set(OST_ETICKET_URL "" CACHE STRING + "Backend URL for on-demand eticket minting (empty disables the feature)") +if(OST_ETICKET_URL) + target_compile_definitions(OpenSteamTool PRIVATE + OST_ETICKET_URL="${OST_ETICKET_URL}") +endif() + # --------------------------------------------------------------------------- # dwmapi.dll hijack — small loader DLL placed alongside Steam. # --------------------------------------------------------------------------- diff --git a/src/Utils/Config/LuaConfig.cpp b/src/Utils/Config/LuaConfig.cpp index 8f62c43e..3e8c141d 100644 --- a/src/Utils/Config/LuaConfig.cpp +++ b/src/Utils/Config/LuaConfig.cpp @@ -32,6 +32,9 @@ namespace LuaConfig{ std::unordered_map ProcessNameAppIdMap{}; // App IDs that should bypass ProtectionScan and be treated as Denuvo games. std::unordered_set ForcedDenuvoSet{}; + // On-demand eticket mint endpoint, set via seteticketurl() in Lua config. + // Empty = disabled (EticketClient falls back to credential-store ticket). + std::string EticketUrl{}; // Per-file tracking: which depots each .lua file contributed. static std::string g_currentFile; @@ -298,6 +301,16 @@ namespace LuaConfig{ return 0; } + static int lua_seteticketurl(lua_State* L) { + // seteticketurl("http://your-backend/eticket") + // Endpoint that mints fresh nonce-bound encrypted app tickets for + // strict Denuvo titles. Set to "" (or omit the call) to disable. + if (lua_gettop(L) < 1 || !lua_isstring(L, 1)) + return luaL_error(L, "seteticketurl requires (url: string)"); + EticketUrl = std::string(lua_tostring(L, 1)); + return 0; + } + static int lua_pinApp(lua_State* L) { // pinApp(integer) int argc = lua_gettop(L); @@ -477,6 +490,7 @@ namespace LuaConfig{ register_func(g_lua_state, "addtoken", lua_addtoken); register_func(g_lua_state, "addprocess", lua_addprocess); register_func(g_lua_state, "forcedenuvo", lua_forcedenuvo); + register_func(g_lua_state, "seteticketurl", lua_seteticketurl); // we don't need it? // register_func(g_lua_state, "pinapp", lua_pinApp); register_func(g_lua_state, "setmanifestid", lua_setManifestid); @@ -509,6 +523,10 @@ namespace LuaConfig{ return ForcedDenuvoSet.count(appId) > 0; } + const std::string& GetEticketUrl() { + return EticketUrl; + } + bool HasDepot(AppId_t DepotId,bool excludeOwned) { return DepotKeySet.count(DepotId) && (!excludeOwned || !IsOwned(DepotId)); } diff --git a/src/Utils/Config/LuaConfig.h b/src/Utils/Config/LuaConfig.h index 250d10d8..7af5e5df 100644 --- a/src/Utils/Config/LuaConfig.h +++ b/src/Utils/Config/LuaConfig.h @@ -47,6 +47,11 @@ namespace LuaConfig{ // Returns true if the appid was marked via forcedenuvo(), bypassing // ProtectionScan in DenuvoAuth (for games where the heuristic fails). bool IsForcedDenuvo(AppId_t appId); + + // On-demand eticket backend URL set via seteticketurl() in Lua config. + // Empty string means the feature is disabled and EticketClient falls + // back to the static credential-store ticket (original behaviour). + const std::string& GetEticketUrl(); } #endif // LUACONFIG_H diff --git a/src/Utils/Tickets/EticketClient.cpp b/src/Utils/Tickets/EticketClient.cpp index c9c22740..72c76c49 100644 --- a/src/Utils/Tickets/EticketClient.cpp +++ b/src/Utils/Tickets/EticketClient.cpp @@ -1,6 +1,7 @@ #include "EticketClient.h" #include "OSTPlatform/include/Http.h" +#include "Utils/Config/LuaConfig.h" #include "Utils/Logging/Log.h" #include @@ -14,11 +15,28 @@ namespace EticketClient { namespace { - // On-demand mint endpoint — the Tokeer backend's /eticket route. Hardcoded so - // the feature is always active (no Lua opt-in). The backend POSTs - // {app_id, nonce(hex), existing_steam_id} and returns {eticket, appticket, - // steam_id}. Any failure falls back to the static credential store ticket. - constexpr const char* kEticketUrl = "http://31.57.38.79:8080/eticket"; + // On-demand mint endpoint. The backend is POSTed + // {app_id, nonce(hex), existing_steam_id} and returns + // {eticket, appticket, steam_id}. Any failure falls back to the static + // credential-store ticket. + // + // Resolved in two steps so a build can be self-contained without putting + // any one deployment's backend into public source: + // 1. seteticketurl() in the Lua config, if called (runtime override). + // 2. OST_ETICKET_URL, baked in at compile time via + // cmake -DOST_ETICKET_URL="https://your-host/eticket" + // + // Empty when neither is set, which disables the feature outright: the DLL + // never makes a network request and behaves exactly like stock OST. +#ifndef OST_ETICKET_URL +#define OST_ETICKET_URL "" +#endif + + std::string EticketUrl() { + const std::string& configured = LuaConfig::GetEticketUrl(); + if (!configured.empty()) return configured; + return std::string(OST_ETICKET_URL); + } // Short connect timeouts so a down/unreachable backend fails fast and the // caller falls back; generous recv because the backend mints via a live @@ -103,6 +121,10 @@ namespace { // an entry is cached, subsequent calls (ownership vs eticket, any order) share // it so both layers always align to the same account. bool EnsureFetched(AppId_t appId, std::span nonce, uint64_t existingSteamId, CachedTickets& out) { + // No seteticketurl() in the config: feature off, never touch the network. + // Callers fall back to the static credential-store ticket, i.e. stock OST. + if (EticketUrl().empty()) return false; + { std::lock_guard lock(g_mutex); if (g_noOwnerApps.count(appId)) return false; // already known: no pool owner @@ -135,7 +157,7 @@ namespace { reqBody += "}"; auto r = OSTPlatform::Http::Execute( - L"POST", kEticketUrl, + L"POST", EticketUrl().c_str(), reqBody.data(), static_cast(reqBody.size()), L"Content-Type: application/json\r\n", kResolveMs, kConnectMs, kSendMs, kRecvMs); From 0b863ec339837582027258d5c33441784a176c57 Mon Sep 17 00:00:00 2001 From: mmxlyo Date: Mon, 7 Sep 2026 15:03:54 +0800 Subject: [PATCH 17/30] fix(portable): resolve inject DLLs and cloud_redirect relative to portable DllDir --- src/Utils/CloudRedirect/CloudRedirectHost.cpp | 12 +++++++++++- src/Utils/Config/Config.cpp | 18 +++++++++++++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/Utils/CloudRedirect/CloudRedirectHost.cpp b/src/Utils/CloudRedirect/CloudRedirectHost.cpp index ee12043e..76c2a68c 100644 --- a/src/Utils/CloudRedirect/CloudRedirectHost.cpp +++ b/src/Utils/CloudRedirect/CloudRedirectHost.cpp @@ -1,4 +1,5 @@ #include "CloudRedirectHost.h" +#include "dllmain.h" #include "OSTPlatform/include/DynamicLibrary.h" #include "Utils/Config/Config.h" @@ -63,12 +64,21 @@ namespace { std::filesystem::path ResolveLibraryPath(const std::string& steamRoot, const std::string& configured) { - if (configured.empty()) + if (configured.empty()) { + if (DllDir[0] != '\0') { + auto p = std::filesystem::path(DllDir) / "cloud_redirect.dll"; + if (std::filesystem::exists(p)) return p; + } return std::filesystem::path(steamRoot) / "cloud_redirect.dll"; + } std::filesystem::path lib(configured); if (lib.is_absolute()) return lib; + if (DllDir[0] != '\0') { + auto p = std::filesystem::path(DllDir) / lib; + if (std::filesystem::exists(p)) return p; + } return std::filesystem::path(steamRoot) / lib; } diff --git a/src/Utils/Config/Config.cpp b/src/Utils/Config/Config.cpp index dc362763..759a7748 100644 --- a/src/Utils/Config/Config.cpp +++ b/src/Utils/Config/Config.cpp @@ -1,4 +1,5 @@ #include "Config.h" +#include "dllmain.h" #include "Utils/Logging/Log.h" #include "Utils/SteamMetadata/ManifestClient.h" @@ -148,16 +149,27 @@ namespace { // [[inject]] if (auto arr = tbl["inject"].as_array()) { - std::filesystem::path steamDir = std::filesystem::path(configPath).parent_path(); + std::filesystem::path configDir = std::filesystem::path(configPath).parent_path(); for (auto& node : *arr) { auto t = node.as_table(); if (!t) continue; auto path = (*t)["path"].value(); if (!path || path->empty()) continue; - // Bare names resolve next to steam.exe. + // Relative paths resolve next to opensteamtool.toml, DLL dir, or steam.exe std::filesystem::path full = *path; - if (full.is_relative()) full = steamDir / full; + if (full.is_relative()) { + std::filesystem::path candidate = configDir / full; + if (std::filesystem::exists(candidate)) { + full = candidate; + } else if (DllDir[0] != '\0' && std::filesystem::exists(std::filesystem::path(DllDir) / full)) { + full = std::filesystem::path(DllDir) / full; + } else if (SteamInstallPath[0] != '\0' && std::filesystem::exists(std::filesystem::path(SteamInstallPath) / full)) { + full = std::filesystem::path(SteamInstallPath) / full; + } else { + full = candidate; + } + } if (!std::filesystem::exists(full)) { LOG_WARN("inject dll not found: {}", full.string()); continue; From dd60ea9fc16b69eade5facfd757dbe8a67052fb0 Mon Sep 17 00:00:00 2001 From: mmxlyo Date: Mon, 7 Sep 2026 15:31:11 +0800 Subject: [PATCH 18/30] fix: remove stray merge conflict marker from src/dllmain.cpp --- src/dllmain.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/dllmain.cpp b/src/dllmain.cpp index a99c0bb5..afc2df03 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -1,4 +1,3 @@ -<<<<<<< HEAD #include "dllmain.h" #include "Hook/HookManager.h" #include "Utils/Config/ConfigFileWatcher.h" From 5b2a83f8f1ba383366a01d69b37bc6d20c4bc611 Mon Sep 17 00:00:00 2001 From: mmxlyo Date: Mon, 7 Sep 2026 15:47:57 +0800 Subject: [PATCH 19/30] fix(portable): redirect all cache, logs, and generated files to portable directory --- src/Utils/Config/Config.cpp | 22 +++++++++++++++++-- src/Utils/Config/LuaConfig.cpp | 2 -- src/Utils/SteamMetadata/IPCLoader.cpp | 4 +++- src/Utils/SteamMetadata/PatternLoader.cpp | 4 +++- src/Utils/SteamMetadata/RemoteToml.cpp | 25 ++++++++++++++++------ src/dllmain.cpp | 19 ++++++++++------- src/dllmain.h | 26 +++++++++++++++++++++++ 7 files changed, 82 insertions(+), 20 deletions(-) diff --git a/src/Utils/Config/Config.cpp b/src/Utils/Config/Config.cpp index 759a7748..2a3fcdb1 100644 --- a/src/Utils/Config/Config.cpp +++ b/src/Utils/Config/Config.cpp @@ -39,8 +39,13 @@ namespace { Snapshot MakeDefaultSnapshot(const std::string& configPath) { Snapshot snapshot; - std::filesystem::path p(configPath); - snapshot.logDir = (p.parent_path() / "opensteamtool").string(); + const char* storageDir = GetStorageDirectory(); + if (storageDir && storageDir[0] != '\0') { + snapshot.logDir = (std::filesystem::path(storageDir) / "opensteamtool").string(); + } else { + std::filesystem::path p(configPath); + snapshot.logDir = (p.parent_path() / "opensteamtool").string(); + } return snapshot; } @@ -120,6 +125,19 @@ namespace { else if (*val == "warn") snapshot.logLevel = LogLevel::Warn; else if (*val == "error") snapshot.logLevel = LogLevel::Error; } + if (auto val = (*log)["dir"].value()) { + std::filesystem::path p(*val); + if (p.is_relative()) { + const char* storageDir = GetStorageDirectory(); + if (storageDir && storageDir[0] != '\0') { + snapshot.logDir = (std::filesystem::path(storageDir) / p).string(); + } else { + snapshot.logDir = (std::filesystem::path(configPath).parent_path() / p).string(); + } + } else { + snapshot.logDir = *val; + } + } } // [lua] diff --git a/src/Utils/Config/LuaConfig.cpp b/src/Utils/Config/LuaConfig.cpp index 3e8c141d..2f331b93 100644 --- a/src/Utils/Config/LuaConfig.cpp +++ b/src/Utils/Config/LuaConfig.cpp @@ -748,8 +748,6 @@ namespace LuaConfig{ std::vector files; std::error_code ec; - if (!std::filesystem::exists(directory, ec)) - std::filesystem::create_directories(directory, ec); if (!std::filesystem::exists(directory, ec) || !std::filesystem::is_directory(directory, ec)) return files; diff --git a/src/Utils/SteamMetadata/IPCLoader.cpp b/src/Utils/SteamMetadata/IPCLoader.cpp index 9819f670..7505e5f0 100644 --- a/src/Utils/SteamMetadata/IPCLoader.cpp +++ b/src/Utils/SteamMetadata/IPCLoader.cpp @@ -1,4 +1,5 @@ #include "IPCLoader.h" +#include "dllmain.h" #include "IPCMessages.gen.h" #include "OSTPlatform/include/Numbers.h" #include "Utils/Logging/Log.h" @@ -138,6 +139,7 @@ namespace { static void ShowMissingPopup(const std::string& sha256) { + const std::string rootLabel = IsPortableMode() ? "" : ""; SteamDiagnostics::ShowWarning( "OpenSteamTool - IPC spec missing", "OpenSteamTool: IPC spec file not found.\n\n" @@ -146,7 +148,7 @@ namespace { "You can:\n" " 1. Wait for the next upstream publish and restart Steam.\n" " 2. Drop a matching TOML at:\n" - " \\opensteamtool\\ipc\\steamclient\\" + sha256 + ".toml\n" + " " + rootLabel + "\\opensteamtool\\ipc\\steamclient\\" + sha256 + ".toml\n" " 3. Check upstream:\n" " https://github.com/OpenSteam001/steam-monitor/tree/ipc/steamclient"); } diff --git a/src/Utils/SteamMetadata/PatternLoader.cpp b/src/Utils/SteamMetadata/PatternLoader.cpp index cba686a6..c023bcbe 100644 --- a/src/Utils/SteamMetadata/PatternLoader.cpp +++ b/src/Utils/SteamMetadata/PatternLoader.cpp @@ -1,4 +1,5 @@ #include "PatternLoader.h" +#include "dllmain.h" #include "OSTPlatform/include/Memory.h" #include "OSTPlatform/include/Numbers.h" #include "Utils/Logging/Log.h" @@ -148,6 +149,7 @@ static void ShowDownloadFailedPopup(const std::string& dllName, const std::string& sha256, const std::string& component) { + const std::string rootLabel = IsPortableMode() ? "" : ""; SteamDiagnostics::ShowWarning( "OpenSteamTool - Unsupported Steam Version", "OpenSteamTool: signature file not found for " + dllName + ".\n\n" @@ -156,7 +158,7 @@ static void ShowDownloadFailedPopup(const std::string& dllName, "You can:\n" " 1. Wait for the next signature update, then restart Steam.\n" " 2. Drop a matching TOML at:\n" - " \\opensteamtool\\pattern\\" + component + "\\" + sha256 + ".toml\n" + " " + rootLabel + "\\opensteamtool\\pattern\\" + component + "\\" + sha256 + ".toml\n" " 3. Check upstream:\n" " https://github.com/OpenSteam001/steam-monitor/tree/pattern/" + component + "\n" " 4. Report the diagnostics below:\n" diff --git a/src/Utils/SteamMetadata/RemoteToml.cpp b/src/Utils/SteamMetadata/RemoteToml.cpp index 5bab6f36..5a728482 100644 --- a/src/Utils/SteamMetadata/RemoteToml.cpp +++ b/src/Utils/SteamMetadata/RemoteToml.cpp @@ -1,4 +1,5 @@ #include "RemoteToml.h" +#include "dllmain.h" #include "OSTPlatform/include/Http.h" #include "Utils/Config/Config.h" #include "Utils/Logging/Log.h" @@ -90,7 +91,11 @@ Result Fetch(const Request& request) // 2. Cache path & dir. fs::path steamRoot = fs::path(request.dllPath).parent_path(); - fs::path cacheDir = steamRoot / "opensteamtool" / request.channel / request.component; + fs::path baseDir = GetStorageDirectory(); + if (baseDir.empty()) { + baseDir = steamRoot; + } + fs::path cacheDir = baseDir / "opensteamtool" / request.channel / request.component; fs::path cachePath = cacheDir / (out.sha256 + ".toml"); const std::string cachePathText = cachePath.string(); @@ -146,13 +151,21 @@ Result Fetch(const Request& request) } // 5. Remote failed → fall back to whatever is cached for this exact SHA. - if (fs::exists(cachePath)) { + fs::path fallbackPath = cachePath; + if (!fs::exists(fallbackPath) && IsPortableMode()) { + fs::path steamCachePath = steamRoot / "opensteamtool" / request.channel / request.component / (out.sha256 + ".toml"); + if (fs::exists(steamCachePath)) { + fallbackPath = steamCachePath; + } + } + + if (fs::exists(fallbackPath)) { LOG_WARN("RemoteToml({}/{}): remote failed (last URL {} HTTP {}); " "falling back to local cache {}", request.channel, request.component, - lastUrl.empty() ? "" : lastUrl, http.status, cachePathText); + lastUrl.empty() ? "" : lastUrl, http.status, fallbackPath.string()); - std::ifstream ifs(cachePath, std::ios::binary); + std::ifstream ifs(fallbackPath, std::ios::binary); if (ifs) { std::string buf((std::istreambuf_iterator(ifs)), std::istreambuf_iterator()); @@ -163,10 +176,10 @@ Result Fetch(const Request& request) return out; } LOG_WARN("RemoteToml({}/{}): cache file empty: {}", - request.channel, request.component, cachePathText); + request.channel, request.component, fallbackPath.string()); } else { LOG_WARN("RemoteToml({}/{}): could not open cache file: {}", - request.channel, request.component, cachePathText); + request.channel, request.component, fallbackPath.string()); } } diff --git a/src/dllmain.cpp b/src/dllmain.cpp index afc2df03..be83124f 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -51,12 +51,15 @@ bool InitializeSteamComponents(OSTPlatform::DynamicLibrary::ModuleHandle selfMod } sprintf_s(ConfigPath, kRuntimePathCapacity, "%s", tomlPath.c_str()); - std::string luaPath = (std::filesystem::path(DllDir) / "config" / "lua").string(); - if (!std::filesystem::exists(luaPath)) { - std::string steamLua = (std::filesystem::path(SteamInstallPath) / "config" / "lua").string(); - if (std::filesystem::exists(steamLua) || dllPath.empty()) { - luaPath = steamLua; - } + std::string luaPath; + if (IsPortableMode()) { + luaPath = (std::filesystem::path(DllDir) / "config" / "lua").string(); + std::error_code ec; + std::filesystem::create_directories(luaPath, ec); + } else { + luaPath = (std::filesystem::path(SteamInstallPath) / "config" / "lua").string(); + std::error_code ec; + std::filesystem::create_directories(luaPath, ec); } sprintf_s(LuaDir, kRuntimePathCapacity, "%s", luaPath.c_str()); @@ -105,8 +108,8 @@ static uint32_t InitThread(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) std::vector watchDirs = Config::GetLuaPaths(); watchDirs.push_back(std::string(LuaDir)); - // If DllDir and SteamInstallPath are different, also watch Steam's config/lua if it exists - if (_stricmp(SteamInstallPath, DllDir) != 0) { + // In portable mode, also watch Steam's config/lua if it already exists + if (IsPortableMode()) { std::string steamLua = (std::filesystem::path(SteamInstallPath) / "config" / "lua").string(); if (std::filesystem::exists(steamLua) && steamLua != std::string(LuaDir)) { watchDirs.push_back(steamLua); diff --git a/src/dllmain.h b/src/dllmain.h index 1c0a935c..0b88c5d0 100644 --- a/src/dllmain.h +++ b/src/dllmain.h @@ -24,6 +24,8 @@ #include "Utils/Config/Config.h" +#include + inline OSTPlatform::DynamicLibrary::ModuleHandle client_hModule = nullptr; inline OSTPlatform::DynamicLibrary::ModuleHandle ui_hModule = nullptr; @@ -37,6 +39,30 @@ inline char LuaDir[kRuntimePathCapacity] = {}; inline char ConfigPath[kRuntimePathCapacity] = {}; inline char DllDir[kRuntimePathCapacity] = {}; +inline bool IsPortableMode() { + if (DllDir[0] == '\0' || SteamInstallPath[0] == '\0') { + return false; + } + std::error_code ec; + if (std::filesystem::equivalent(DllDir, SteamInstallPath, ec)) { + return false; + } + return _stricmp(DllDir, SteamInstallPath) != 0; +} + +inline const char* GetStorageDirectory() { + if (IsPortableMode()) { + return DllDir; + } + if (SteamInstallPath[0] != '\0') { + return SteamInstallPath; + } + if (DllDir[0] != '\0') { + return DllDir; + } + return ""; +} + // The fake AppId used by -onlinefix (SpaceWar). constexpr AppId_t kOnlineFixAppId = 480; From 537a255d142ca7f87ff939deddb5ff3df28fb99c Mon Sep 17 00:00:00 2001 From: mmxlyo Date: Mon, 7 Sep 2026 16:22:55 +0800 Subject: [PATCH 20/30] fix(denuvo): add nodenuvo Lua directive and prevent false positive on RE Engine sections --- src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp | 5 ++++- .../Features/DenuvoAuth/ProtectionScan.cpp | 8 ++++++++ src/Utils/Config/LuaConfig.cpp | 19 +++++++++++++++++++ src/Utils/Config/LuaConfig.h | 4 ++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp b/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp index 21951bcd..f3c4e807 100644 --- a/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp +++ b/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp @@ -159,7 +159,10 @@ namespace { } auth.scanned = true; - if (LuaConfig::IsForcedDenuvo(appId)) { + if (LuaConfig::IsNoDenuvo(appId)) { + auth.denuvo = false; + LOG_PIPE_INFO("DenuvoAuth: nodenuvo appid={} — skipping ProtectionScan and forcing non-Denuvo", appId); + } else if (LuaConfig::IsForcedDenuvo(appId)) { auth.denuvo = true; LOG_PIPE_INFO("DenuvoAuth: forcedenuvo appid={} — skipping ProtectionScan", appId); } else { diff --git a/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp b/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp index 5d1cc898..1163d35f 100644 --- a/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp +++ b/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp @@ -277,6 +277,14 @@ namespace { if (!(section.IsExecutable() && section.IsWritable())) continue; if (section.rawSize < kProtectorBlobMinBytes) continue; + // Skip known non-Denuvo engine sections: + // .rex and .mx are Capcom RE Engine's internal runtime sections (RWX), not Denuvo. + if (section.name == ".rex" || section.name == ".mx") { + LOG_PIPE_DEBUG("DenuvoAuth: skipping known non-Denuvo section {} path={}", + section.name, module.path); + continue; + } + const size_t sampleSize = (std::min)(static_cast(section.rawSize), kProtectorBlobEntropySampleBytes); const OSTPlatform::PE::ByteBuffer sample = image.ReadRawBytes(section.rawOffset, sampleSize); diff --git a/src/Utils/Config/LuaConfig.cpp b/src/Utils/Config/LuaConfig.cpp index 2f331b93..b29e1aa7 100644 --- a/src/Utils/Config/LuaConfig.cpp +++ b/src/Utils/Config/LuaConfig.cpp @@ -32,6 +32,8 @@ namespace LuaConfig{ std::unordered_map ProcessNameAppIdMap{}; // App IDs that should bypass ProtectionScan and be treated as Denuvo games. std::unordered_set ForcedDenuvoSet{}; + // App IDs that should bypass ProtectionScan and be treated as non-Denuvo games. + std::unordered_set NoDenuvoSet{}; // On-demand eticket mint endpoint, set via seteticketurl() in Lua config. // Empty = disabled (EticketClient falls back to credential-store ticket). std::string EticketUrl{}; @@ -301,6 +303,17 @@ namespace LuaConfig{ return 0; } + static int lua_nodenuvo(lua_State* L) { + // nodenuvo(appid) — explicitly mark as non-Denuvo, bypassing ProtectionScan. + if (lua_gettop(L) < 1 || !lua_isinteger(L, 1)) + return luaL_error(L, "nodenuvo requires (appid: integer)"); + lua_Integer value = lua_tointeger(L, 1); + if (value <= 0 || value > static_cast(UINT32_MAX)) + return luaL_error(L, "nodenuvo: appid out of range"); + NoDenuvoSet.insert(static_cast(value)); + return 0; + } + static int lua_seteticketurl(lua_State* L) { // seteticketurl("http://your-backend/eticket") // Endpoint that mints fresh nonce-bound encrypted app tickets for @@ -490,6 +503,8 @@ namespace LuaConfig{ register_func(g_lua_state, "addtoken", lua_addtoken); register_func(g_lua_state, "addprocess", lua_addprocess); register_func(g_lua_state, "forcedenuvo", lua_forcedenuvo); + register_func(g_lua_state, "nodenuvo", lua_nodenuvo); + register_func(g_lua_state, "disallowdenuvo", lua_nodenuvo); register_func(g_lua_state, "seteticketurl", lua_seteticketurl); // we don't need it? // register_func(g_lua_state, "pinapp", lua_pinApp); @@ -523,6 +538,10 @@ namespace LuaConfig{ return ForcedDenuvoSet.count(appId) > 0; } + bool IsNoDenuvo(AppId_t appId) { + return NoDenuvoSet.count(appId) > 0; + } + const std::string& GetEticketUrl() { return EticketUrl; } diff --git a/src/Utils/Config/LuaConfig.h b/src/Utils/Config/LuaConfig.h index 7af5e5df..ff2ecfb2 100644 --- a/src/Utils/Config/LuaConfig.h +++ b/src/Utils/Config/LuaConfig.h @@ -48,6 +48,10 @@ namespace LuaConfig{ // ProtectionScan in DenuvoAuth (for games where the heuristic fails). bool IsForcedDenuvo(AppId_t appId); + // Returns true if the appid was marked via nodenuvo() / disallowdenuvo(), + // completely skipping ProtectionScan and Denuvo authorization. + bool IsNoDenuvo(AppId_t appId); + // On-demand eticket backend URL set via seteticketurl() in Lua config. // Empty string means the feature is disabled and EticketClient falls // back to the static credential-store ticket (original behaviour). From 3581c9168f37c6a23f375745eb196ffd6f052182 Mon Sep 17 00:00:00 2001 From: mmxlyo Date: Mon, 7 Sep 2026 19:01:28 +0800 Subject: [PATCH 21/30] feat: merge ost-Injector into project, add English batch scripts, update multi-language docs for portable mode --- .github/workflows/main.yml | 13 +- README.md | 27 +- README_ES.md | 25 +- README_ZH.md | 26 +- scripts/CreateAutoInjectTask.bat | 24 ++ scripts/DeleteAutoInjectTask.bat | 21 ++ scripts/config.ini | 3 + src/CMakeLists.txt | 34 +++ src/Injector/Injector.cpp | 493 +++++++++++++++++++++++++++++++ src/Injector/Injector.h | 32 ++ 10 files changed, 678 insertions(+), 20 deletions(-) create mode 100644 scripts/CreateAutoInjectTask.bat create mode 100644 scripts/DeleteAutoInjectTask.bat create mode 100644 scripts/config.ini create mode 100644 src/Injector/Injector.cpp create mode 100644 src/Injector/Injector.h diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 77a5fc13..c18cb2c2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -73,9 +73,16 @@ jobs: Download `OpenSteamTool-${{ github.event.inputs.version }}-Release.zip` (or the `-Debug` variant if you need logging to `/opensteamtool directory`). - Extract and copy `dwmapi.dll`, `xinput1_4.dll` and `OpenSteamTool.dll` to your Steam root directory (e.g. `C:\Program Files (x86)\Steam`). - - Create a Lua config directory (for example `C:\Program Files (x86)\Steam\config\lua`) and place your Lua scripts there. **NOT** `C:\Program Files (x86)\Steam\config\stplug-in`! + ### Method 1: Portable Mode (Recommended, ost-Injector) + 1. Extract the zip to any standalone folder outside the Steam directory. + 2. Place your Lua unlock scripts in `config/lua/` inside that folder. + 3. Run `ost-Injector.exe` to launch/inject into Steam, or run `CreateAutoInjectTask.bat` to set up background auto-injection on system startup. + *(No files are placed in or modify your Steam directory!)* + + ### Method 2: Standard Mode (DLL Hijacking) + 1. Extract and copy `dwmapi.dll`, `xinput1_4.dll` and `OpenSteamTool.dll` directly to your Steam root directory (e.g. `C:\Program Files (x86)\Steam`). + 2. Create a Lua config directory (`C:\Program Files (x86)\Steam\config\lua`) and place your Lua scripts there. **NOT** `config\stplug-in`! files: | OpenSteamTool-${{ github.event.inputs.version }}-Release.zip OpenSteamTool-${{ github.event.inputs.version }}-Debug.zip + diff --git a/README.md b/README.md index 2a901b51..273733e7 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,25 @@ The `extract_tickets` tool dumps the `AppTicket` and `ETicket` hex strings you n - Steam Cloud synchronization support.(This is a huge project) ## Usage -1. Run `build.bat` from the project root to build the project. -2. Copy generated `dwmapi.dll`, `xinput1_4.dll` and `OpenSteamTool.dll` to the Steam root directory. -3. Create Lua directory (for example `C:\steam\config\lua`) and place Lua scripts there. The DLL will automatically load and execute them. -4. Lua example: + +### Method 1: Portable Mode (Recommended, using ost-Injector) + +Portable mode operates completely independently: **no DLLs are placed in the Steam directory, and the Steam installation folder remains untouched**: + +1. Extract the build / release package (containing `ost-Injector.exe`, `OpenSteamTool.dll`, `CreateAutoInjectTask.bat`, `DeleteAutoInjectTask.bat`, `config.ini`, etc.) to any standalone portable directory (e.g. `D:\OpenSteamTool_Portable`). +2. Create a `config/lua/` folder in that directory and place your game/DLC unlock Lua scripts there (e.g. `games.lua`). +3. Choose a launch method: + - **Manual Launch**: Run `ost-Injector.exe` directly. It will detect or launch Steam and inject `OpenSteamTool.dll` once the Steam UI initializes. + - **Auto-Inject on Startup**: Right-click `CreateAutoInjectTask.bat` and select "Run as administrator" to create a scheduled logon task. The injector runs quietly in the background (`-watch` mode) and auto-injects whenever Steam starts. To remove the task, right-click and run `DeleteAutoInjectTask.bat` as administrator. + - **Command Line Modes**: `ost-Injector.exe` supports `-watch` (background daemon) and `-silent` (one-shot silent injection). The default `config.ini` allows customizing the Steam executable path and DLL path. + +### Method 2: Standard Mode (DLL Hijacking) + +1. Run `build.bat` from the project root to build the project, or download a pre-built Release package. +2. Copy the generated `dwmapi.dll`, `xinput1_4.dll`, and `OpenSteamTool.dll` to your Steam root directory. +3. Create a Lua directory (e.g. `C:\Program Files (x86)\Steam\config\lua`) and place your Lua scripts there. The DLL will automatically load and execute them. + +### Lua Configuration Example ```lua addappid(1361510) -- unlock game with appid 1361510 @@ -292,8 +307,8 @@ build.bat ``` ### Output -- Debug: `build/Debug/OpenSteamTool.dll`, `build/Debug/dwmapi.dll`, `build/Debug/xinput1_4.dll` -- Release: `build/Release/OpenSteamTool.dll`, `build/Release/dwmapi.dll`, `build/Release/xinput1_4.dll` +- Debug: `build/Debug/OpenSteamTool.dll`, `build/Debug/dwmapi.dll`, `build/Debug/xinput1_4.dll`, `build/Debug/ost-Injector.exe`, and auto-copied helper scripts +- Release: `build/Release/OpenSteamTool.dll`, `build/Release/dwmapi.dll`, `build/Release/xinput1_4.dll`, `build/Release/ost-Injector.exe`, and auto-copied helper scripts ## Disclaimer This project is provided for research and educational purposes only. You are responsible for complying with local laws, platform terms of service, and software licenses. diff --git a/README_ES.md b/README_ES.md index 5324dcab..99d7ea1a 100644 --- a/README_ES.md +++ b/README_ES.md @@ -97,10 +97,25 @@ La herramienta `extract_tickets` vuelca las cadenas hexadecimales de `AppTicket` - Soporte para la sincronización con Steam Cloud (este es un proyecto enorme). ## Uso -1. Ejecuta `build.bat` desde la raíz del proyecto para compilarlo. + +### Método 1: Modo Portátil (Recomendado, usando ost-Injector) + +El modo portátil funciona de forma completamente independiente: **no se coloca ninguna DLL en el directorio de Steam y la carpeta de instalación de Steam permanece intacta**: + +1. Extrae el paquete de lanzamiento (que contiene `ost-Injector.exe`, `OpenSteamTool.dll`, `CreateAutoInjectTask.bat`, `DeleteAutoInjectTask.bat`, `config.ini`, etc.) en cualquier carpeta portátil independiente (por ejemplo, `D:\OpenSteamTool_Portable`). +2. Crea una carpeta `config/lua/` en ese directorio y coloca allí tus scripts Lua de desbloqueo (como `games.lua`). +3. Elige un método de inicio: + - **Inicio Manual**: Ejecuta `ost-Injector.exe` directamente. Detectará o iniciará Steam e inyectará `OpenSteamTool.dll` tan pronto como la interfaz de Steam esté lista. + - **Inyección Automática al Iniciar Sesión**: Haz clic derecho en `CreateAutoInjectTask.bat` y selecciona "Ejecutar como administrador" para crear una tarea programada. El inyector se ejecutará silenciosamente en segundo plano (modo `-watch`) y se inyectará automáticamente cada vez que se inicie Steam. Para desinstalar la tarea, haz clic derecho y ejecuta `DeleteAutoInjectTask.bat` como administrador. + - **Línea de Comandos**: `ost-Injector.exe` admite `-watch` (demonio en segundo plano) y `-silent` (inyección silenciosa única). El archivo `config.ini` permite personalizar la ruta del ejecutable de Steam y la ruta de la DLL. + +### Método 2: Modo Estándar (Secuestro de DLL / DLL Hijacking) + +1. Ejecuta `build.bat` desde la raíz del proyecto para compilarlo, o descarga un paquete Release precompilado. 2. Copia los archivos generados `dwmapi.dll`, `xinput1_4.dll` y `OpenSteamTool.dll` al directorio raíz de Steam. -3. Crea un directorio para Lua (por ejemplo, C:\steam\config\lua) y coloca allí tus scripts de Lua. La DLL los cargará y ejecutará automáticamente. -4. Ejemplo de Lua: +3. Crea un directorio para Lua (por ejemplo, `C:\Program Files (x86)\Steam\config\lua`) y coloca allí tus scripts de Lua. La DLL los cargará y ejecutará automáticamente. + +### Ejemplo de Configuración Lua ```lua addappid(1361510) -- desbloquea el juego con appid 1361510 @@ -256,9 +271,9 @@ build.bat ``` ### Archivos de salida -- Debug: `build/Debug/OpenSteamTool.dll`, `build/Debug/dwmapi.dll`, `build/Debug/xinput1_4.dll` +- Debug: `build/Debug/OpenSteamTool.dll`, `build/Debug/dwmapi.dll`, `build/Debug/xinput1_4.dll`, `build/Debug/ost-Injector.exe`, y scripts auxiliares copiados automáticamente. -- Release: `build/Release/OpenSteamTool.dll`, `build/Release/dwmapi.dll`, `build/Release/xinput1_4.dll` +- Release: `build/Release/OpenSteamTool.dll`, `build/Release/dwmapi.dll`, `build/Release/xinput1_4.dll`, `build/Release/ost-Injector.exe`, y scripts auxiliares copiados automáticamente. ## Descargo de responsabilidad Este proyecto se proporciona únicamente con fines de investigación y educativos. Eres responsable de cumplir con las leyes locales, los términos de servicio de la plataforma y las licencias de software correspondientes. diff --git a/README_ZH.md b/README_ZH.md index 39940a6a..43422cbd 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -103,10 +103,24 @@ ## 使用方法 -1. 在项目根目录运行 `build.bat` 构建项目 -2. 将生成的 `dwmapi.dll`、`xinput1_4.dll` 和 `OpenSteamTool.dll` 复制到 Steam 根目录 -3. 创建 Lua 目录(例如 `C:\steam\config\lua`)并将 Lua 脚本放在那里。DLL 会自动加载并执行它们 -4. Lua 示例: +### 方式一:便携模式(推荐,使用 ost-Injector) + +便携模式完全独立运行,**无需向 Steam 安装目录放置任何 DLL,也不改动 Steam 文件夹**: + +1. 解压构建好的发布包(包含 `ost-Injector.exe`、`OpenSteamTool.dll`、`CreateAutoInjectTask.bat`、`DeleteAutoInjectTask.bat`、`config.ini` 等)到任意独立便携目录(例如 `D:\OpenSteamTool_Portable`)。 +2. 在该目录下创建 `config/lua/` 文件夹,并放入游戏或 DLC 解锁脚本(如 `games.lua`)。 +3. 选择启动方式: + - **手动启动**:直接双击运行 `ost-Injector.exe`,注入器会自动检测或拉起 Steam,并在 Steam UI 就绪后自动完成注入。 + - **开机自动静默注入**:右键以管理员身份运行 `CreateAutoInjectTask.bat`,即可创建开机登录计划任务。注入器将在后台以 `-watch` 模式常驻静默监听,一旦检测到 Steam 启动立即自动完成注入。若需移除自启任务,右键管理员运行 `DeleteAutoInjectTask.bat` 即可。 + - **命令行模式**:`ost-Injector.exe` 支持 `-watch`(后台常驻监听)与 `-silent`(单次静默注入)。默认配置文件 `config.ini` 可自定义 Steam 可执行程序路径与目标 DLL 路径。 + +### 方式二:标准模式(DLL 劫持) + +1. 在项目根目录运行 `build.bat` 构建项目,或下载预编译 Release 包。 +2. 将生成的 `dwmapi.dll`、`xinput1_4.dll` 和 `OpenSteamTool.dll` 复制到 Steam 根目录。 +3. 创建 Lua 目录(例如 `C:\Program Files (x86)\Steam\config\lua`)并将 Lua 脚本放在那里。DLL 会自动加载并执行它们。 + +### Lua 配置示例 ```lua addappid(1361510) -- 解锁 appid 为 1361510 的游戏 @@ -264,8 +278,8 @@ build.bat ``` ### 输出 -- Debug:`build/Debug/OpenSteamTool.dll`、`build/Debug/dwmapi.dll`、`build/Debug/xinput1_4.dll` -- Release:`build/Release/OpenSteamTool.dll`、`build/Release/dwmapi.dll`、`build/Release/xinput1_4.dll` +- Debug:`build/Debug/OpenSteamTool.dll`、`build/Debug/dwmapi.dll`、`build/Debug/xinput1_4.dll`、`build/Debug/ost-Injector.exe` 以及自动复制的辅助脚本 +- Release:`build/Release/OpenSteamTool.dll`、`build/Release/dwmapi.dll`、`build/Release/xinput1_4.dll`、`build/Release/ost-Injector.exe` 以及自动复制的辅助脚本 ## 免责声明 本项目仅供研究和教育目的使用。你负责遵守当地法律、平台服务条款和软件许可证。 diff --git a/scripts/CreateAutoInjectTask.bat b/scripts/CreateAutoInjectTask.bat new file mode 100644 index 00000000..c2e28318 --- /dev/null +++ b/scripts/CreateAutoInjectTask.bat @@ -0,0 +1,24 @@ +@echo off +chcp 65001 >nul +echo ======================================================= +echo OpenSteamTool - Setup Auto Inject Task +echo ======================================================= +echo. +echo Creating scheduled task "OpenSteamTool_AutoInject"... +schtasks /create /tn "OpenSteamTool_AutoInject" /tr "\"%~dp0ost-Injector.exe\" -watch" /sc onlogon /rl highest /f +if %errorlevel% equ 0 ( + echo. + echo ======================================================= + echo [SUCCESS] Scheduled task "OpenSteamTool_AutoInject" created! + echo The background watcher will start upon logon and automatically + echo inject OpenSteamTool.dll whenever Steam starts. + echo ======================================================= +) else ( + echo. + echo ======================================================= + echo [FAILED] Failed to create scheduled task. + echo Please right-click this script and select "Run as administrator". + echo ======================================================= +) +echo. +pause diff --git a/scripts/DeleteAutoInjectTask.bat b/scripts/DeleteAutoInjectTask.bat new file mode 100644 index 00000000..a312ba0f --- /dev/null +++ b/scripts/DeleteAutoInjectTask.bat @@ -0,0 +1,21 @@ +@echo off +chcp 65001 >nul +echo ======================================================= +echo OpenSteamTool - Remove Auto Inject Task +echo ======================================================= +echo. +echo Deleting scheduled task "OpenSteamTool_AutoInject"... +schtasks /delete /tn "OpenSteamTool_AutoInject" /f +if %errorlevel% equ 0 ( + echo. + echo ======================================================= + echo [SUCCESS] Scheduled task removed successfully! + echo ======================================================= +) else ( + echo. + echo ======================================================= + echo [INFO] Task does not exist or has already been removed. + echo ======================================================= +) +echo. +pause diff --git a/scripts/config.ini b/scripts/config.ini new file mode 100644 index 00000000..0c0fdd54 --- /dev/null +++ b/scripts/config.ini @@ -0,0 +1,3 @@ +[Settings] +ExePath=C:\Program Files (x86)\Steam\steam.exe +DllPath=OpenSteamTool.dll diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index adae20df..9b2b4cc5 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -199,3 +199,37 @@ add_library(xinput1_4 SHARED xinput1_4/xinput1_4.cpp xinput1_4/xinput1_4.def ) + +# --------------------------------------------------------------------------- +# ost-Injector — portable injector executable. +# --------------------------------------------------------------------------- +add_executable(ost-Injector + Injector/Injector.cpp + Injector/Injector.h +) + +set_target_properties(ost-Injector PROPERTIES + OUTPUT_NAME "ost-Injector" +) + +target_link_libraries(ost-Injector PRIVATE + kernel32 + user32 + advapi32 + shell32 +) + +# Copy portable helper scripts and default config into the target output directory +add_custom_command(TARGET ost-Injector POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/CreateAutoInjectTask.bat" + "$/CreateAutoInjectTask.bat" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/DeleteAutoInjectTask.bat" + "$/DeleteAutoInjectTask.bat" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/config.ini" + "$/config.ini" + COMMENT "Copying portable injector scripts and config template" +) + diff --git a/src/Injector/Injector.cpp b/src/Injector/Injector.cpp new file mode 100644 index 00000000..8461d4de --- /dev/null +++ b/src/Injector/Injector.cpp @@ -0,0 +1,493 @@ +#include "Injector.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Injector { + + bool IsModuleLoaded(DWORD pid, const std::wstring& moduleName) { + HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, pid); + if (hSnap == INVALID_HANDLE_VALUE) return false; + + MODULEENTRY32W me = { sizeof(me) }; + bool found = false; + + if (Module32FirstW(hSnap, &me)) { + do { + if (_wcsicmp(moduleName.c_str(), me.szModule) == 0) { + found = true; + break; + } + } while (Module32NextW(hSnap, &me)); + } + + CloseHandle(hSnap); + return found; + } + + std::vector FindProcessesByName(const std::wstring& processName) { + std::vector pids; + HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (hSnap == INVALID_HANDLE_VALUE) return pids; + + PROCESSENTRY32W pe = { sizeof(pe) }; + if (Process32FirstW(hSnap, &pe)) { + do { + if (_wcsicmp(processName.c_str(), pe.szExeFile) == 0) { + pids.push_back(pe.th32ProcessID); + } + } while (Process32NextW(hSnap, &pe)); + } + + CloseHandle(hSnap); + return pids; + } + + bool InjectDllByHandle(HANDLE hProcess, const std::wstring& dllPath, bool isSilent) { + SIZE_T byteCount = (dllPath.size() + 1) * sizeof(wchar_t); + void* remoteMem = VirtualAllocEx(hProcess, nullptr, byteCount, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if (!remoteMem) { + if (!isSilent) { + std::wcerr << L"[-] VirtualAllocEx failed. Error: " << GetLastError() << std::endl; + } + return false; + } + + if (!WriteProcessMemory(hProcess, remoteMem, dllPath.c_str(), byteCount, nullptr)) { + if (!isSilent) { + std::wcerr << L"[-] WriteProcessMemory failed. Error: " << GetLastError() << std::endl; + } + VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE); + return false; + } + + HMODULE hKernel32 = GetModuleHandleW(L"kernel32.dll"); + FARPROC loadLibraryWAddr = GetProcAddress(hKernel32, "LoadLibraryW"); + if (!loadLibraryWAddr) { + if (!isSilent) { + std::wcerr << L"[-] Failed to locate LoadLibraryW. Error: " << GetLastError() << std::endl; + } + VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE); + return false; + } + + HANDLE hThread = CreateRemoteThread(hProcess, nullptr, 0, + reinterpret_cast(loadLibraryWAddr), + remoteMem, 0, nullptr); + + if (!hThread) { + if (!isSilent) { + std::wcerr << L"[-] CreateRemoteThread failed. Error: " << GetLastError() << std::endl; + } + VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE); + return false; + } + + WaitForSingleObject(hThread, INFINITE); + + DWORD exitCode = 0; + GetExitCodeThread(hThread, &exitCode); + CloseHandle(hThread); + VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE); + + if (exitCode == 0) { + DWORD pid = GetProcessId(hProcess); + if (pid != 0 && IsModuleLoaded(pid, L"OpenSteamTool.dll")) { + return true; + } + if (!isSilent) { + std::wcerr << L"[-] Warning: LoadLibraryW returned NULL. The DLL initialization may have failed." << std::endl; + } + return false; + } + + return true; + } + + std::wstring GetExecutableDirectory() { + wchar_t buffer[MAX_PATH] = { 0 }; + GetModuleFileNameW(NULL, buffer, MAX_PATH); + std::wstring exePath(buffer); + size_t lastSlash = exePath.find_last_of(L"\\/"); + if (lastSlash != std::wstring::npos) { + return exePath.substr(0, lastSlash); + } + return L"."; + } + + std::wstring GetIniFilePath(const std::wstring& iniFileName) { + return GetExecutableDirectory() + L"\\" + iniFileName; + } + + std::wstring GetSteamPathFromRegistry() { + HKEY hKey = nullptr; + std::wstring steamExePath = L""; + + if (RegOpenKeyExW(HKEY_CURRENT_USER, L"SOFTWARE\\Valve\\Steam", 0, KEY_READ, &hKey) == ERROR_SUCCESS) { + wchar_t buffer[MAX_PATH] = { 0 }; + DWORD bufferSize = sizeof(buffer); + DWORD type = REG_SZ; + + if (RegQueryValueExW(hKey, L"SteamExe", nullptr, &type, reinterpret_cast(buffer), &bufferSize) == ERROR_SUCCESS) { + steamExePath = buffer; + for (wchar_t& ch : steamExePath) { + if (ch == L'/') ch = L'\\'; + } + } else { + bufferSize = sizeof(buffer); + if (RegQueryValueExW(hKey, L"SteamPath", nullptr, &type, reinterpret_cast(buffer), &bufferSize) == ERROR_SUCCESS) { + std::wstring steamDir = buffer; + for (wchar_t& ch : steamDir) { + if (ch == L'/') ch = L'\\'; + } + steamExePath = steamDir + L"\\steam.exe"; + } + } + RegCloseKey(hKey); + } + return steamExePath; + } + + std::wstring ResolveAbsoluteDllPath(const std::wstring& rawDllPath, const std::wstring& baseDir) { + std::filesystem::path raw(rawDllPath); + if (raw.is_absolute()) { + return raw.lexically_normal().wstring(); + } + std::filesystem::path base(baseDir); + return (base / raw).lexically_normal().wstring(); + } + + bool FileExists(const std::wstring& filePath) { + DWORD attributes = GetFileAttributesW(filePath.c_str()); + return (attributes != INVALID_FILE_ATTRIBUTES && !(attributes & FILE_ATTRIBUTE_DIRECTORY)); + } + + void LogMessage(const std::wstring& baseDir, const std::string& msg, bool isSilent) { + if (!isSilent) { + std::cout << msg << std::endl; + } + try { + std::wstring logFile = baseDir + L"\\inject.log"; + std::ofstream ofs(logFile, std::ios::app | std::ios::binary); + if (ofs.is_open()) { + auto now = std::chrono::system_clock::now(); + auto timeT = std::chrono::system_clock::to_time_t(now); + std::tm tmNow; + localtime_s(&tmNow, &timeT); + + std::ostringstream ss; + ss << "[" << std::put_time(&tmNow, "%Y-%m-%d %H:%M:%S") << "] " << msg << "\r\n"; + std::string line = ss.str(); + ofs.write(line.c_str(), line.size()); + } + } catch (...) {} + } + + void ShowErrorAlert(const std::wstring& message) { + MessageBoxW(NULL, message.c_str(), L"OpenSteamTool Injector Error", MB_OK | MB_ICONERROR | MB_SETFOREGROUND); + } + + int RunWatcher(const std::wstring& baseDir, const std::wstring& dllPath) { + HANDLE hMutex = CreateMutexW(NULL, TRUE, L"Global\\OpenSteamTool_AutoInject_Watcher"); + if (!hMutex && GetLastError() == ERROR_ACCESS_DENIED) { + hMutex = CreateMutexW(NULL, TRUE, L"Local\\OpenSteamTool_AutoInject_Watcher"); + } + if (GetLastError() == ERROR_ALREADY_EXISTS) { + if (hMutex) CloseHandle(hMutex); + return 0; // Instance already running + } + + LogMessage(baseDir, "[Watcher] 自动注入后台监听已启动,等待 steam.exe 启动...", true); + std::set injectedPids; + + constexpr DWORD kInjectAccess = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | + PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ; + + while (true) { + std::vector pids = FindProcessesByName(L"steam.exe"); + if (!pids.empty()) { + std::set currentPids(pids.begin(), pids.end()); + for (auto it = injectedPids.begin(); it != injectedPids.end(); ) { + if (!currentPids.count(*it)) { + it = injectedPids.erase(it); + } else { + ++it; + } + } + + for (DWORD pid : pids) { + if (injectedPids.count(pid)) continue; + + if (IsModuleLoaded(pid, L"OpenSteamTool.dll")) { + injectedPids.insert(pid); + continue; + } + + // Wait for steamui.dll to be loaded + bool uiReady = false; + for (int i = 0; i < 60; ++i) { + HANDLE hCheck = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid); + if (!hCheck) break; + DWORD exitCode = 0; + GetExitCodeProcess(hCheck, &exitCode); + CloseHandle(hCheck); + if (exitCode != STILL_ACTIVE) break; + + if (IsModuleLoaded(pid, L"steamui.dll")) { + uiReady = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } + + if (uiReady) { + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + HANDLE hProcess = OpenProcess(kInjectAccess, FALSE, pid); + if (hProcess) { + if (InjectDllByHandle(hProcess, dllPath, true)) { + injectedPids.insert(pid); + LogMessage(baseDir, "[Watcher] 成功自动注入 OpenSteamTool 到 Steam (PID: " + std::to_string(pid) + ")", true); + } else { + LogMessage(baseDir, "[Watcher] 注入失败 (PID: " + std::to_string(pid) + ")", true); + } + CloseHandle(hProcess); + } + } + } + } else { + if (!injectedPids.empty()) { + injectedPids.clear(); + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(1500)); + } + + if (hMutex) CloseHandle(hMutex); + return 0; + } + + int RunSilentOnce(const std::wstring& baseDir, const std::wstring& dllPath) { + std::vector pids = FindProcessesByName(L"steam.exe"); + if (pids.empty()) return 0; + + DWORD pid = pids[0]; + if (IsModuleLoaded(pid, L"OpenSteamTool.dll")) return 0; + + bool uiReady = false; + for (int i = 0; i < 60; ++i) { + if (IsModuleLoaded(pid, L"steamui.dll")) { + uiReady = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } + if (!uiReady) return 0; + + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + constexpr DWORD kInjectAccess = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | + PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ; + HANDLE hProcess = OpenProcess(kInjectAccess, FALSE, pid); + if (hProcess) { + bool ok = InjectDllByHandle(hProcess, dllPath, true); + CloseHandle(hProcess); + if (ok) { + LogMessage(baseDir, "[Silent] 成功静默注入 OpenSteamTool 到 Steam (PID: " + std::to_string(pid) + ")", true); + return 0; + } else { + LogMessage(baseDir, "[Silent] 注入失败 (PID: " + std::to_string(pid) + ")", true); + return 1; + } + } + return 0; + } + + void RunInteractive(const std::wstring& baseDir, const std::wstring& exePath, const std::wstring& dllPath) { + SetConsoleTitleW(L"OpenSteamTool Injector (ost-Injector)"); + + std::cout << "=================================================" << std::endl; + std::cout << " OpenSteamTool Injector (ost-Injector) " << std::endl; + std::cout << " Supported modes: manual, -silent, -watch " << std::endl; + std::cout << "=================================================" << std::endl; + std::cout << std::endl; + + std::wcout << L"[+] Target Executable : " << exePath << std::endl; + std::wcout << L"[+] Payload DLL : " << dllPath << std::endl; + std::cout << std::endl; + + if (!FileExists(dllPath)) { + std::wstring err = L"Error: Payload DLL was not found at:\n" + dllPath + L"\n\nPlease ensure OpenSteamTool.dll exists."; + std::wcerr << L"[-] " << err << std::endl; + ShowErrorAlert(err); + return; + } + + std::vector existingPids = FindProcessesByName(L"steam.exe"); + constexpr DWORD kInjectAccess = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | + PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ; + + if (!existingPids.empty()) { + DWORD pid = existingPids[0]; + std::cout << "[+] Found running Steam process (PID: " << pid << ")" << std::endl; + + if (IsModuleLoaded(pid, L"OpenSteamTool.dll")) { + std::cout << "[!] 当前 Steam 进程已加载过 OpenSteamTool.dll!" << std::endl; + std::cout << "[!] 无需重复注入。" << std::endl; + std::cout << "This console will close in 3 seconds..." << std::endl; + std::this_thread::sleep_for(std::chrono::seconds(3)); + return; + } + + std::cout << "[+] Waiting for steamui.dll to load..." << std::endl; + for (int i = 0; i < 60; ++i) { + if (IsModuleLoaded(pid, L"steamui.dll")) break; + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + } + + std::cout << "[+] Injecting DLL into running Steam..." << std::endl; + HANDLE hProcess = OpenProcess(kInjectAccess, FALSE, pid); + if (hProcess) { + if (InjectDllByHandle(hProcess, dllPath, false)) { + std::cout << "[+] Injection completed successfully." << std::endl; + } else { + std::wcerr << L"[-] Injection failed." << std::endl; + ShowErrorAlert(L"DLL injection into running Steam process failed."); + } + CloseHandle(hProcess); + } else { + std::wcerr << L"[-] OpenProcess failed. Error: " << GetLastError() << std::endl; + ShowErrorAlert(L"Failed to open Steam process. Try running as Administrator."); + } + return; + } + + // Steam is not running: launch it + if (!FileExists(exePath)) { + std::wstring err = L"Error: Target Steam executable does not exist at:\n" + exePath; + std::wcerr << L"[-] " << err << std::endl; + ShowErrorAlert(err); + return; + } + + size_t lastSlash = exePath.find_last_of(L"\\/"); + std::wstring workingDir = (lastSlash == std::wstring::npos) ? L"" : exePath.substr(0, lastSlash); + + STARTUPINFOW si = { sizeof(si) }; + PROCESS_INFORMATION pi = { 0 }; + std::vector cmdBuffer(exePath.begin(), exePath.end()); + cmdBuffer.push_back(L'\0'); + + std::cout << "[+] Launching Steam executable..." << std::endl; + if (!CreateProcessW(nullptr, cmdBuffer.data(), nullptr, nullptr, FALSE, 0, nullptr, + workingDir.empty() ? nullptr : workingDir.c_str(), &si, &pi)) { + std::wstring err = L"CreateProcessW failed. Error: " + std::to_wstring(GetLastError()); + std::wcerr << L"[-] " << err << std::endl; + ShowErrorAlert(err); + return; + } + + std::cout << "[+] Waiting for steamui.dll to load..." << std::endl; + bool moduleFound = false; + auto startTime = std::chrono::steady_clock::now(); + + while (std::chrono::duration_cast(std::chrono::steady_clock::now() - startTime).count() < 30) { + if (IsModuleLoaded(pi.dwProcessId, L"steamui.dll")) { + moduleFound = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + + if (!moduleFound) { + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + std::wcerr << L"[-] Timeout reached. steamui.dll never loaded." << std::endl; + ShowErrorAlert(L"Timeout waiting for Steam UI to initialize."); + return; + } + + std::cout << "[+] Injecting DLL into spawned Steam..." << std::endl; + if (!InjectDllByHandle(pi.hProcess, dllPath, false)) { + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + std::wcerr << L"[-] DLL injection failed." << std::endl; + ShowErrorAlert(L"DLL injection failed."); + return; + } + + std::cout << "[+] Injection completed successfully." << std::endl; + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + } + +} // namespace Injector + +int main(int argc, char* argv[]) { + bool isWatchMode = false; + bool isSilentMode = false; + + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + for (char& c : arg) c = static_cast(tolower(c)); + if (arg == "-watch" || arg == "--watch" || arg == "-daemon" || arg == "/watch") { + isWatchMode = true; + } else if (arg == "-silent" || arg == "--silent" || arg == "-s" || arg == "/s") { + isSilentMode = true; + } + } + + if (isWatchMode || isSilentMode) { + HWND hWnd = GetConsoleWindow(); + if (hWnd) ShowWindow(hWnd, SW_HIDE); + } + + std::wstring baseDir = Injector::GetExecutableDirectory(); + std::wstring iniPath = Injector::GetIniFilePath(L"config.ini"); + std::wstring steamReg = Injector::GetSteamPathFromRegistry(); + + if (!Injector::FileExists(iniPath)) { + std::wstring defaultExe = !steamReg.empty() ? steamReg : L"C:\\Program Files (x86)\\Steam\\steam.exe"; + std::wstring defaultDll = L"OpenSteamTool.dll"; + WritePrivateProfileStringW(L"Settings", L"ExePath", defaultExe.c_str(), iniPath.c_str()); + WritePrivateProfileStringW(L"Settings", L"DllPath", defaultDll.c_str(), iniPath.c_str()); + } + + wchar_t wExeBuffer[MAX_PATH] = { 0 }; + wchar_t wDllBuffer[MAX_PATH] = { 0 }; + GetPrivateProfileStringW(L"Settings", L"ExePath", L"", wExeBuffer, MAX_PATH, iniPath.c_str()); + GetPrivateProfileStringW(L"Settings", L"DllPath", L"", wDllBuffer, MAX_PATH, iniPath.c_str()); + + std::wstring exePath = wExeBuffer; + std::wstring rawDllPath = wDllBuffer; + + if (exePath.empty()) { + exePath = !steamReg.empty() ? steamReg : L"C:\\Program Files (x86)\\Steam\\steam.exe"; + } + if (rawDllPath.empty()) { + rawDllPath = L"OpenSteamTool.dll"; + } + + std::wstring absDllPath = Injector::ResolveAbsoluteDllPath(rawDllPath, baseDir); + + if (isWatchMode) { + return Injector::RunWatcher(baseDir, absDllPath); + } + if (isSilentMode) { + return Injector::RunSilentOnce(baseDir, absDllPath); + } + + Injector::RunInteractive(baseDir, exePath, absDllPath); + return 0; +} diff --git a/src/Injector/Injector.h b/src/Injector/Injector.h new file mode 100644 index 00000000..16bcaa4d --- /dev/null +++ b/src/Injector/Injector.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include + +namespace Injector { + + // Process & Module Inspection + bool IsModuleLoaded(DWORD pid, const std::wstring& moduleName); + std::vector FindProcessesByName(const std::wstring& processName); + + // Injection Primitives + bool InjectDllByHandle(HANDLE hProcess, const std::wstring& dllPath, bool isSilent = false); + + // Path & Registry Resolution + std::wstring GetExecutableDirectory(); + std::wstring GetIniFilePath(const std::wstring& iniFileName); + std::wstring GetSteamPathFromRegistry(); + std::wstring ResolveAbsoluteDllPath(const std::wstring& rawDllPath, const std::wstring& baseDir); + bool FileExists(const std::wstring& filePath); + + // Execution Modes + void RunInteractive(const std::wstring& baseDir, const std::wstring& exePath, const std::wstring& dllPath); + int RunWatcher(const std::wstring& baseDir, const std::wstring& dllPath); + int RunSilentOnce(const std::wstring& baseDir, const std::wstring& dllPath); + + // Logging & Notifications + void LogMessage(const std::wstring& baseDir, const std::string& msg, bool isSilent = false); + void ShowErrorAlert(const std::wstring& message); + +} // namespace Injector From 18ede75d21af812480340bde6fdb80b22085c14a Mon Sep 17 00:00:00 2001 From: mmxlyo Date: Mon, 7 Sep 2026 19:03:23 +0800 Subject: [PATCH 22/30] refactor: move Injector source code from src to tools directory --- build.bat | 5 ++++ src/CMakeLists.txt | 34 ++++------------------------ tools/CMakeLists.txt | 33 +++++++++++++++++++++++++++ {src => tools}/Injector/Injector.cpp | 0 {src => tools}/Injector/Injector.h | 0 5 files changed, 43 insertions(+), 29 deletions(-) rename {src => tools}/Injector/Injector.cpp (100%) rename {src => tools}/Injector/Injector.h (100%) diff --git a/build.bat b/build.bat index 83be242b..0258a458 100644 --- a/build.bat +++ b/build.bat @@ -35,6 +35,11 @@ for %%C in (%CONFIGS%) do ( cmake --build build --config %%C if errorlevel 1 goto :fail + REM ost-Injector and extract_tickets build steps + echo [INFO] Building tool ost-Injector for %%C + cmake --build build --config %%C --target ost-Injector + if errorlevel 1 goto :fail + REM extract_tickets is EXCLUDE_FROM_ALL, so build it explicitly. It lands in REM build\tools\%%C\ rather than the shipped output directory. echo [INFO] Building tool extract_tickets for %%C diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9b2b4cc5..ac9f8f06 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -201,35 +201,11 @@ add_library(xinput1_4 SHARED ) # --------------------------------------------------------------------------- -# ost-Injector — portable injector executable. +# ost-Injector — portable injector executable (defined in tools/CMakeLists.txt). +# Ensure ost-Injector is always built alongside OpenSteamTool. # --------------------------------------------------------------------------- -add_executable(ost-Injector - Injector/Injector.cpp - Injector/Injector.h -) - -set_target_properties(ost-Injector PROPERTIES - OUTPUT_NAME "ost-Injector" -) - -target_link_libraries(ost-Injector PRIVATE - kernel32 - user32 - advapi32 - shell32 -) +if(TARGET ost-Injector) + add_dependencies(OpenSteamTool ost-Injector) +endif() -# Copy portable helper scripts and default config into the target output directory -add_custom_command(TARGET ost-Injector POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/CreateAutoInjectTask.bat" - "$/CreateAutoInjectTask.bat" - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/DeleteAutoInjectTask.bat" - "$/DeleteAutoInjectTask.bat" - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/config.ini" - "$/config.ini" - COMMENT "Copying portable injector scripts and config template" -) diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index 84c34cd0..81922859 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -16,3 +16,36 @@ add_executable(extract_tickets ) target_compile_features(extract_tickets PRIVATE cxx_std_20) +if(WIN32) + add_executable(ost-Injector + Injector/Injector.cpp + Injector/Injector.h + ) + target_compile_features(ost-Injector PRIVATE cxx_std_20) + set_target_properties(ost-Injector PROPERTIES + OUTPUT_NAME "ost-Injector" + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/$" + RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/Release" + RUNTIME_OUTPUT_DIRECTORY_DEBUG "${CMAKE_BINARY_DIR}/Debug" + ) + target_link_libraries(ost-Injector PRIVATE + kernel32 + user32 + advapi32 + shell32 + ) + add_custom_command(TARGET ost-Injector POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/CreateAutoInjectTask.bat" + "$/CreateAutoInjectTask.bat" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/DeleteAutoInjectTask.bat" + "$/DeleteAutoInjectTask.bat" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/../scripts/config.ini" + "$/config.ini" + COMMENT "Copying portable injector scripts and config template" + ) +endif() + diff --git a/src/Injector/Injector.cpp b/tools/Injector/Injector.cpp similarity index 100% rename from src/Injector/Injector.cpp rename to tools/Injector/Injector.cpp diff --git a/src/Injector/Injector.h b/tools/Injector/Injector.h similarity index 100% rename from src/Injector/Injector.h rename to tools/Injector/Injector.h From 868324ae5bf87f5f7e1d5f1607d828fa15ba061e Mon Sep 17 00:00:00 2001 From: mmxlyo Date: Tue, 8 Sep 2026 12:03:46 +0800 Subject: [PATCH 23/30] feat: switch to Diversion shadow module memory isolation - Clone steamclient64.dll into bin\diversion64.dll and load as client_hModule with graceful fallback - Intercept LoadModuleWithPath in SteamUI to redirect steamclient64 to diversion module - Add g_HooksInstalled atomic synchronization barrier between UI and client hooks --- src/Hook/Hooks_SteamUI.cpp | 52 ++++++++++++++++++++++++++++++++++++++ src/dllmain.cpp | 50 ++++++++++++++++++++++++++++-------- src/dllmain.h | 2 ++ 3 files changed, 94 insertions(+), 10 deletions(-) diff --git a/src/Hook/Hooks_SteamUI.cpp b/src/Hook/Hooks_SteamUI.cpp index 325980e6..0d0b5156 100644 --- a/src/Hook/Hooks_SteamUI.cpp +++ b/src/Hook/Hooks_SteamUI.cpp @@ -4,12 +4,62 @@ #include "dllmain.h" #include "steam_messages.pb.h" #include "Utils/HookSupport/VehCommon.h" +#include +#include +#include #include #include #include namespace { + using namespace std::chrono_literals; + constexpr int kMaxRetry = 50; + constexpr auto kRetryInterval = 100ms; + + static bool IsSteamClientPath(const char* path) { + if (!path) return false; + std::string_view p(path); + auto endsWithCi = [](std::string_view str, std::string_view suffix) { + if (str.size() < suffix.size()) return false; + auto end = str.substr(str.size() - suffix.size()); + return _strnicmp(end.data(), suffix.data(), suffix.size()) == 0; + }; + return _stricmp(path, "steamclient64.dll") == 0 || + _stricmp(path, "steamclient.dll") == 0 || + endsWithCi(p, "\\steamclient64.dll") || + endsWithCi(p, "\\steamclient.dll") || + endsWithCi(p, "/steamclient64.dll") || + endsWithCi(p, "/steamclient.dll"); + } + + HOOK_FUNC(LoadModuleWithPath, HMODULE, const char* path, bool flags) + { + LOG_STEAMUI_INFO("LoadModuleWithPath called with path: {}, flags: {}", + path ? path : "(null)", flags); + + const bool isSteamClient = IsSteamClientPath(path); + + if (isSteamClient) { + // Wait for all hooks on client_hModule to be fully initialized + for (int i = 0; i < kMaxRetry && !g_HooksInstalled.load(); ++i) { + LOG_STEAMUI_DEBUG("LoadModuleWithPath: waiting for hooks to be installed... (attempt {}/{})", + i + 1, kMaxRetry); + std::this_thread::sleep_for(kRetryInterval); + } + } + + HMODULE h = oLoadModuleWithPath(path, flags); + + if (isSteamClient && client_hModule) { + LOG_STEAMUI_INFO("LoadModuleWithPath: diverted {} (original {:p}) -> diversion {:p}", + path, static_cast(h), static_cast(client_hModule)); + return reinterpret_cast(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); @@ -99,6 +149,7 @@ namespace Hooks_SteamUI RESOLVE_U(RepeatedFieldUint32_Add); HOOK_BEGIN(); + INSTALL_HOOK_U(LoadModuleWithPath); INSTALL_HOOK_U(FillInAppOverview); INSTALL_HOOK_U(BuildCompleteAppOverviewChange); INSTALL_HOOK_U(CSteamUIAppControllerRunFrame); @@ -108,6 +159,7 @@ namespace Hooks_SteamUI void Uninstall() { UNHOOK_BEGIN(); + UNINSTALL_HOOK(LoadModuleWithPath); UNINSTALL_HOOK(FillInAppOverview); UNINSTALL_HOOK(BuildCompleteAppOverviewChange); UNINSTALL_HOOK(CSteamUIAppControllerRunFrame); diff --git a/src/dllmain.cpp b/src/dllmain.cpp index be83124f..e7b82411 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -28,9 +28,9 @@ bool InitializeSteamComponents(OSTPlatform::DynamicLibrary::ModuleHandle selfMod return false; } sprintf_s(SteamInstallPath, kRuntimePathCapacity, "%s", steamPath.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(SteamclientPath, kRuntimePathCapacity, "%s\\steamclient64.dll", SteamInstallPath); + sprintf_s(SteamUIPath, kRuntimePathCapacity, "%s\\steamui.dll", SteamInstallPath); + sprintf_s(DiversionPath, kRuntimePathCapacity, "%s\\bin\\diversion64.dll", SteamInstallPath); // 2. Locate OpenSteamTool DLL directory (portable mode support). auto dllDir = OSTPlatform::DynamicLibrary::GetModuleDirectory(selfModule); @@ -63,13 +63,38 @@ bool InitializeSteamComponents(OSTPlatform::DynamicLibrary::ModuleHandle selfMod } sprintf_s(LuaDir, kRuntimePathCapacity, "%s", luaPath.c_str()); - client_hModule = OSTPlatform::DynamicLibrary::Load(SteamclientPath); + // 4. 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. + std::filesystem::path diversionFsPath(DiversionPath); + std::error_code ec; + std::filesystem::create_directories(diversionFsPath.parent_path(), ec); + + if (!CopyFileA(SteamclientPath, DiversionPath, FALSE)) { + const DWORD gle = GetLastError(); + if (std::filesystem::exists(diversionFsPath, ec)) { + LOG_WARN("CopyFileA to diversion64.dll failed (err={}), reusing existing diversion file", gle); + } else { + LOG_ERROR("CopyFileA failed: {} -> {} (err={})", SteamclientPath, DiversionPath, gle); + } + } else { + LOG_INFO("Cloned steamclient64.dll -> {}", DiversionPath); + } + + client_hModule = OSTPlatform::DynamicLibrary::Load(DiversionPath); if (!client_hModule) { - LOG_ERROR("Load steamclient64.dll failed: {} (err={})", - SteamclientPath, OSTPlatform::DynamicLibrary::GetLastErrorCode()); - return false; + LOG_WARN("Load diversion module failed (path={}, err={}), falling back to real steamclient64.dll", + DiversionPath, OSTPlatform::DynamicLibrary::GetLastErrorCode()); + client_hModule = OSTPlatform::DynamicLibrary::Load(SteamclientPath); + if (!client_hModule) { + LOG_ERROR("Load steamclient64.dll failed: {} (err={})", + SteamclientPath, OSTPlatform::DynamicLibrary::GetLastErrorCode()); + return false; + } + LOG_INFO("Loaded fallback steamclient64.dll from {}", SteamclientPath); + } else { + LOG_INFO("Loaded diversion module from {}", DiversionPath); } - LOG_INFO("Loaded steamclient64.dll from {}", SteamclientPath); ui_hModule = OSTPlatform::DynamicLibrary::Load(SteamUIPath); if (!ui_hModule) { @@ -103,6 +128,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); @@ -122,7 +151,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 +160,8 @@ static uint32_t InitThread(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) // [cloud].enabled is set and cloud_redirect.dll is present. CloudRedirectHost::Initialize(SteamInstallPath); - LOG_INFO("OpenSteamTool init complete"); + g_HooksInstalled.store(true); + LOG_INFO("OpenSteamTool init complete (Diversion active)"); return 0; } @@ -157,6 +186,7 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved) } else if (dwReason == DLL_PROCESS_DETACH) { + g_HooksInstalled.store(false); // During process termination (pvReserved != nullptr), avoid loader-lock work in // unhooks; only stop file watchers to ensure clean thread termination. if (pvReserved != nullptr) { diff --git a/src/dllmain.h b/src/dllmain.h index 0b88c5d0..5df42fc9 100644 --- a/src/dllmain.h +++ b/src/dllmain.h @@ -29,6 +29,8 @@ inline OSTPlatform::DynamicLibrary::ModuleHandle client_hModule = nullptr; inline OSTPlatform::DynamicLibrary::ModuleHandle ui_hModule = nullptr; +inline std::atomic g_HooksInstalled{false}; + inline constexpr size_t kRuntimePathCapacity = 260; inline char SteamInstallPath[kRuntimePathCapacity] = {}; From 44cf79b4f5ef3c6eb240b3c4da51389c900d6b5a Mon Sep 17 00:00:00 2001 From: mmxlyo Date: Tue, 8 Sep 2026 12:12:12 +0800 Subject: [PATCH 24/30] fix(build): resolve HMODULE undeclared identifier and format specifier in Hooks_SteamUI --- src/Hook/Hooks_SteamUI.cpp | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/src/Hook/Hooks_SteamUI.cpp b/src/Hook/Hooks_SteamUI.cpp index 0d0b5156..1936020b 100644 --- a/src/Hook/Hooks_SteamUI.cpp +++ b/src/Hook/Hooks_SteamUI.cpp @@ -4,6 +4,8 @@ #include "dllmain.h" #include "steam_messages.pb.h" #include "Utils/HookSupport/VehCommon.h" +#include +#include #include #include #include @@ -22,18 +24,29 @@ namespace std::string_view p(path); auto endsWithCi = [](std::string_view str, std::string_view suffix) { if (str.size() < suffix.size()) return false; - auto end = str.substr(str.size() - suffix.size()); - return _strnicmp(end.data(), suffix.data(), suffix.size()) == 0; + return std::equal(suffix.rbegin(), suffix.rend(), str.rbegin(), + [](char a, char b) { + return std::tolower(static_cast(a)) == + std::tolower(static_cast(b)); + }); }; - return _stricmp(path, "steamclient64.dll") == 0 || - _stricmp(path, "steamclient.dll") == 0 || + auto equalsCi = [](std::string_view a, std::string_view b) { + if (a.size() != b.size()) return false; + return std::equal(a.begin(), a.end(), b.begin(), + [](char c1, char c2) { + return std::tolower(static_cast(c1)) == + std::tolower(static_cast(c2)); + }); + }; + return equalsCi(p, "steamclient64.dll") || + equalsCi(p, "steamclient.dll") || endsWithCi(p, "\\steamclient64.dll") || endsWithCi(p, "\\steamclient.dll") || endsWithCi(p, "/steamclient64.dll") || endsWithCi(p, "/steamclient.dll"); } - HOOK_FUNC(LoadModuleWithPath, HMODULE, const char* path, bool flags) + HOOK_FUNC(LoadModuleWithPath, void*, const char* path, bool flags) { LOG_STEAMUI_INFO("LoadModuleWithPath called with path: {}, flags: {}", path ? path : "(null)", flags); @@ -49,12 +62,12 @@ namespace } } - HMODULE h = oLoadModuleWithPath(path, flags); + void* h = oLoadModuleWithPath(path, flags); if (isSteamClient && client_hModule) { - LOG_STEAMUI_INFO("LoadModuleWithPath: diverted {} (original {:p}) -> diversion {:p}", - path, static_cast(h), static_cast(client_hModule)); - return reinterpret_cast(client_hModule); + LOG_STEAMUI_INFO("LoadModuleWithPath: diverted {} (original {}) -> diversion {}", + path ? path : "steamclient64.dll", h, static_cast(client_hModule)); + return client_hModule; } return h; From 4a1fdcf6ea10d7b479ef7478b9c0865a7db5c560 Mon Sep 17 00:00:00 2001 From: mmxlyo Date: Tue, 8 Sep 2026 12:41:43 +0800 Subject: [PATCH 25/30] feat(injector): optimize injector subsystem and heuristics against AV false positives - Switch ost-Injector to native Windows GUI subsystem (WIN32_EXECUTABLE) with wWinMain - Remove ShowWindow(SW_HIDE) call that triggered malware heuristics - Implement EnsureInteractiveConsole for dynamic console attachment in manual mode - Add ost-Injector.rc and app.ico with full PE version metadata and icon - Add standalone zero-dependency C# source Injector.cs and build_injector.bat --- tools/CMakeLists.txt | 2 + tools/Injector/Injector.cpp | 26 +- tools/Injector/Injector.cs | 747 ++++++++++++++++++++++++++++++ tools/Injector/app.ico | Bin 0 -> 2686 bytes tools/Injector/build_injector.bat | 29 ++ tools/Injector/ost-Injector.rc | 36 ++ 6 files changed, 837 insertions(+), 3 deletions(-) create mode 100644 tools/Injector/Injector.cs create mode 100644 tools/Injector/app.ico create mode 100644 tools/Injector/build_injector.bat create mode 100644 tools/Injector/ost-Injector.rc diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index 81922859..77ca13b5 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -20,10 +20,12 @@ if(WIN32) add_executable(ost-Injector Injector/Injector.cpp Injector/Injector.h + Injector/ost-Injector.rc ) target_compile_features(ost-Injector PRIVATE cxx_std_20) set_target_properties(ost-Injector PROPERTIES OUTPUT_NAME "ost-Injector" + WIN32_EXECUTABLE TRUE MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/$" RUNTIME_OUTPUT_DIRECTORY_RELEASE "${CMAKE_BINARY_DIR}/Release" diff --git a/tools/Injector/Injector.cpp b/tools/Injector/Injector.cpp index 8461d4de..84bcd9ba 100644 --- a/tools/Injector/Injector.cpp +++ b/tools/Injector/Injector.cpp @@ -200,6 +200,20 @@ namespace Injector { MessageBoxW(NULL, message.c_str(), L"OpenSteamTool Injector Error", MB_OK | MB_ICONERROR | MB_SETFOREGROUND); } + void EnsureInteractiveConsole() { + HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE); + DWORD fileType = (hOut != NULL && hOut != INVALID_HANDLE_VALUE) ? GetFileType(hOut) : FILE_TYPE_UNKNOWN; + if (fileType == FILE_TYPE_UNKNOWN) { + if (!AttachConsole(ATTACH_PARENT_PROCESS)) { + AllocConsole(); + } + FILE* fp = nullptr; + freopen_s(&fp, "CONOUT$", "w", stdout); + freopen_s(&fp, "CONOUT$", "w", stderr); + freopen_s(&fp, "CONIN$", "r", stdin); + } + } + int RunWatcher(const std::wstring& baseDir, const std::wstring& dllPath) { HANDLE hMutex = CreateMutexW(NULL, TRUE, L"Global\\OpenSteamTool_AutoInject_Watcher"); if (!hMutex && GetLastError() == ERROR_ACCESS_DENIED) { @@ -448,9 +462,8 @@ int main(int argc, char* argv[]) { } } - if (isWatchMode || isSilentMode) { - HWND hWnd = GetConsoleWindow(); - if (hWnd) ShowWindow(hWnd, SW_HIDE); + if (!isWatchMode && !isSilentMode) { + Injector::EnsureInteractiveConsole(); } std::wstring baseDir = Injector::GetExecutableDirectory(); @@ -491,3 +504,10 @@ int main(int argc, char* argv[]) { Injector::RunInteractive(baseDir, exePath, absDllPath); return 0; } + +#if defined(_WIN32) +int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PWSTR pCmdLine, int nCmdShow) { + return main(__argc, __argv); +} +#endif + diff --git a/tools/Injector/Injector.cs b/tools/Injector/Injector.cs new file mode 100644 index 00000000..be711d6a --- /dev/null +++ b/tools/Injector/Injector.cs @@ -0,0 +1,747 @@ +using System; +using System.IO; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.Win32; + +[assembly: AssemblyTitle("OpenSteamTool Auto Injector")] +[assembly: AssemblyDescription("OpenSteamTool Portable Background Helper and Auto Injector")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("OpenSteamTool")] +[assembly: AssemblyProduct("OpenSteamTool")] +[assembly: AssemblyCopyright("Copyright © 2024-2026 OpenSteamTool")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] + +namespace OpenSteamToolInjector +{ + class Program + { + #region Win32 API + + const uint PROCESS_ALL_ACCESS = 0x1F0FFF; + const uint PROCESS_CREATE_THREAD = 0x0002; + const uint PROCESS_QUERY_INFORMATION = 0x0400; + const uint PROCESS_VM_OPERATION = 0x0008; + const uint PROCESS_VM_WRITE = 0x0020; + const uint PROCESS_VM_READ = 0x0010; + + const uint MEM_COMMIT = 0x1000; + const uint MEM_RESERVE = 0x2000; + const uint MEM_RELEASE = 0x8000; + const uint PAGE_READWRITE = 0x04; + + const uint INFINITE = 0xFFFFFFFF; + + const uint TH32CS_SNAPMODULE = 0x00000008; + const uint TH32CS_SNAPMODULE32 = 0x00000010; + + const int STD_OUTPUT_HANDLE = -11; + const int STD_INPUT_HANDLE = -10; + const int STD_ERROR_HANDLE = -12; + const int ATTACH_PARENT_PROCESS = -1; + const uint FILE_TYPE_UNKNOWN = 0x0000; + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + struct MODULEENTRY32 + { + public uint dwSize; + public uint th32ModuleID; + public uint th32ProcessID; + public uint GlblcntUsage; + public uint ProccntUsage; + public IntPtr modBaseAddr; + public uint modBaseSize; + public IntPtr hModule; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] + public string szModule; + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + public string szExePath; + } + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr CreateToolhelp32Snapshot(uint dwFlags, uint th32ProcessID); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + static extern bool Module32First(IntPtr hSnapshot, ref MODULEENTRY32 lpme); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + static extern bool Module32Next(IntPtr hSnapshot, ref MODULEENTRY32 lpme); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr OpenProcess(uint dwDesiredAccess, bool bInheritHandle, int dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)] + static extern IntPtr VirtualAllocEx(IntPtr hProcess, IntPtr lpAddress, IntPtr dwSize, uint flAllocationType, uint flProtect); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool VirtualFreeEx(IntPtr hProcess, IntPtr lpAddress, IntPtr dwSize, uint dwFreeType); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, IntPtr nSize, out IntPtr lpNumberOfBytesWritten); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + static extern IntPtr GetModuleHandle(string lpModuleName); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi, ExactSpelling = true)] + static extern IntPtr GetProcAddress(IntPtr hModule, string procName); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr CreateRemoteThread(IntPtr hProcess, IntPtr lpThreadAttributes, IntPtr dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetExitCodeThread(IntPtr hThread, out uint lpExitCode); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + static extern uint GetPrivateProfileString(string lpAppName, string lpKeyName, string lpDefault, StringBuilder lpReturnedString, uint nSize, string lpFileName); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + static extern bool WritePrivateProfileString(string lpAppName, string lpKeyName, string lpString, string lpFileName); + + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + static extern int MessageBox(IntPtr hWnd, string text, string caption, uint type); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool AttachConsole(int dwProcessId); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool AllocConsole(); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr GetStdHandle(int nStdHandle); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern uint GetFileType(IntPtr hFile); + + #endregion + + static void EnsureInteractiveConsole() + { + try + { + IntPtr hOut = GetStdHandle(STD_OUTPUT_HANDLE); + uint fileType = (hOut != IntPtr.Zero && hOut != new IntPtr(-1)) ? GetFileType(hOut) : FILE_TYPE_UNKNOWN; + + if (fileType == FILE_TYPE_UNKNOWN) + { + if (!AttachConsole(ATTACH_PARENT_PROCESS)) + { + AllocConsole(); + } + hOut = GetStdHandle(STD_OUTPUT_HANDLE); + } + + if (hOut != IntPtr.Zero && hOut != new IntPtr(-1)) + { + Microsoft.Win32.SafeHandles.SafeFileHandle safeOut = new Microsoft.Win32.SafeHandles.SafeFileHandle(hOut, false); + FileStream fsOut = new FileStream(safeOut, FileAccess.Write); + StreamWriter writer = new StreamWriter(fsOut, Console.OutputEncoding) { AutoFlush = true }; + Console.SetOut(writer); + Console.SetError(writer); + } + + IntPtr hIn = GetStdHandle(STD_INPUT_HANDLE); + if (hIn != IntPtr.Zero && hIn != new IntPtr(-1)) + { + Microsoft.Win32.SafeHandles.SafeFileHandle safeIn = new Microsoft.Win32.SafeHandles.SafeFileHandle(hIn, false); + FileStream fsIn = new FileStream(safeIn, FileAccess.Read); + StreamReader reader = new StreamReader(fsIn, Console.InputEncoding); + Console.SetIn(reader); + } + } + catch { } + } + + static void SafeSetColor(ConsoleColor color) + { + try { Console.ForegroundColor = color; } catch { } + } + + static void SafeResetColor() + { + try { Console.ResetColor(); } catch { } + } + + static void SafeSetTitle(string title) + { + try { Console.Title = title; } catch { } + } + + static string GetSteamPathFromRegistry() + { + try + { + using (RegistryKey key = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Valve\Steam")) + { + if (key != null) + { + object val = key.GetValue("SteamExe"); + if (val != null && !string.IsNullOrEmpty(val.ToString())) + { + string path = val.ToString().Replace('/', '\\'); + if (File.Exists(path)) + return path; + } + + object pathVal = key.GetValue("SteamPath"); + if (pathVal != null && !string.IsNullOrEmpty(pathVal.ToString())) + { + string combined = Path.Combine(pathVal.ToString().Replace('/', '\\'), "steam.exe"); + if (File.Exists(combined)) + return combined; + } + } + } + } + catch { } + return string.Empty; + } + + static void ReadIniSettings(string iniPath, out string exePath, out string dllPath) + { + exePath = ""; + dllPath = ""; + if (!File.Exists(iniPath)) return; + + try + { + string currentSection = ""; + foreach (string rawLine in File.ReadAllLines(iniPath, Encoding.UTF8)) + { + string line = rawLine.Trim(); + if (string.IsNullOrEmpty(line) || line.StartsWith(";") || line.StartsWith("#")) + continue; + + if (line.StartsWith("[") && line.EndsWith("]")) + { + currentSection = line.Substring(1, line.Length - 2).Trim(); + continue; + } + + int eq = line.IndexOf('='); + if (eq > 0 && currentSection.Equals("Settings", StringComparison.OrdinalIgnoreCase)) + { + string key = line.Substring(0, eq).Trim(); + string val = line.Substring(eq + 1).Trim(); + if (key.Equals("ExePath", StringComparison.OrdinalIgnoreCase)) exePath = val; + else if (key.Equals("DllPath", StringComparison.OrdinalIgnoreCase)) dllPath = val; + } + } + } + catch { } + } + + static bool IsModuleLoaded(int pid, string targetModuleName) + { + IntPtr hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, (uint)pid); + if (hSnap == IntPtr.Zero || hSnap == (IntPtr)(-1)) + { + try + { + Process p = Process.GetProcessById(pid); + foreach (ProcessModule m in p.Modules) + { + if (string.Equals(m.ModuleName, targetModuleName, StringComparison.OrdinalIgnoreCase)) + return true; + } + } + catch { } + return false; + } + + try + { + MODULEENTRY32 me = new MODULEENTRY32(); + me.dwSize = (uint)Marshal.SizeOf(typeof(MODULEENTRY32)); + + if (Module32First(hSnap, ref me)) + { + do + { + if (string.Equals(me.szModule, targetModuleName, StringComparison.OrdinalIgnoreCase)) + return true; + } + while (Module32Next(hSnap, ref me)); + } + } + finally + { + CloseHandle(hSnap); + } + return false; + } + + static bool InjectDllByHandle(IntPtr hProcess, string dllPath, bool isSilent = false) + { + byte[] bytes = Encoding.Unicode.GetBytes(dllPath + "\0"); + IntPtr size = new IntPtr(bytes.Length); + + IntPtr remoteMem = VirtualAllocEx(hProcess, IntPtr.Zero, size, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if (remoteMem == IntPtr.Zero) + { + if (!isSilent) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("[-] VirtualAllocEx failed. Error: " + Marshal.GetLastWin32Error()); + Console.ResetColor(); + } + return false; + } + + try + { + IntPtr written; + if (!WriteProcessMemory(hProcess, remoteMem, bytes, size, out written)) + { + if (!isSilent) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("[-] WriteProcessMemory failed. Error: " + Marshal.GetLastWin32Error()); + Console.ResetColor(); + } + return false; + } + + IntPtr hKernel32 = GetModuleHandle("kernel32.dll"); + IntPtr loadLibraryWAddr = GetProcAddress(hKernel32, "LoadLibraryW"); + if (loadLibraryWAddr == IntPtr.Zero) + { + if (!isSilent) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("[-] Failed to find LoadLibraryW. Error: " + Marshal.GetLastWin32Error()); + Console.ResetColor(); + } + return false; + } + + IntPtr hThread = CreateRemoteThread(hProcess, IntPtr.Zero, IntPtr.Zero, loadLibraryWAddr, remoteMem, 0, IntPtr.Zero); + if (hThread == IntPtr.Zero) + { + if (!isSilent) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("[-] CreateRemoteThread failed. Error: " + Marshal.GetLastWin32Error()); + Console.ResetColor(); + } + return false; + } + + try + { + WaitForSingleObject(hThread, INFINITE); + uint exitCode; + if (GetExitCodeThread(hThread, out exitCode)) + { + if (exitCode == 0) + { + if (!isSilent) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine("[-] Warning: LoadLibraryW returned 0 (NULL). DLL might have failed in DllMain or dependencies missing."); + Console.ResetColor(); + } + return false; + } + } + return true; + } + finally + { + CloseHandle(hThread); + } + } + finally + { + VirtualFreeEx(hProcess, remoteMem, IntPtr.Zero, MEM_RELEASE); + } + } + + static void ShowErrorAlert(string message) + { + MessageBox(IntPtr.Zero, message, "OpenSteamTool Injector Error", 0x10 | 0x10000); + } + + static void LogMessage(string baseDir, string msg, bool isSilent = false) + { + if (!isSilent) + { + Console.WriteLine(msg); + } + try + { + string logFile = Path.Combine(baseDir, "inject.log"); + File.AppendAllText(logFile, string.Format("[{0:yyyy-MM-dd HH:mm:ss}] {1}\r\n", DateTime.Now, msg)); + } + catch { } + } + + static void RunWatcher(string baseDir, string absDllPath) + { + bool createdNew; + using (Mutex mutex = new Mutex(true, "Global\\OpenSteamTool_AutoInject_Watcher", out createdNew)) + { + if (!createdNew) + { + // 已有监听实例在运行,直接退出 + return; + } + + LogMessage(baseDir, "[Watcher] 自动注入后台监听已启动,等待 steam.exe 启动...", true); + + HashSet injectedPids = new HashSet(); + + while (true) + { + try + { + Process[] steams = Process.GetProcessesByName("steam"); + if (steams.Length > 0) + { + foreach (Process p in steams) + { + int pid = p.Id; + if (!injectedPids.Contains(pid)) + { + if (IsModuleLoaded(pid, "OpenSteamTool.dll")) + { + injectedPids.Add(pid); + continue; + } + + // 等待 steamui.dll 准备就绪 + bool uiReady = false; + for (int i = 0; i < 60; i++) + { + if (p.HasExited) break; + if (IsModuleLoaded(pid, "steamui.dll")) + { + uiReady = true; + break; + } + Thread.Sleep(500); + } + + if (uiReady && !p.HasExited) + { + // 稍微延迟 500ms 保证初始化完全 + Thread.Sleep(500); + + uint access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ; + IntPtr hProcess = OpenProcess(access, false, pid); + if (hProcess != IntPtr.Zero) + { + try + { + if (InjectDllByHandle(hProcess, absDllPath, true)) + { + injectedPids.Add(pid); + LogMessage(baseDir, string.Format("[Watcher] 成功自动注入 OpenSteamTool 到 Steam (PID: {0})", pid), true); + } + else + { + LogMessage(baseDir, string.Format("[Watcher] 注入失败 (PID: {0})", pid), true); + } + } + finally + { + CloseHandle(hProcess); + } + } + } + } + } + } + else + { + if (injectedPids.Count > 0) + { + injectedPids.Clear(); + } + } + } + catch { } + + Thread.Sleep(1500); + } + } + } + + static int RunSilentOnce(string baseDir, string absDllPath) + { + try + { + Process[] steams = Process.GetProcessesByName("steam"); + if (steams.Length == 0) + { + return 0; + } + + Process target = steams[0]; + int pid = target.Id; + + if (IsModuleLoaded(pid, "OpenSteamTool.dll")) + { + return 0; + } + + bool uiReady = false; + for (int i = 0; i < 60; i++) + { + if (target.HasExited) return 0; + if (IsModuleLoaded(pid, "steamui.dll")) + { + uiReady = true; + break; + } + Thread.Sleep(500); + } + + if (!uiReady || target.HasExited) return 0; + + Thread.Sleep(500); + + uint access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ; + IntPtr hProcess = OpenProcess(access, false, pid); + if (hProcess != IntPtr.Zero) + { + try + { + if (InjectDllByHandle(hProcess, absDllPath, true)) + { + LogMessage(baseDir, string.Format("[Silent] 成功静默注入 OpenSteamTool 到 Steam (PID: {0})", pid), true); + return 0; + } + else + { + LogMessage(baseDir, string.Format("[Silent] 注入失败 (PID: {0})", pid), true); + return 1; + } + } + finally + { + CloseHandle(hProcess); + } + } + } + catch (Exception ex) + { + LogMessage(baseDir, "[Silent] 异常: " + ex.Message, true); + } + return 0; + } + + static int Main(string[] args) + { + bool isWatchMode = false; + bool isSilentMode = false; + + foreach (string arg in args) + { + string a = arg.Trim().ToLowerInvariant(); + if (a == "-watch" || a == "--watch" || a == "-daemon" || a == "/watch") + { + isWatchMode = true; + } + else if (a == "-silent" || a == "--silent" || a == "-s" || a == "/s") + { + isSilentMode = true; + } + } + + // 只有非静默且非后台监听模式(交互模式)才动态接入或分配控制台 + if (!isWatchMode && !isSilentMode) + { + EnsureInteractiveConsole(); + } + + string baseDir = AppDomain.CurrentDomain.BaseDirectory; + string iniPath = Path.Combine(baseDir, "config.ini"); + string steamReg = GetSteamPathFromRegistry(); + + if (!File.Exists(iniPath)) + { + string defaultExe = !string.IsNullOrEmpty(steamReg) ? steamReg : @"C:\Program Files (x86)\Steam\steam.exe"; + string defaultDll = @"C:\Program Files (x86)\Steam\OpenSteamTool.dll"; + File.WriteAllText(iniPath, "[Settings]\r\nExePath=" + defaultExe + "\r\nDllPath=" + defaultDll + "\r\n", Encoding.ASCII); + } + + string exePath = ""; + string dllPath = ""; + ReadIniSettings(iniPath, out exePath, out dllPath); + + if (string.IsNullOrEmpty(exePath)) + { + exePath = !string.IsNullOrEmpty(steamReg) ? steamReg : @"C:\Program Files (x86)\Steam\steam.exe"; + } + + if (string.IsNullOrEmpty(dllPath)) + { + dllPath = @"C:\Program Files (x86)\Steam\OpenSteamTool.dll"; + } + + string absDllPath = Path.IsPathRooted(dllPath) ? dllPath : Path.GetFullPath(Path.Combine(baseDir, dllPath)); + + // 模式 1:后台常驻监听模式 (-watch) + if (isWatchMode) + { + RunWatcher(baseDir, absDllPath); + return 0; + } + + // 模式 2:单次静默注入模式 (-silent) + if (isSilentMode) + { + return RunSilentOnce(baseDir, absDllPath); + } + + // 模式 3:常规交互式控制台模式 (直接双击) + SafeSetTitle("OpenSteamTool Auto Injector"); + SafeSetColor(ConsoleColor.Cyan); + Console.WriteLine("================================================="); + Console.WriteLine(" OpenSteamTool Auto Injector "); + Console.WriteLine(" Supported modes: manual, -silent, -watch "); + Console.WriteLine("================================================="); + SafeResetColor(); + Console.WriteLine(); + + try + { + Console.WriteLine("[+] Target Executable : " + exePath); + Console.WriteLine("[+] Payload DLL : " + absDllPath); + Console.WriteLine(); + + if (!File.Exists(absDllPath)) + { + string err = "Error: Payload DLL was not found at:\n" + absDllPath + "\n\nPlease ensure OpenSteamTool.dll exists."; + SafeSetColor(ConsoleColor.Red); + Console.WriteLine("[-] " + err); + SafeResetColor(); + ShowErrorAlert(err); + return 1; + } + + Process targetProcess = null; + Process[] existing = Process.GetProcessesByName("steam"); + if (existing.Length > 0) + { + targetProcess = existing[0]; + SafeSetColor(ConsoleColor.Green); + Console.WriteLine("[+] Found running Steam process (PID: " + targetProcess.Id + ")"); + SafeResetColor(); + + if (IsModuleLoaded(targetProcess.Id, "OpenSteamTool.dll")) + { + SafeSetColor(ConsoleColor.Yellow); + Console.WriteLine("[!] 当前 Steam 进程已加载过 OpenSteamTool.dll!"); + Console.WriteLine("[!] 无需重复注入。"); + SafeResetColor(); + Console.WriteLine(); + Console.WriteLine("This console will close in 3 seconds..."); + Thread.Sleep(3000); + return 0; + } + } + + if (targetProcess == null) + { + if (!File.Exists(exePath)) + { + string err = "Error: Target Steam executable does not exist at:\n" + exePath; + SafeSetColor(ConsoleColor.Red); + Console.WriteLine("[-] " + err); + SafeResetColor(); + ShowErrorAlert(err); + return 1; + } + + Console.WriteLine("[+] Launching Steam process..."); + ProcessStartInfo psi = new ProcessStartInfo(); + psi.FileName = exePath; + psi.WorkingDirectory = Path.GetDirectoryName(exePath); + psi.UseShellExecute = true; + targetProcess = Process.Start(psi); + Console.WriteLine("[+] Steam launched (PID: " + targetProcess.Id + ")"); + } + + Console.WriteLine("[+] Waiting for steamui.dll to be loaded in Steam process..."); + bool moduleFound = false; + DateTime startWait = DateTime.UtcNow; + + while ((DateTime.UtcNow - startWait).TotalSeconds < 30) + { + if (IsModuleLoaded(targetProcess.Id, "steamui.dll")) + { + moduleFound = true; + break; + } + Thread.Sleep(200); + } + + if (!moduleFound) + { + throw new TimeoutException("Timeout reached: steamui.dll was not loaded within 30 seconds."); + } + + SafeSetColor(ConsoleColor.Green); + Console.WriteLine("[+] steamui.dll detected in Steam process!"); + SafeResetColor(); + + Console.WriteLine("[+] Injecting OpenSteamTool.dll into Steam..."); + uint access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ; + IntPtr hProcess = OpenProcess(access, false, targetProcess.Id); + + if (hProcess == IntPtr.Zero) + { + throw new InvalidOperationException("Failed to open Steam process. Try running as Administrator. Error: " + Marshal.GetLastWin32Error()); + } + + try + { + if (!InjectDllByHandle(hProcess, absDllPath)) + { + throw new InvalidOperationException("DLL injection failed into Steam process."); + } + } + finally + { + CloseHandle(hProcess); + } + + SafeSetColor(ConsoleColor.Green); + Console.WriteLine(); + Console.WriteLine("[+] ============================================="); + Console.WriteLine("[+] SUCCESS: OpenSteamTool injected perfectly! "); + Console.WriteLine("[+] ============================================="); + SafeResetColor(); + Console.WriteLine(); + Console.WriteLine("This console will close in 3 seconds..."); + Thread.Sleep(3000); + return 0; + } + catch (Exception ex) + { + SafeSetColor(ConsoleColor.Red); + Console.WriteLine(); + Console.WriteLine("[-] Error: " + ex.Message); + SafeResetColor(); + ShowErrorAlert(ex.Message); + Console.WriteLine("Press any key to exit..."); + try { Console.ReadKey(); } catch { Console.ReadLine(); } + return 1; + } + } + } +} diff --git a/tools/Injector/app.ico b/tools/Injector/app.ico new file mode 100644 index 0000000000000000000000000000000000000000..f21a3ac419dbe346215d1215a5463c306d53ea46 GIT binary patch literal 2686 zcmeHJyN=UP5WTUHBGH73YK5(&p^VlI1(NeAThj6cHq~~UPuOpurbr|Tm-zrB2%<=v zC- z_XW@q@~FuU&Uj0Y1MtQ%7^^Yz0q%t^pWiD0-*5BUjW59AA!rFjif9hcZ zeb_L0tPpFAWxxSrt_m}hS{kWYBzj7uPWj1H!>}bu!uUiTJgT+)BuS=NYOy==v5!lV zTS}-+k9=Z$2)~xN!Yge`y0`k)4$@7h32AxjFSM|RMWyCwypGnmfI-WLHZGsFHH!@U zyj_atnGoJKVj5}XasMzaZK{mv@RkyXKsSg&efSl<0cF@ zS`$yw{q1M~<2#q7=@_TqZ@)TYy5An_!Do6vcg=9r{G9{LoDDhnm1)mG4-V-0CenU)`a+4uEbLm4WQf*x{ zk3ip)oxJ~#81E{;J56)I6!_ab44^_SVcv} zvfGrnkoBE=OZ>2OH^iZ0TBBR?6`MhPnHg@ya96pAe&CRGb|OE4E8vsIx%MSO-0YFI8<4gVXm@@)`B^!%nbx3>ogzMK#A5-l>z4>EG?g*LBzv7v5cyS OSUZ;yWgRN~+4c)NnGzcS literal 0 HcmV?d00001 diff --git a/tools/Injector/build_injector.bat b/tools/Injector/build_injector.bat new file mode 100644 index 00000000..c7394f1e --- /dev/null +++ b/tools/Injector/build_injector.bat @@ -0,0 +1,29 @@ +@echo off +setlocal +echo [OpenSteamTool] Compiling portable Injector (C#)... + +set "CSC=%SystemRoot%\Microsoft.NET\Framework64\v4.0.30319\csc.exe" +if not exist "%CSC%" set "CSC=%SystemRoot%\Microsoft.NET\Framework\v4.0.30319\csc.exe" + +if not exist "%CSC%" ( + echo [Error] csc.exe compiler not found! + pause + exit /b 1 +) + +"%CSC%" /nologo /target:winexe /platform:x64 /optimize+ /win32icon:"%~dp0app.ico" /out:"%~dp0ost-Injector.exe" "%~dp0Injector.cs" + +if %ERRORLEVEL% equ 0 ( + echo. + echo ======================================================= + echo [SUCCESS] ost-Injector.exe compiled successfully! + echo 1. Native WinExe Subsystem without window-hiding hack + echo 2. Full PE version metadata and embedded icon + echo 3. Automatic console attachment for manual launch + echo ======================================================= +) else ( + echo. + echo [ERROR] Compilation failed! +) + +pause diff --git a/tools/Injector/ost-Injector.rc b/tools/Injector/ost-Injector.rc new file mode 100644 index 00000000..2a713d44 --- /dev/null +++ b/tools/Injector/ost-Injector.rc @@ -0,0 +1,36 @@ +#include + +1 ICON "app.ico" + +VS_VERSION_INFO VERSIONINFO +FILEVERSION 1,0,0,0 +PRODUCTVERSION 1,0,0,0 +FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG +FILEFLAGS VS_FF_DEBUG +#else +FILEFLAGS 0x0L +#endif +FILEOS VOS_NT_WINDOWS32 +FILETYPE VFT_APP +FILESUBTYPE VFT2_UNKNOWN +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "OpenSteamTool" + VALUE "FileDescription", "OpenSteamTool Portable Helper and Auto Injector" + VALUE "FileVersion", "1.0.0.0" + VALUE "InternalName", "ost-Injector.exe" + VALUE "LegalCopyright", "Copyright (C) 2024-2026 OpenSteamTool" + VALUE "OriginalFilename", "ost-Injector.exe" + VALUE "ProductName", "OpenSteamTool" + VALUE "ProductVersion", "1.0.0.0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END From b85483fda991dc3f3a6721df42960f54c0c9d60a Mon Sep 17 00:00:00 2001 From: mmxlyo Date: Tue, 8 Sep 2026 13:04:16 +0800 Subject: [PATCH 26/30] fix(injector): fix wWinMain argv crash and enhance watcher PID lifecycle --- scripts/CreateAutoInjectTask.bat | 6 ++++-- tools/Injector/Injector.cpp | 21 ++++++++++++++++++++- tools/Injector/Injector.cs | 23 +++++++++++++++++++++-- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/scripts/CreateAutoInjectTask.bat b/scripts/CreateAutoInjectTask.bat index c2e28318..65a01755 100644 --- a/scripts/CreateAutoInjectTask.bat +++ b/scripts/CreateAutoInjectTask.bat @@ -10,8 +10,10 @@ if %errorlevel% equ 0 ( echo. echo ======================================================= echo [SUCCESS] Scheduled task "OpenSteamTool_AutoInject" created! - echo The background watcher will start upon logon and automatically - echo inject OpenSteamTool.dll whenever Steam starts. + echo Starting background watcher service right now... + schtasks /run /tn "OpenSteamTool_AutoInject" + echo The background watcher is now running and will auto-start upon logon, + echo automatically injecting OpenSteamTool.dll whenever Steam starts. echo ======================================================= ) else ( echo. diff --git a/tools/Injector/Injector.cpp b/tools/Injector/Injector.cpp index 84bcd9ba..a4b37b78 100644 --- a/tools/Injector/Injector.cpp +++ b/tools/Injector/Injector.cpp @@ -507,7 +507,26 @@ int main(int argc, char* argv[]) { #if defined(_WIN32) int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PWSTR pCmdLine, int nCmdShow) { - return main(__argc, __argv); + int argc = 0; + LPWSTR* argvW = CommandLineToArgvW(GetCommandLineW(), &argc); + std::vector args; + if (argvW) { + for (int i = 0; i < argc; ++i) { + int size_needed = WideCharToMultiByte(CP_UTF8, 0, argvW[i], -1, NULL, 0, NULL, NULL); + std::string strTo(size_needed, 0); + WideCharToMultiByte(CP_UTF8, 0, argvW[i], -1, &strTo[0], size_needed, NULL, NULL); + if (!strTo.empty() && strTo.back() == '\0') strTo.pop_back(); + args.push_back(strTo); + } + LocalFree(argvW); + } + std::vector argvPtrs; + for (auto& s : args) { + argvPtrs.push_back(&s[0]); + } + argvPtrs.push_back(nullptr); + return main(argc, argvPtrs.data()); } #endif + diff --git a/tools/Injector/Injector.cs b/tools/Injector/Injector.cs index be711d6a..4e3e981f 100644 --- a/tools/Injector/Injector.cs +++ b/tools/Injector/Injector.cs @@ -395,12 +395,17 @@ static void RunWatcher(string baseDir, string absDllPath) { if (!createdNew) { - // 已有监听实例在运行,直接退出 + LogMessage(baseDir, "[Watcher] 检测到已有另一个后台监听实例在运行,本实例自动退出。", true); return; } LogMessage(baseDir, "[Watcher] 自动注入后台监听已启动,等待 steam.exe 启动...", true); + if (!File.Exists(absDllPath)) + { + LogMessage(baseDir, "[Watcher] 警告: 未找到 Payload DLL 文件: " + absDllPath, true); + } + HashSet injectedPids = new HashSet(); while (true) @@ -410,6 +415,13 @@ static void RunWatcher(string baseDir, string absDllPath) Process[] steams = Process.GetProcessesByName("steam"); if (steams.Length > 0) { + HashSet currentPids = new HashSet(); + foreach (Process p in steams) + { + currentPids.Add(p.Id); + } + injectedPids.RemoveWhere(pid => !currentPids.Contains(pid)); + foreach (Process p in steams) { int pid = p.Id; @@ -460,6 +472,10 @@ static void RunWatcher(string baseDir, string absDllPath) CloseHandle(hProcess); } } + else + { + LogMessage(baseDir, string.Format("[Watcher] 无法打开 Steam 进程 (PID: {0}),错误码: {1}。若 Steam 以管理员运行,请以管理员身份运行注入器。", pid, Marshal.GetLastWin32Error()), true); + } } } } @@ -472,7 +488,10 @@ static void RunWatcher(string baseDir, string absDllPath) } } } - catch { } + catch (Exception ex) + { + LogMessage(baseDir, "[Watcher] 循环异常: " + ex.Message, true); + } Thread.Sleep(1500); } From 0d79b825f138e886c8ef3686adbbfe34d522b946 Mon Sep 17 00:00:00 2001 From: mmxlyo Date: Tue, 8 Sep 2026 13:45:27 +0800 Subject: [PATCH 27/30] feat(extract_tickets): auto-generate ready-to-use .lua config alongside tickets.txt --- tools/extract_tickets/ConvertTicketsToLua.bat | 4 + tools/extract_tickets/ConvertTicketsToLua.ps1 | 77 +++++++++++++++++++ tools/extract_tickets/extract_tickets.cpp | 28 ++++++- 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tools/extract_tickets/ConvertTicketsToLua.bat create mode 100644 tools/extract_tickets/ConvertTicketsToLua.ps1 diff --git a/tools/extract_tickets/ConvertTicketsToLua.bat b/tools/extract_tickets/ConvertTicketsToLua.bat new file mode 100644 index 00000000..8df29292 --- /dev/null +++ b/tools/extract_tickets/ConvertTicketsToLua.bat @@ -0,0 +1,4 @@ +@echo off +chcp 65001 >nul +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ConvertTicketsToLua.ps1" "%~1" +pause diff --git a/tools/extract_tickets/ConvertTicketsToLua.ps1 b/tools/extract_tickets/ConvertTicketsToLua.ps1 new file mode 100644 index 00000000..39eb452e --- /dev/null +++ b/tools/extract_tickets/ConvertTicketsToLua.ps1 @@ -0,0 +1,77 @@ +param( + [string]$Target = "" +) + +if ([string]::IsNullOrWhiteSpace($Target)) { + $Target = $PSScriptRoot +} + +Write-Host "=======================================================" -ForegroundColor Cyan +Write-Host " OpenSteamTool - Convert tickets.txt to Lua Script" -ForegroundColor Cyan +Write-Host "=======================================================" -ForegroundColor Cyan +Write-Host "" + +$files = @() +if (Test-Path $Target -PathType Leaf) { + $files += (Get-Item $Target) +} elseif (Test-Path $Target -PathType Container) { + $files += (Get-ChildItem -Path $Target -Filter "tickets.txt" -Recurse) +} + +if ($files.Count -eq 0) { + Write-Host "[Warning] No tickets.txt found in target path." -ForegroundColor Yellow + exit 0 +} + +foreach ($f in $files) { + Write-Host ("[Processing] " + $f.FullName) -ForegroundColor Cyan + $lines = Get-Content $f.FullName + $appId = "" + $appTicket = "" + $eTicket = "" + + foreach ($line in $lines) { + $l = $line.Trim() + if ($l -match '^appid\s*:\s*(\d+)') { + $appId = $matches[1] + } elseif ($l -match '^appticket[^:]*:\s*([0-9a-fA-F]+)') { + $appTicket = $matches[1] + } elseif ($l -match '^eticket[^:]*:\s*([0-9a-fA-F]+)') { + $eTicket = $matches[1] + } + } + + if ([string]::IsNullOrEmpty($appId)) { + Write-Host ("[-] Skip: AppID not found in " + $f.Name) -ForegroundColor Red + continue + } + + $outDir = $f.DirectoryName + $outLua = Join-Path $outDir ($appId + ".lua") + + $luaContent = @() + $luaContent += ("-- Auto-generated Lua config for AppID: " + $appId) + $luaContent += ("addappid(" + $appId + ")") + $luaContent += "" + + if (![string]::IsNullOrEmpty($appTicket)) { + $luaContent += "-- App Ownership Ticket (AppTicket)" + $luaContent += ("setAppTicket(" + $appId + ', "' + $appTicket + '")') + $luaContent += "" + } + + if (![string]::IsNullOrEmpty($eTicket)) { + $luaContent += "-- Encrypted App Ticket (ETicket)" + $luaContent += ("setETicket(" + $appId + ', "' + $eTicket + '")') + $luaContent += "" + } + + [System.IO.File]::WriteAllLines($outLua, $luaContent, [System.Text.Encoding]::UTF8) + Write-Host ("[+] Successfully generated: " + $outLua) -ForegroundColor Green +} + +Write-Host "" +Write-Host "=======================================================" -ForegroundColor Cyan +Write-Host "Done. You can copy the generated .lua file directly" -ForegroundColor Green +Write-Host "to your OpenSteamTool config/lua/ folder." -ForegroundColor Green +Write-Host "=======================================================" -ForegroundColor Cyan diff --git a/tools/extract_tickets/extract_tickets.cpp b/tools/extract_tickets/extract_tickets.cpp index a13ab83e..fda66840 100644 --- a/tools/extract_tickets/extract_tickets.cpp +++ b/tools/extract_tickets/extract_tickets.cpp @@ -367,7 +367,33 @@ bool WriteOutputs(uint32_t appId, return false; } - std::cout << "Wrote " << dir << "\\\n"; + // Generate ready-to-use Lua script + std::string luaText; + luaText += "-- Auto-generated by extract_tickets for AppID: " + std::to_string(appId) + "\n"; + luaText += "addappid(" + std::to_string(appId) + ")\n\n"; + + if (ownership) { + luaText += "-- App Ownership Ticket (AppTicket)\n"; + luaText += "setAppTicket(" + std::to_string(appId) + ", \"" + ToHexString(*ownership) + "\")\n\n"; + } + + if (encrypted) { + luaText += "-- Encrypted App Ticket (ETicket)\n"; + luaText += "setETicket(" + std::to_string(appId) + ", \"" + ToHexString(*encrypted) + "\")\n\n"; + } + + const std::string luaPath{JoinPath(dir, std::to_string(appId) + ".lua")}; + std::ofstream luaFile{luaPath, std::ios::trunc}; + if (!luaFile || !(luaFile << luaText)) { + std::cerr << "Failed to write " << luaPath << ".\n"; + ok = false; + } + + std::cout << "Wrote " << dir << "\\ (" << std::to_string(appId) << ".lua, tickets.txt"; + if (ownership) std::cout << ", appticket.bin"; + if (encrypted) std::cout << ", eticket.bin"; + std::cout << ")\n"; + std::cout << "[INFO] Ready-to-use Lua script saved to: " << luaPath << "\n"; return ok; } From b9076707468f31a1b43ffdf66c016c7dd0bce89f Mon Sep 17 00:00:00 2001 From: mmxlyo Date: Tue, 8 Sep 2026 14:05:48 +0800 Subject: [PATCH 28/30] feat(extract_tickets): extract depot decryption keys into generated lua scripts --- README.md | 13 +- README_ZH.md | 13 +- tools/extract_tickets/ConvertTicketsToLua.ps1 | 102 ++++- tools/extract_tickets/extract_tickets.cpp | 413 +++++++++++++++++- tools/extract_tickets/steam.h | 27 ++ 5 files changed, 544 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 273733e7..aef2defb 100644 --- a/README.md +++ b/README.md @@ -71,22 +71,21 @@ The `extract_tickets` tool dumps the `AppTicket` and `ETicket` hex strings you n extract_tickets.exe 1361510 ``` 3. It reads the Steam install path from the registry, loads `steamclient64.dll`, and writes everything into an `/` folder next to the executable: + - `.lua` — ready-to-use full Lua config (with `addappid`, extracted depot decryption keys, `setAppTicket`, `setETicket`, ready to place directly into `config/lua/`) + - `depot_.key` — raw 32-byte depot decryption key (when cached) - `appticket.bin` — raw app ownership ticket (binary) - `eticket.bin` — raw encrypted app ticket (binary) - - `tickets.txt` — plain-text summary with the hex strings: + - `tickets.txt` — plain-text summary with keys and ticket hex strings: ``` appid:1361510 + depotkey(1361511):5954562e... appticket(184 bytes):14000000... eticket(143 bytes):... ``` A ticket that could not be obtained is reported as `appticket:null` / `eticket:null`. -4. Paste the hex strings from `tickets.txt` into your Lua config: - ```lua - setAppTicket(1361510, "14000000...") - setETicket(1361510, "...") - ``` +4. The generated `.lua` is completely ready to use; you can copy it directly to your OpenSteamTool `config/lua/` directory. You can also run `ConvertTicketsToLua.bat` (or `ConvertTicketsToLua.ps1`) to batch convert existing `tickets.txt` files into `.lua` configs with keys included. -> **Note:** Tickets are only valid when extracted from an account that **genuinely owns** the game. +> **Note:** Tickets and decryption keys are only valid when extracted from an account that **genuinely owns** the game. If you haven't downloaded the game on Steam yet, starting the install/download once will cache the depot decryption keys locally. ### Stats and Achievements - Enable stats and achievements for unowned games. diff --git a/README_ZH.md b/README_ZH.md index 43422cbd..01dc457a 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -72,22 +72,21 @@ extract_tickets.exe 1361510 ``` 3. 它从注册表读取 Steam 安装路径,加载 `steamclient64.dll`,并将所有内容写入可执行文件旁边的 `/` 文件夹: + - `.lua` — 自动生成开箱即用的完整 Lua 配置文件(包含 `addappid`、提取的 Depot 解密密钥、`setAppTicket`、`setETicket`,可直接复制到 `config/lua/` 目录使用) + - `depot_.key` — 原始 32 字节 Depot 解密密钥(若本地缓存存在) - `appticket.bin` — 原始应用所有权令牌(二进制) - `eticket.bin` — 原始加密应用令牌(二进制) - - `tickets.txt` — 包含十六进制字符串的纯文本摘要: + - `tickets.txt` — 包含密钥与令牌十六进制字符串的纯文本摘要: ``` appid:1361510 + depotkey(1361511):5954562e... appticket(184 bytes):14000000... eticket(143 bytes):... ``` 无法获取的令牌报告为 `appticket:null` / `eticket:null` -4. 将 `tickets.txt` 中的十六进制字符串粘贴到你的 Lua 配置中: - ```lua - setAppTicket(1361510, "14000000...") - setETicket(1361510, "...") - ``` +4. 工具会自动在输出目录生成完整、可直接使用的 `.lua` 脚本;你也可以运行 `ConvertTicketsToLua.bat`(或 `ConvertTicketsToLua.ps1`)批量将已有的 `tickets.txt` 转换为包含密钥的 `.lua` 文件。 -> **注意:** 令牌仅当从**真正拥有**游戏的账户提取时才有效 +> **注意:** 令牌和解密密钥仅当从**真正拥有**该游戏的账户提取时才有效。如果尚未在 Steam 下载过该游戏,在 Steam 中点击一次安装/下载即可缓存对应 Depot 的解密密钥到本地。 ### 统计和成就 - 为未拥有的游戏启用统计和成就 diff --git a/tools/extract_tickets/ConvertTicketsToLua.ps1 b/tools/extract_tickets/ConvertTicketsToLua.ps1 index 39eb452e..71608c6b 100644 --- a/tools/extract_tickets/ConvertTicketsToLua.ps1 +++ b/tools/extract_tickets/ConvertTicketsToLua.ps1 @@ -23,17 +23,81 @@ if ($files.Count -eq 0) { exit 0 } +# Helper to find Steam install path +function Get-SteamPath { + try { + $p = (Get-ItemProperty -Path 'HKCU:\Software\Valve\Steam' -Name 'SteamPath' -ErrorAction Stop).SteamPath + if (![string]::IsNullOrEmpty($p)) { return $p.Replace('/', '\') } + } catch {} + return $null +} + +# Helper to query depot decryption keys from config.vdf +function Get-SteamDepotKeys([string]$steamPath) { + $depotKeys = @{} + $configPath = Join-Path $steamPath "config\config.vdf" + if (Test-Path $configPath) { + $text = [System.IO.File]::ReadAllText($configPath) + $matches = [regex]::Matches($text, '"(\d{3,10})"\s*\{\s*"DecryptionKey"\s*"([0-9a-fA-F]{64})"') + foreach ($m in $matches) { + $depotKeys[$m.Groups[1].Value] = $m.Groups[2].Value + } + } + return $depotKeys +} + +# Helper to find installed depots for an AppID +function Get-AppInstalledDepots([string]$steamPath, [string]$appId) { + $depots = @{} + $libraries = @($steamPath) + $libVdf = Join-Path $steamPath "steamapps\libraryfolders.vdf" + if (Test-Path $libVdf) { + $libText = [System.IO.File]::ReadAllText($libVdf) + $libMatches = [regex]::Matches($libText, '"path"\s*"([^"]+)"') + foreach ($lm in $libMatches) { + $p = $lm.Groups[1].Value -replace '\\\\', '\' + if ($libraries -notcontains $p) { $libraries += $p } + } + } + + foreach ($lib in $libraries) { + $acf = Join-Path $lib "steamapps\appmanifest_${appId}.acf" + if (Test-Path $acf) { + $acfText = [System.IO.File]::ReadAllText($acf) + $mDepots = [regex]::Matches($acfText, '"InstalledDepots"\s*\{([\s\S]*?)\n\t\}') + if ($mDepots.Count -gt 0) { + $block = $mDepots[0].Groups[1].Value + $dMatches = [regex]::Matches($block, '"(\d{3,10})"\s*\{([\s\S]*?)\}') + foreach ($dm in $dMatches) { + $dId = $dm.Groups[1].Value + $dBody = $dm.Groups[2].Value + $manifestId = "" + if ($dBody -match '"manifest"\s*"(\d+)"') { $manifestId = $matches[1] } + $depots[$dId] = $manifestId + } + } + } + } + return $depots +} + +$localSteamPath = Get-SteamPath +$cachedDepotKeys = if ($localSteamPath) { Get-SteamDepotKeys $localSteamPath } else { @{} } + foreach ($f in $files) { Write-Host ("[Processing] " + $f.FullName) -ForegroundColor Cyan $lines = Get-Content $f.FullName $appId = "" $appTicket = "" $eTicket = "" + $depotKeys = @{} foreach ($line in $lines) { $l = $line.Trim() if ($l -match '^appid\s*:\s*(\d+)') { $appId = $matches[1] + } elseif ($l -match '^depotkey\((\d+)\)\s*:\s*([0-9a-fA-F]{64})') { + $depotKeys[$matches[1]] = $matches[2] } elseif ($l -match '^appticket[^:]*:\s*([0-9a-fA-F]+)') { $appTicket = $matches[1] } elseif ($l -match '^eticket[^:]*:\s*([0-9a-fA-F]+)') { @@ -46,12 +110,48 @@ foreach ($f in $files) { continue } + # If no depot keys in tickets.txt, attempt lookup from local Steam config + if ($depotKeys.Count -eq 0 -and $localSteamPath -and $cachedDepotKeys.Count -gt 0) { + $installedDepots = Get-AppInstalledDepots $localSteamPath $appId + foreach ($dId in $installedDepots.Keys) { + if ($cachedDepotKeys.ContainsKey($dId)) { + $depotKeys[$dId] = $cachedDepotKeys[$dId] + } + } + # Check if appId itself has a key + if ($cachedDepotKeys.ContainsKey($appId)) { + $depotKeys[$appId] = $cachedDepotKeys[$appId] + } + # Check heuristic range + $numericAppId = [uint32]$appId + foreach ($k in $cachedDepotKeys.Keys) { + $numKey = [uint32]$k + if ($numKey -ge $numericAppId -and $numKey -le ($numericAppId + 50)) { + if (-not $depotKeys.ContainsKey($k)) { + $depotKeys[$k] = $cachedDepotKeys[$k] + } + } + } + } + $outDir = $f.DirectoryName $outLua = Join-Path $outDir ($appId + ".lua") $luaContent = @() $luaContent += ("-- Auto-generated Lua config for AppID: " + $appId) - $luaContent += ("addappid(" + $appId + ")") + + if (-not $depotKeys.ContainsKey($appId)) { + $luaContent += ("addappid(" + $appId + ")") + } + + if ($depotKeys.Count -gt 0) { + $luaContent += "" + $luaContent += "-- Depot Decryption Keys" + foreach ($dId in ($depotKeys.Keys | Sort-Object { [uint32]$_ })) { + $luaContent += ("addappid(" + $dId + ', 1, "' + $depotKeys[$dId] + '")') + Write-Host (" [Key] Depot " + $dId + " -> " + $depotKeys[$dId]) -ForegroundColor Green + } + } $luaContent += "" if (![string]::IsNullOrEmpty($appTicket)) { diff --git a/tools/extract_tickets/extract_tickets.cpp b/tools/extract_tickets/extract_tickets.cpp index fda66840..0a2bca33 100644 --- a/tools/extract_tickets/extract_tickets.cpp +++ b/tools/extract_tickets/extract_tickets.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -7,12 +8,20 @@ #include #include #include +#include +#include #include #include "steam.h" namespace { +struct DepotKeyInfo { + uint32_t depotId{0}; + std::string hexKey; // 64 hex characters (32 bytes AES key) + std::string manifestId; // optional manifest id +}; + bool IsDecimal(std::string_view value) { if (value.empty()) return false; for (char ch : value) { @@ -101,6 +110,322 @@ std::string NormalizeDir(std::string dir) { return dir; } +std::optional> HexStringToBytes(std::string_view hex) { + if (hex.size() % 2 != 0) return std::nullopt; + std::vector bytes; + bytes.reserve(hex.size() / 2); + + auto hexVal = [](char c) -> int { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; + }; + + for (size_t i = 0; i < hex.size(); i += 2) { + int hi = hexVal(hex[i]); + int lo = hexVal(hex[i + 1]); + if (hi < 0 || lo < 0) return std::nullopt; + bytes.push_back(static_cast((hi << 4) | lo)); + } + return bytes; +} + +std::vector FindSteamLibraryFolders(const std::string& steamPath) { + std::vector libraries; + libraries.push_back(NormalizeDir(steamPath)); + + const std::string libraryVdfPath = JoinPath(steamPath, "steamapps\\libraryfolders.vdf"); + std::ifstream file(libraryVdfPath); + if (!file) return libraries; + + std::string line; + while (std::getline(file, line)) { + size_t pos = line.find("\"path\""); + if (pos != std::string::npos) { + size_t start = line.find('"', pos + 6); + if (start != std::string::npos) { + size_t end = line.find('"', start + 1); + if (end != std::string::npos) { + std::string lib = line.substr(start + 1, end - start - 1); + std::string unescaped; + for (size_t i = 0; i < lib.size(); ++i) { + if (lib[i] == '\\' && i + 1 < lib.size() && lib[i + 1] == '\\') { + unescaped += '\\'; + ++i; + } else { + unescaped += lib[i]; + } + } + unescaped = NormalizeDir(unescaped); + if (!unescaped.empty()) { + bool exists = false; + for (const auto& existing : libraries) { + if (_stricmp(existing.c_str(), unescaped.c_str()) == 0) { + exists = true; + break; + } + } + if (!exists) libraries.push_back(unescaped); + } + } + } + } + } + return libraries; +} + +void ParseAcfDepots(const std::string& acfPath, + std::unordered_map& outDepots, + std::unordered_set& outDlcIds) { + std::ifstream file(acfPath); + if (!file) return; + + std::string line; + bool inInstalledDepots = false; + uint32_t currentDepotId = 0; + int braceDepth = 0; + int depotsDepth = -1; + + while (std::getline(file, line)) { + for (char c : line) { + if (c == '{') { + braceDepth++; + } else if (c == '}') { + if (braceDepth == depotsDepth) { + inInstalledDepots = false; + depotsDepth = -1; + } + braceDepth--; + } + } + + if (!inInstalledDepots) { + if (line.find("\"InstalledDepots\"") != std::string::npos) { + inInstalledDepots = true; + depotsDepth = braceDepth; + } + continue; + } + + std::vector tokens; + size_t pos = 0; + while ((pos = line.find('"', pos)) != std::string::npos) { + size_t endPos = line.find('"', pos + 1); + if (endPos == std::string::npos) break; + tokens.push_back(line.substr(pos + 1, endPos - pos - 1)); + pos = endPos + 1; + } + + if (tokens.size() == 1 && IsDecimal(tokens[0])) { + auto parsed = ParseAppId(tokens[0]); + if (parsed) { + currentDepotId = *parsed; + if (outDepots.find(currentDepotId) == outDepots.end()) { + outDepots[currentDepotId] = ""; + } + } + } else if (tokens.size() >= 2 && currentDepotId != 0) { + if (tokens[0] == "manifest") { + outDepots[currentDepotId] = tokens[1]; + } else if (tokens[0] == "dlcappid") { + if (auto dlc = ParseAppId(tokens[1])) { + outDlcIds.insert(*dlc); + } + } + } + } +} + +std::unordered_map ParseConfigVdfDepotKeys(const std::string& steamPath) { + std::unordered_map depotKeys; + const std::string configPath = JoinPath(steamPath, "config\\config.vdf"); + std::ifstream file(configPath); + if (!file) return depotKeys; + + std::string line; + uint32_t currentDepotId = 0; + bool inDepots = false; + int braceDepth = 0; + int depotsDepth = -1; + + while (std::getline(file, line)) { + if (size_t comment = line.find("//"); comment != std::string::npos) { + line.erase(comment); + } + + for (char c : line) { + if (c == '{') { + braceDepth++; + } else if (c == '}') { + if (braceDepth == depotsDepth) { + inDepots = false; + depotsDepth = -1; + } + braceDepth--; + } + } + + if (!inDepots) { + if (line.find("\"depots\"") != std::string::npos) { + inDepots = true; + depotsDepth = braceDepth; + } + } + + std::vector tokens; + size_t pos = 0; + while ((pos = line.find('"', pos)) != std::string::npos) { + size_t endPos = line.find('"', pos + 1); + if (endPos == std::string::npos) break; + tokens.push_back(line.substr(pos + 1, endPos - pos - 1)); + pos = endPos + 1; + } + + if (tokens.size() == 1 && IsDecimal(tokens[0])) { + auto parsed = ParseAppId(tokens[0]); + if (parsed) { + currentDepotId = *parsed; + } + } else if (tokens.size() >= 2) { + if (tokens[0] == "DecryptionKey" && tokens[1].size() == 64 && currentDepotId != 0) { + depotKeys[currentDepotId] = tokens[1]; + } + } + } + + if (depotKeys.empty()) { + file.clear(); + file.seekg(0, std::ios::beg); + std::string fullText((std::istreambuf_iterator(file)), + std::istreambuf_iterator()); + size_t offset = 0; + while ((offset = fullText.find("\"DecryptionKey\"", offset)) != std::string::npos) { + size_t keyStart = fullText.find('"', offset + 15); + if (keyStart != std::string::npos) { + size_t keyEnd = fullText.find('"', keyStart + 1); + if (keyEnd != std::string::npos && (keyEnd - keyStart - 1) == 64) { + std::string key = fullText.substr(keyStart + 1, 64); + size_t searchBack = offset; + while (searchBack > 0 && fullText[searchBack] != '{') searchBack--; + size_t q2 = fullText.rfind('"', searchBack); + if (q2 != std::string::npos && q2 > 0) { + size_t q1 = fullText.rfind('"', q2 - 1); + if (q1 != std::string::npos) { + std::string candidateId = fullText.substr(q1 + 1, q2 - q1 - 1); + if (IsDecimal(candidateId)) { + if (auto dId = ParseAppId(candidateId)) { + depotKeys[*dId] = key; + } + } + } + } + } + } + offset += 15; + } + } + + return depotKeys; +} + +std::vector ExtractDepotDecryptionKeys( + const std::string& steamPath, + uint32_t appId, + ISteamClient* client, + HSteamPipe pipe, + HSteamUser user) { + + std::unordered_map knownDepotManifests; + std::unordered_set knownDlcIds; + + knownDepotManifests[appId] = ""; + + if (client && pipe && user) { + auto* apps = reinterpret_cast( + client->GetISteamGenericInterface(user, pipe, kSteamAppsInterfaceVersion)); + if (apps) { + DepotId_t depots[128]{}; + uint32_t count = apps->GetInstalledDepots(appId, depots, 128); + for (uint32_t i = 0; i < count; ++i) { + if (depots[i] != 0 && knownDepotManifests.find(depots[i]) == knownDepotManifests.end()) { + knownDepotManifests[depots[i]] = ""; + } + } + + int dlcCount = apps->GetDLCCount(); + for (int i = 0; i < dlcCount; ++i) { + AppId_t dlcId{0}; + bool available{false}; + char dlcName[256]{}; + if (apps->BGetDLCDataByIndex(i, &dlcId, &available, dlcName, sizeof(dlcName)) && dlcId != 0) { + knownDlcIds.insert(dlcId); + DepotId_t dlcDepots[64]{}; + uint32_t dlcDepotCount = apps->GetInstalledDepots(dlcId, dlcDepots, 64); + for (uint32_t j = 0; j < dlcDepotCount; ++j) { + if (dlcDepots[j] != 0 && knownDepotManifests.find(dlcDepots[j]) == knownDepotManifests.end()) { + knownDepotManifests[dlcDepots[j]] = ""; + } + } + } + } + } + } + + auto libraries = FindSteamLibraryFolders(steamPath); + for (const auto& lib : libraries) { + std::string acf = JoinPath(lib, ("steamapps\\appmanifest_" + std::to_string(appId) + ".acf").c_str()); + ParseAcfDepots(acf, knownDepotManifests, knownDlcIds); + } + + for (uint32_t dlcId : knownDlcIds) { + for (const auto& lib : libraries) { + std::string acf = JoinPath(lib, ("steamapps\\appmanifest_" + std::to_string(dlcId) + ".acf").c_str()); + ParseAcfDepots(acf, knownDepotManifests, knownDlcIds); + } + } + + auto allDepotKeys = ParseConfigVdfDepotKeys(steamPath); + + std::vector result; + std::unordered_set addedDepots; + + for (const auto& [dId, manifest] : knownDepotManifests) { + auto it = allDepotKeys.find(dId); + if (it != allDepotKeys.end() && !it->second.empty()) { + result.push_back({dId, it->second, manifest}); + addedDepots.insert(dId); + } + } + + for (uint32_t dlcId : knownDlcIds) { + if (addedDepots.find(dlcId) == addedDepots.end()) { + auto it = allDepotKeys.find(dlcId); + if (it != allDepotKeys.end() && !it->second.empty()) { + result.push_back({dlcId, it->second, ""}); + addedDepots.insert(dlcId); + } + } + } + + for (const auto& [dId, key] : allDepotKeys) { + if (addedDepots.find(dId) == addedDepots.end()) { + if (dId >= appId && dId <= appId + 50) { + std::string manifest = ""; + if (knownDepotManifests.count(dId)) manifest = knownDepotManifests[dId]; + result.push_back({dId, key, manifest}); + addedDepots.insert(dId); + } + } + } + + std::sort(result.begin(), result.end(), [](const DepotKeyInfo& a, const DepotKeyInfo& b) { + return a.depotId < b.depotId; + }); + + return result; +} + HMODULE LoadSteamClient64(std::string& loadedPath) { auto steamPath{FindSteamInstallPath()}; if (!steamPath) { @@ -339,11 +664,12 @@ std::string TicketLine(const char* name, const std::optional folder: the raw binary tickets -// (only when present) plus a plain-text summary file. +// Everything lands in a single folder: the raw binary tickets, +// raw depot keys, plus a plain-text summary and ready-to-use .lua script. bool WriteOutputs(uint32_t appId, const std::optional>& ownership, - const std::optional>& encrypted) { + const std::optional>& encrypted, + const std::vector& depotKeys) { const std::string dir{std::to_string(appId)}; if (!CreateDirectoryA(dir.c_str(), nullptr) && GetLastError() != ERROR_ALREADY_EXISTS) { std::cerr << "Failed to create directory " << dir @@ -355,10 +681,21 @@ bool WriteOutputs(uint32_t appId, if (ownership) ok = WriteBinaryFile(JoinPath(dir, "appticket.bin"), *ownership) && ok; if (encrypted) ok = WriteBinaryFile(JoinPath(dir, "eticket.bin"), *encrypted) && ok; - const std::string text{ - "appid:" + std::to_string(appId) + "\n" - + TicketLine("appticket", ownership) - + TicketLine("eticket", encrypted)}; + // Write binary depot key files (.key) + for (const auto& dk : depotKeys) { + auto keyBytes = HexStringToBytes(dk.hexKey); + if (keyBytes) { + ok = WriteBinaryFile(JoinPath(dir, "depot_" + std::to_string(dk.depotId) + ".key"), *keyBytes) && ok; + } + } + + // Build tickets.txt summary + std::string text = "appid:" + std::to_string(appId) + "\n"; + for (const auto& dk : depotKeys) { + text += "depotkey(" + std::to_string(dk.depotId) + "):" + dk.hexKey + "\n"; + } + text += TicketLine("appticket", ownership); + text += TicketLine("eticket", encrypted); const std::string textPath{JoinPath(dir, "tickets.txt")}; std::ofstream summary{textPath, std::ios::trunc}; @@ -370,8 +707,45 @@ bool WriteOutputs(uint32_t appId, // Generate ready-to-use Lua script std::string luaText; luaText += "-- Auto-generated by extract_tickets for AppID: " + std::to_string(appId) + "\n"; - luaText += "addappid(" + std::to_string(appId) + ")\n\n"; + bool appIdHasKey = false; + for (const auto& dk : depotKeys) { + if (dk.depotId == appId) { + appIdHasKey = true; + break; + } + } + + if (!appIdHasKey) { + luaText += "addappid(" + std::to_string(appId) + ")\n"; + } + + // Write depot decryption keys + if (!depotKeys.empty()) { + luaText += "\n-- Depot Decryption Keys\n"; + for (const auto& dk : depotKeys) { + luaText += "addappid(" + std::to_string(dk.depotId) + ", 1, \"" + dk.hexKey + "\")\n"; + } + } + + // Write manifest reference if available + bool hasManifests = false; + for (const auto& dk : depotKeys) { + if (!dk.manifestId.empty()) { + hasManifests = true; + break; + } + } + if (hasManifests) { + luaText += "\n-- Manifest IDs (reference)\n"; + for (const auto& dk : depotKeys) { + if (!dk.manifestId.empty()) { + luaText += "-- setManifestid(" + std::to_string(dk.depotId) + ", \"" + dk.manifestId + "\")\n"; + } + } + } + + luaText += "\n"; if (ownership) { luaText += "-- App Ownership Ticket (AppTicket)\n"; luaText += "setAppTicket(" + std::to_string(appId) + ", \"" + ToHexString(*ownership) + "\")\n\n"; @@ -392,7 +766,21 @@ bool WriteOutputs(uint32_t appId, std::cout << "Wrote " << dir << "\\ (" << std::to_string(appId) << ".lua, tickets.txt"; if (ownership) std::cout << ", appticket.bin"; if (encrypted) std::cout << ", eticket.bin"; + for (const auto& dk : depotKeys) { + std::cout << ", depot_" << dk.depotId << ".key"; + } std::cout << ")\n"; + + if (!depotKeys.empty()) { + std::cout << "[INFO] Extracted " << depotKeys.size() << " depot decryption key(s):\n"; + for (const auto& dk : depotKeys) { + std::cout << " Depot " << dk.depotId << ": " << dk.hexKey << "\n"; + } + } else { + std::cout << "[INFO] No cached depot decryption keys found in config.vdf for AppID " << appId << ".\n"; + std::cout << "[TIP] If this game requires depot keys, start installing/updating it once in Steam to cache them, then run extract_tickets again.\n"; + } + std::cout << "[INFO] Ready-to-use Lua script saved to: " << luaPath << "\n"; return ok; } @@ -456,7 +844,14 @@ int Run(int argc, char** argv) { auto encrypted{ExtractEncryptedAppTicket(client, pipe, user, *appId)}; if (encrypted) PrintHex("Encrypted ticket", *encrypted); - const bool ok{WriteOutputs(*appId, ownership, encrypted)}; + auto steamPathOpt{FindSteamInstallPath()}; + std::string steamPath = steamPathOpt ? *steamPathOpt : ""; + std::vector depotKeys; + if (!steamPath.empty()) { + depotKeys = ExtractDepotDecryptionKeys(steamPath, *appId, client, pipe, user); + } + + const bool ok{WriteOutputs(*appId, ownership, encrypted, depotKeys)}; client->BReleaseSteamPipe(pipe); FreeLibrary(steamClient); diff --git a/tools/extract_tickets/steam.h b/tools/extract_tickets/steam.h index f17dad10..8e74b988 100644 --- a/tools/extract_tickets/steam.h +++ b/tools/extract_tickets/steam.h @@ -12,6 +12,7 @@ typedef unsigned __int64 uint64; typedef int32 HSteamPipe; typedef int32 HSteamUser; typedef uint32 AppId_t; +typedef uint32 DepotId_t; typedef uint64 SteamAPICall_t; // Steam universes (steamuniverse.h). @@ -41,6 +42,7 @@ inline constexpr const char* kSteamClientInterfaceVersion = "SteamClient023"; inline constexpr const char* kSteamUserInterfaceVersion = "SteamUser023"; inline constexpr const char* kSteamUtilsInterfaceVersion = "SteamUtils010"; inline constexpr const char* kSteamAppTicketInterfaceVersion = "STEAMAPPTICKET_INTERFACE_VERSION001"; +inline constexpr const char* kSteamAppsInterfaceVersion = "STEAMAPPS_INTERFACE_VERSION008"; // Interfaces returned by ISteamClient getters we never dereference; declared // opaque so the vtable slots keep their SDK signatures. @@ -122,4 +124,29 @@ class ISteamAppTicket virtual uint32 GetAppOwnershipTicketData( uint32 nAppID, void *pvBuffer, uint32 cbBufferLength, uint32 *piAppId, uint32 *piSteamId, uint32 *piSignature, uint32 *pcbSignature ) = 0; }; +// isteamapps.h +class ISteamApps { +public: + virtual bool BIsSubscribed() = 0; + virtual bool BIsLowViolence() = 0; + virtual bool BIsCybercafe() = 0; + virtual bool BIsVACBanned() = 0; + virtual const char* GetCurrentGameLanguage() = 0; + virtual const char* GetAvailableGameLanguages() = 0; + virtual bool BIsSubscribedApp(AppId_t appID) = 0; + virtual bool BIsDlcInstalled(AppId_t appID) = 0; + virtual uint32 GetEarliestPurchaseUnixTime(AppId_t nAppID) = 0; + virtual bool BIsSubscribedFromFreeWeekend() = 0; + virtual int GetDLCCount() = 0; + virtual bool BGetDLCDataByIndex(int iDLC, AppId_t* pAppID, bool* pbAvailable, char* pchName, int cchNameBufferSize) = 0; + virtual void InstallDLC(AppId_t nAppID) = 0; + virtual void UninstallDLC(AppId_t nAppID) = 0; + virtual void RequestAppProofOfPurchaseKey(AppId_t nAppID) = 0; + virtual bool GetCurrentBetaName(char* pchName, int cchNameBufferSize) = 0; + virtual bool MarkContentCorrupt(bool bMissingFilesOnly) = 0; + virtual uint32 GetInstalledDepots(AppId_t appID, DepotId_t* pvecDepots, uint32 cMaxDepots) = 0; + virtual uint32 GetAppInstallDir(AppId_t appID, char* pchFolder, uint32 cchFolderBufferSize) = 0; + virtual bool BIsAppInstalled(AppId_t appID) = 0; +}; + typedef void* (*CreateInterfaceFn)(const char* pName, int* pReturnCode); From ee9cd58baed9d819815d29fa1acc896fcd700bd1 Mon Sep 17 00:00:00 2001 From: mmxlyo Date: Tue, 8 Sep 2026 14:28:01 +0800 Subject: [PATCH 29/30] feat(cloud): transparently redirect GetModuleHandle for CloudRedirect compatibility under Diversion --- opensteamtool.example.toml | 7 +- src/Hook/Hooks_SteamUI.cpp | 111 +++++++++++++++++- src/Utils/CloudRedirect/CloudRedirectHost.cpp | 14 ++- 3 files changed, 124 insertions(+), 8 deletions(-) diff --git a/opensteamtool.example.toml b/opensteamtool.example.toml index 7097caa9..ff237180 100644 --- a/opensteamtool.example.toml +++ b/opensteamtool.example.toml @@ -85,15 +85,16 @@ enable_api = true # 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. +# through CloudRedirect's cloud-save engine (fully compatible with Diversion memory isolation). # # 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. +# Path to cloud_redirect.dll. Absolute, or relative to opensteamtool.toml, DLL dir, or Steam root. +# Defaults to searching alongside opensteamtool.toml, OpenSteamTool.dll, then Steam root. # 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/Hook/Hooks_SteamUI.cpp b/src/Hook/Hooks_SteamUI.cpp index 1936020b..08d6a501 100644 --- a/src/Hook/Hooks_SteamUI.cpp +++ b/src/Hook/Hooks_SteamUI.cpp @@ -7,11 +7,14 @@ #include #include #include -#include -#include +#include +#include #include +#include +#include #include #include +#include namespace { @@ -40,12 +43,103 @@ namespace }; return equalsCi(p, "steamclient64.dll") || equalsCi(p, "steamclient.dll") || + equalsCi(p, "steamclient64") || + equalsCi(p, "steamclient") || endsWithCi(p, "\\steamclient64.dll") || endsWithCi(p, "\\steamclient.dll") || endsWithCi(p, "/steamclient64.dll") || endsWithCi(p, "/steamclient.dll"); } + static bool IsSteamClientPathW(const wchar_t* path) { + if (!path) return false; + std::wstring_view p(path); + auto endsWithCiW = [](std::wstring_view str, std::wstring_view suffix) { + if (str.size() < suffix.size()) return false; + return std::equal(suffix.rbegin(), suffix.rend(), str.rbegin(), + [](wchar_t a, wchar_t b) { + return std::towlower(a) == std::towlower(b); + }); + }; + auto equalsCiW = [](std::wstring_view a, std::wstring_view b) { + if (a.size() != b.size()) return false; + return std::equal(a.begin(), a.end(), b.begin(), + [](wchar_t c1, wchar_t c2) { + return std::towlower(c1) == std::towlower(c2); + }); + }; + return equalsCiW(p, L"steamclient64.dll") || + equalsCiW(p, L"steamclient.dll") || + equalsCiW(p, L"steamclient64") || + equalsCiW(p, L"steamclient") || + endsWithCiW(p, L"\\steamclient64.dll") || + endsWithCiW(p, L"\\steamclient.dll") || + endsWithCiW(p, L"/steamclient64.dll") || + endsWithCiW(p, L"/steamclient.dll"); + } + + // Original pointers for system module lookup APIs + 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) { + *phModule = reinterpret_cast(client_hModule); + if (!(dwFlags & GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT)) { + HMODULE dummy = nullptr; + oGetModuleHandleExA(dwFlags & (GET_MODULE_HANDLE_EX_FLAG_PIN), + DiversionPath, &dummy); + } + return TRUE; + } + return FALSE; + } + 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) { + *phModule = reinterpret_cast(client_hModule); + if (!(dwFlags & GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT)) { + HMODULE dummy = nullptr; + std::wstring wDivPath = std::filesystem::path(DiversionPath).wstring(); + oGetModuleHandleExW(dwFlags & (GET_MODULE_HANDLE_EX_FLAG_PIN), + wDivPath.c_str(), &dummy); + } + return TRUE; + } + return FALSE; + } + return oGetModuleHandleExW(dwFlags, lpModuleName, phModule); + } + + HOOK_FUNC(LoadModuleWithPath, void*, const char* path, bool flags) { LOG_STEAMUI_INFO("LoadModuleWithPath called with path: {}, flags: {}", @@ -166,12 +260,24 @@ namespace Hooks_SteamUI INSTALL_HOOK_U(FillInAppOverview); INSTALL_HOOK_U(BuildCompleteAppOverviewChange); INSTALL_HOOK_U(CSteamUIAppControllerRunFrame); + + // System module handle redirection for Diversion shadow memory isolation + OSTPlatform::Detour::Attach(reinterpret_cast(&oGetModuleHandleA), reinterpret_cast(hkGetModuleHandleA)); + OSTPlatform::Detour::Attach(reinterpret_cast(&oGetModuleHandleW), reinterpret_cast(hkGetModuleHandleW)); + OSTPlatform::Detour::Attach(reinterpret_cast(&oGetModuleHandleExA), reinterpret_cast(hkGetModuleHandleExA)); + OSTPlatform::Detour::Attach(reinterpret_cast(&oGetModuleHandleExW), reinterpret_cast(hkGetModuleHandleExW)); + HOOK_END(); } void Uninstall() { UNHOOK_BEGIN(); + OSTPlatform::Detour::Detach(reinterpret_cast(&oGetModuleHandleA), reinterpret_cast(hkGetModuleHandleA)); + OSTPlatform::Detour::Detach(reinterpret_cast(&oGetModuleHandleW), reinterpret_cast(hkGetModuleHandleW)); + OSTPlatform::Detour::Detach(reinterpret_cast(&oGetModuleHandleExA), reinterpret_cast(hkGetModuleHandleExA)); + OSTPlatform::Detour::Detach(reinterpret_cast(&oGetModuleHandleExW), reinterpret_cast(hkGetModuleHandleExW)); + UNINSTALL_HOOK(LoadModuleWithPath); UNINSTALL_HOOK(FillInAppOverview); UNINSTALL_HOOK(BuildCompleteAppOverviewChange); @@ -179,6 +285,7 @@ namespace Hooks_SteamUI UNHOOK_END(); } + void QueueRemoval(AppId_t appId) { std::lock_guard lock(g_removalMutex); diff --git a/src/Utils/CloudRedirect/CloudRedirectHost.cpp b/src/Utils/CloudRedirect/CloudRedirectHost.cpp index 76c2a68c..d405f508 100644 --- a/src/Utils/CloudRedirect/CloudRedirectHost.cpp +++ b/src/Utils/CloudRedirect/CloudRedirectHost.cpp @@ -69,6 +69,10 @@ namespace { auto p = std::filesystem::path(DllDir) / "cloud_redirect.dll"; if (std::filesystem::exists(p)) return p; } + if (ConfigPath[0] != '\0') { + auto p = std::filesystem::path(ConfigPath).parent_path() / "cloud_redirect.dll"; + if (std::filesystem::exists(p)) return p; + } return std::filesystem::path(steamRoot) / "cloud_redirect.dll"; } @@ -79,6 +83,10 @@ namespace { auto p = std::filesystem::path(DllDir) / lib; if (std::filesystem::exists(p)) return p; } + if (ConfigPath[0] != '\0') { + auto p = std::filesystem::path(ConfigPath).parent_path() / lib; + if (std::filesystem::exists(p)) return p; + } return std::filesystem::path(steamRoot) / lib; } @@ -149,8 +157,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})", + libPath.string(), static_cast(client_hModule)); if (g_enableStatsSync) { g_enableStatsSync(true, true); @@ -167,7 +175,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"); } From a867d66f493d6470e613c449ce581931bd8a3a85 Mon Sep 17 00:00:00 2001 From: mmxlyo Date: Tue, 8 Sep 2026 15:22:32 +0800 Subject: [PATCH 30/30] fix(extract_tickets): fix compilation errors under MSVC and copy companion scripts --- tools/CMakeLists.txt | 12 ++++++++++++ tools/extract_tickets/extract_tickets.cpp | 12 +++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index 77ca13b5..c188467c 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -15,6 +15,18 @@ add_executable(extract_tickets extract_tickets/extract_tickets.cpp ) target_compile_features(extract_tickets PRIVATE cxx_std_20) +target_link_libraries(extract_tickets PRIVATE + advapi32 +) +add_custom_command(TARGET extract_tickets POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/extract_tickets/ConvertTicketsToLua.bat" + "$/ConvertTicketsToLua.bat" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${CMAKE_CURRENT_SOURCE_DIR}/extract_tickets/ConvertTicketsToLua.ps1" + "$/ConvertTicketsToLua.ps1" + COMMENT "Copying extract_tickets companion scripts" +) if(WIN32) add_executable(ost-Injector diff --git a/tools/extract_tickets/extract_tickets.cpp b/tools/extract_tickets/extract_tickets.cpp index 0a2bca33..ae5ef61a 100644 --- a/tools/extract_tickets/extract_tickets.cpp +++ b/tools/extract_tickets/extract_tickets.cpp @@ -1,10 +1,15 @@ +#ifndef NOMINMAX +#define NOMINMAX +#endif #include #include #include #include +#include #include #include +#include #include #include #include @@ -93,7 +98,7 @@ std::optional FindSteamInstallPath() { return std::nullopt; } -std::string JoinPath(std::string base, const char* name) { +std::string JoinPath(std::string base, std::string_view name) { for (char& ch : base) { if (ch == '/') ch = '\\'; } @@ -358,7 +363,7 @@ std::vector ExtractDepotDecryptionKeys( AppId_t dlcId{0}; bool available{false}; char dlcName[256]{}; - if (apps->BGetDLCDataByIndex(i, &dlcId, &available, dlcName, sizeof(dlcName)) && dlcId != 0) { + if (apps->BGetDLCDataByIndex(i, &dlcId, &available, dlcName, static_cast(sizeof(dlcName))) && dlcId != 0) { knownDlcIds.insert(dlcId); DepotId_t dlcDepots[64]{}; uint32_t dlcDepotCount = apps->GetInstalledDepots(dlcId, dlcDepots, 64); @@ -412,7 +417,8 @@ std::vector ExtractDepotDecryptionKeys( if (addedDepots.find(dId) == addedDepots.end()) { if (dId >= appId && dId <= appId + 50) { std::string manifest = ""; - if (knownDepotManifests.count(dId)) manifest = knownDepotManifests[dId]; + auto it = knownDepotManifests.find(dId); + if (it != knownDepotManifests.end()) manifest = it->second; result.push_back({dId, key, manifest}); addedDepots.insert(dId); }