diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 766c514..edc57a5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,5 @@ name: Build +run-name: ${{ github.event.inputs.version }} Build on: workflow_dispatch: diff --git a/.github/workflows/tools.yml b/.github/workflows/tools.yml new file mode 100644 index 0000000..9e6c20f --- /dev/null +++ b/.github/workflows/tools.yml @@ -0,0 +1,36 @@ +name: Tools + +on: + workflow_dispatch: + +jobs: + build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: ilammy/msvc-dev-cmd@v1 + + # Configure the src tree (same as the product build); the tools live there + # via add_subdirectory and land in build/tools//. + - name: Configure + run: cmake -S src -B build -G "Ninja Multi-Config" + + # Build only the host tool targets, not the OpenSteamTool DLL. + - name: Build Release + run: cmake --build build --config Release --target ipc_codegen extract_tickets + + - name: Build Debug + run: cmake --build build --config Debug --target ipc_codegen extract_tickets + + - name: Upload Release artifacts + uses: actions/upload-artifact@v4 + with: + name: OpenSteamTool-Tools-Release + path: build/tools/Release/ + + - name: Upload Debug artifacts + uses: actions/upload-artifact@v4 + with: + name: OpenSteamTool-Tools-Debug + path: build/tools/Debug/ diff --git a/README.md b/README.md index 3373415..eaa34ae 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,41 @@ OpenSteamTool is a Windows DLL project built with CMake. - Adding, modifying, deleting, or overwriting `.lua` files in any watched directory automatically triggers a reload. No restart, no offline/online toggle needed. ### Family Sharing and Remote Play -- Bypass Steam Family Sharing restrictions, allowing shared games to be played without limitations. +- 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 -- For AppTicket and ETicket: in `HKEY_CURRENT_USER\Software\Valve\Steam\Apps\{AppId}`, both `AppTicket` and `ETicket` are `REG_BINARY` values. +- 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. In `HKEY_CURRENT_USER\Software\Valve\Steam\Apps\{AppId}`, both `AppTicket` and `ETicket` are `REG_BINARY` values. - Use `setAppTicket(appid, "hex")` and `setETicket(appid, "hex")` in Lua config to write these values to the registry automatically. -- SteamID priority: read `SteamID` as `REG_SZ` (numeric-only) first; if missing, parse from `AppTicket`. +- AppTicket priority: explicit tickets have the highest priority, including tickets configured by `setAppTicket` and existing `AppTicket` registry values. If no explicit AppTicket is available, OpenSteamTool falls back to the forged local ConfigStore ticket path. +- SteamID priority: read `SteamID` as `REG_SZ` (numeric-only) first; if missing, parse from explicit `AppTicket`. + +#### 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. @@ -96,10 +125,9 @@ timeout_recv_ms = 10000 [lua] paths = [] -# Optional signature-file mirror. See "Steam version compatibility" below. -# Leave commented out for the built-in default (raw.githubusercontent.com). -[pattern] -# mirror = "https://cdn.jsdelivr.net/gh/OpenSteam001/steam-monitor@pattern" +# Optional metadata mirror. See "Steam version compatibility" below. +[remote] +# url_template = "https://your.server/{channel}/{component}/{sha256}.toml" ``` ### Manifest via Lua @@ -143,39 +171,16 @@ You can also drop a pattern TOML into the cache directory manually if you know t #### Using a different mirror -For most users, the built-in **GitHub → jsDelivr** automatic fallback is enough; you do not need to touch `opensteamtool.toml` at all. +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. -If you want to force a specific source (private mirror, intranet server, or a CDN that's faster on your network than the defaults), set it explicitly in `opensteamtool.toml`. **Setting `mirror` disables the automatic GitHub→jsDelivr fallback** — only the URL you specify is tried, on the principle that an explicit user choice should win. +The template must include `{channel}`, `{component}`, and `{sha256}`. Channels currently used are `pattern` and `ipc`. ```toml -[pattern] -# Default if unset: -# https://raw.githubusercontent.com/OpenSteam001/steam-monitor/pattern -# Examples: -mirror = "https://cdn.jsdelivr.net/gh/OpenSteam001/steam-monitor@pattern" -# mirror = "https://ghproxy.com/https://raw.githubusercontent.com/OpenSteam001/steam-monitor/pattern" -# mirror = "https://your.server.com/opensteamtool-patterns" +[remote] +url_template = "https://your.server/{channel}/{component}/{sha256}.toml" +# url_template = "https://fast.jsdelivr.net/gh/OpenSteam001/steam-monitor@{channel}/{component}/{sha256}.toml" ``` -The full URL fetched at runtime is `/steamclient/.toml` and `/steamui/.toml`. Any HTTPS server that serves the same directory layout works. A trailing `/` is allowed but optional. - -Resolved URL by config (example, for the `steamui` lookup): - -| Config | Resulting URL | -|---|---| -| `[pattern]` omitted, or `mirror = ""` | `https://raw.githubusercontent.com/OpenSteam001/steam-monitor/pattern/steamui/.toml` | -| `mirror = "https://cdn.jsdelivr.net/gh/OpenSteam001/steam-monitor@pattern"` | `https://cdn.jsdelivr.net/gh/OpenSteam001/steam-monitor@pattern/steamui/.toml` | -| `mirror = "https://your.server.com/p/"` (trailing slash) | `https://your.server.com/p/steamui/.toml` (slash stripped at parse) | - -**Verifying a mirror in your browser:** paste a complete URL — base + subdir + a real SHA-256 + `.toml`. The base URL alone (without the file path) will return `Invalid URL` from most CDNs, which is expected behavior, not a sign the mirror is broken. Example URLs you can paste directly: - -``` -https://cdn.jsdelivr.net/gh/OpenSteam001/steam-monitor@pattern/steamui/7a72275b5efc6781a964f6a8e5414ea2226c4a0a64a82e79b9e7d501dfcc3b57.toml -https://raw.githubusercontent.com/OpenSteam001/steam-monitor/pattern/steamui/7a72275b5efc6781a964f6a8e5414ea2226c4a0a64a82e79b9e7d501dfcc3b57.toml -``` - -Replace the hash with a real one from [the upstream `pattern` branch](https://github.com/OpenSteam001/steam-monitor/tree/pattern/steamui). If the browser returns `200` you're good; `404` means upstream hasn't published a file for that DLL yet (open an issue), and connect/timeout errors mean the mirror itself isn't reachable from your network — pick another. - ### Debug logging Debug builds write per-module log files under `/opensteamtool/`: diff --git a/build.bat b/build.bat index 5dc9d6c..83be242 100644 --- a/build.bat +++ b/build.bat @@ -34,6 +34,12 @@ for %%C in (%CONFIGS%) do ( echo [INFO] Building: %%C cmake --build build --config %%C 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 + cmake --build build --config %%C --target extract_tickets + if errorlevel 1 goto :fail ) echo [OK] Build completed successfully. diff --git a/opensteamtool.example.toml b/opensteamtool.example.toml index cf358f5..b456ffb 100644 --- a/opensteamtool.example.toml +++ b/opensteamtool.example.toml @@ -65,48 +65,10 @@ timeout_recv_ms = 10000 [lua] # paths = [] -[pattern] -# Mirror base URL for the per-DLL signature TOML files. -# At runtime, OpenSteamTool appends "/steamclient/.toml" and -# "/steamui/.toml" to whatever you set here. +[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 +# three placeholders: {channel}, {component}, and {sha256}. # -# Default behaviour (this key commented out / empty) — every launch: -# 1. Try https://raw.githubusercontent.com/OpenSteam001/steam-monitor/pattern -# 2. On connection failure, automatically fall back to -# https://cdn.jsdelivr.net/gh/OpenSteam001/steam-monitor@pattern -# 3. On total remote failure, fall back to /opensteamtool/pattern/ -# (the local cache, written after each successful remote fetch). -# -# Remote is consulted every launch so the upstream bot can re-publish a TOML -# (adding new signatures or fixing existing ones) and users pick it up -# automatically without clearing any cache. -# -# Most users — including users behind GFW where raw.githubusercontent.com -# is blocked but jsDelivr is reachable — do NOT need to set anything here. -# -# Setting `mirror` below DISABLES the automatic GitHub→jsDelivr fallback: -# only the URL you specify is tried (an explicit user choice wins). Local -# cache fallback still applies. Use this if you have a private mirror, an -# intranet server, or want a CDN that's faster than both defaults on your -# network. Verify your chosen mirror by -# opening the FULL URL (mirror + subdir + a real sha256 + ".toml") in a -# browser — pasting just the mirror base will usually show "Invalid URL" -# from CDNs, that's expected. -# -# Example full URL to verify in a browser (replace with one from -# https://github.com/OpenSteam001/steam-monitor/tree/pattern/steamui): -# https://cdn.jsdelivr.net/gh/OpenSteam001/steam-monitor@pattern/steamui/.toml -# -# Common mirrors: -# -# # jsDelivr (global CDN, generally reachable from mainland China) -# mirror = "https://cdn.jsdelivr.net/gh/OpenSteam001/steam-monitor@pattern" -# -# # ghproxy (community proxy — availability varies, use at your own risk) -# mirror = "https://ghproxy.com/https://raw.githubusercontent.com/OpenSteam001/steam-monitor/pattern" -# -# # Self-hosted: any HTTPS server that serves the same directory layout -# mirror = "https://your.server.com/opensteamtool-patterns" -# -# A trailing "/" is allowed but optional; OpenSteamTool strips it. -# mirror = "" +# url_template = "https://your.server/{channel}/{component}/{sha256}.toml" +# url_template = "https://fast.jsdelivr.net/gh/OpenSteam001/steam-monitor@{channel}/{component}/{sha256}.toml" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 708a685..4e9bb68 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,6 +22,7 @@ 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") +set(OPENSTEAMTOOL_TOOLS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../tools") include(Lua) include(Detours) include(Spdlog) @@ -29,43 +30,46 @@ include(Protobuf) include(Tomlplusplus) include(LogMacros) +# Build the host code generators (ipc_codegen) in their own binary subtree so +# their executables never land in the shipped output directory. EXCLUDE_FROM_ALL +# means only the tools actually depended on (ipc_codegen) get built here. +add_subdirectory("${OPENSTEAMTOOL_TOOLS_DIR}" "${CMAKE_BINARY_DIR}/tools" EXCLUDE_FROM_ALL) +include("${OPENSTEAMTOOL_TOOLS_DIR}/cmake/IPCCodegen.cmake") + +set(IPC_IDL "${CMAKE_CURRENT_SOURCE_DIR}/Steam/IPCMessages.steamd") +# Per-config output dir, mirroring the protobuf recipe below: a Multi-Config +# build invokes the generator once per config, and a single shared output file +# would ping-pong between the Release/Debug graphs and force needless rebuilds. +opensteamtool_add_ipc_codegen(IPC_GEN + IDL "${IPC_IDL}" + CPP_OUT "${CMAKE_CURRENT_BINARY_DIR}/generated/$" +) + # --------------------------------------------------------------------------- -# Protobuf code generation — two variants from the same .proto: +# Protobuf code generation — one variant per config, from the same .proto: # -# Debug → full Message (protoc --cpp_out) → links libprotobuf -# Release → lite MessageLite (protoc --cpp_out=lite) → links libprotobuf-lite +# 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. +# Output lands in the per-config generated/ dir (same root as the IPC header), +# so each config owns its own files. A shared output path would ping-pong +# between the Release/Debug graphs (the command embeds the config-specific +# protoc path) and force a regen + recompile on every config switch. # --------------------------------------------------------------------------- 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") +set(PROTO_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated/$") -# 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}" + "--cpp_out=$<$:lite:>${PROTO_GEN_DIR}" "-I${CMAKE_CURRENT_SOURCE_DIR}/proto" "${PROTO_SRC}" DEPENDS "${PROTO_SRC}" protoc - COMMENT "Generating protobuf lite-MessageLite sources (Release)" + COMMENT "Generating protobuf C++ sources" + VERBATIM ) # --------------------------------------------------------------------------- @@ -85,6 +89,12 @@ add_library(OpenSteamTool SHARED Utils/WinHttp.cpp Utils/FileWatcher.cpp Utils/ManifestClient.cpp + Utils/RemoteToml.cpp + Utils/IPCLoader.cpp + Utils/SteamDiagnostics.cpp + + # Generated IPC message structs (depends on IPCMessages.steamd via add_custom_command) + "${IPC_GEN}" # Per-category hook modules Hook/HookManager.cpp @@ -97,20 +107,19 @@ add_library(OpenSteamTool SHARED Hook/Hooks_Manifest.cpp Hook/Hooks_Misc.cpp Hook/Hooks_NetPacket.cpp + Hook/PendingAPICalls.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> + # protobuf generated sources (full in Debug, lite in Release) + "${PROTO_GEN_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}> + ${CMAKE_CURRENT_BINARY_DIR}/generated/$ ) target_link_libraries(OpenSteamTool PRIVATE diff --git a/src/Hook/Hooks_Decryption.cpp b/src/Hook/Hooks_Decryption.cpp index bff22b6..81e02f6 100644 --- a/src/Hook/Hooks_Decryption.cpp +++ b/src/Hook/Hooks_Decryption.cpp @@ -4,9 +4,18 @@ #include namespace { - HOOK_FUNC(LoadDepotDecryptionKey, int32, void* pObject, uint32 foo,char* KeyName, char* Key, uint32 KeySize) { + + void* g_pConfigStoreLocal = nullptr; + + HOOK_FUNC(ConfigStoreGetBinary, int32, void* pObject, EConfigStore eConfigStore, const char* KeyName, char* Key, uint32 KeySize) { + if (eConfigStore == k_EConfigStoreUserLocal && pObject && !g_pConfigStoreLocal) { + g_pConfigStoreLocal = pObject; + LOG_DECRYPTIONKEY_DEBUG("ConfigStoreGetBinary: captured local ConfigStore at {}", g_pConfigStoreLocal); + + } std::string name(KeyName); - LOG_DECRYPTIONKEY_DEBUG("LoadDepotDecryptionKey called for KeyName='{}'", name); + LOG_DECRYPTIONKEY_DEBUG("ConfigStore::GetBinary called for pObject={}, eConfigStore={}, KeyName='{}'", + pObject, static_cast(eConfigStore), name); // Expected shape: ".../\DecryptionKey" if (size_t last = name.find("\\DecryptionKey"); last != std::string::npos) { if (size_t start = name.find_last_of("\\", last - 1); start != std::string::npos) { @@ -23,20 +32,52 @@ namespace { } } } - return oLoadDepotDecryptionKey(pObject, foo, KeyName, Key, KeySize); + return oConfigStoreGetBinary(pObject, eConfigStore, KeyName, Key, KeySize); + } + + std::vector ReadConfigStoreLocalBinary(const std::string& keyName) { + if (!g_pConfigStoreLocal || !oConfigStoreGetBinary) { + LOG_DECRYPTIONKEY_WARN("GetConfigStoreLocalBinary: ConfigStoreGetBinary not ready, cannot get binary value"); + return {}; + } + + std::vector value(1024); + int32 result = oConfigStoreGetBinary(g_pConfigStoreLocal, k_EConfigStoreUserLocal, + keyName.c_str(), + reinterpret_cast(value.data()), + static_cast(value.size())); + if (result <= 0) { + LOG_DECRYPTIONKEY_DEBUG("GetConfigStoreLocalBinary: failed to read KeyName='{}'", keyName); + return {}; + } + + value.resize(result); + LOG_DECRYPTIONKEY_DEBUG("GetConfigStoreLocalBinary: got value for KeyName='{}' ({} bytes)", + keyName, value.size()); + return value; } } namespace Hooks_Decryption { void Install() { HOOK_BEGIN(); - INSTALL_HOOK_C(LoadDepotDecryptionKey); + INSTALL_HOOK_C(ConfigStoreGetBinary); HOOK_END(); } void Uninstall() { UNHOOK_BEGIN(); - UNINSTALL_HOOK_C(LoadDepotDecryptionKey); + UNINSTALL_HOOK_C(ConfigStoreGetBinary); UNHOOK_END(); } + + std::vector GetCacheAppOwnershipTicket(AppId_t appId) { + std::vector ticket = ReadConfigStoreLocalBinary(std::format("apptickets\\{}", appId)); + if (ticket.empty()) { + LOG_DECRYPTIONKEY_DEBUG("no cached ticket for AppId {}", appId); + return ticket; + } + LOG_DECRYPTIONKEY_DEBUG("got cached ticket for AppId {} ({} bytes)", appId, ticket.size()); + return ticket; + } } diff --git a/src/Hook/Hooks_Decryption.h b/src/Hook/Hooks_Decryption.h index a8fc081..4008734 100644 --- a/src/Hook/Hooks_Decryption.h +++ b/src/Hook/Hooks_Decryption.h @@ -1,8 +1,11 @@ #pragma once +#include "dllmain.h" namespace Hooks_Decryption { // LoadDepotDecryptionKey hook: serves user-provided decryption keys for // depots configured via Lua. void Install(); void Uninstall(); + + std::vector GetCacheAppOwnershipTicket(AppId_t appId); } diff --git a/src/Hook/Hooks_IPC.cpp b/src/Hook/Hooks_IPC.cpp index dded03f..37aada4 100644 --- a/src/Hook/Hooks_IPC.cpp +++ b/src/Hook/Hooks_IPC.cpp @@ -1,92 +1,112 @@ +#include "dllmain.h" #include "Hooks_IPC.h" #include "Hooks_IPC_ISteamUser.h" #include "Hooks_IPC_ISteamUtils.h" #include "HookMacros.h" -#include "dllmain.h" -#include "Utils/Hash.h" #include "Hooks_Misc.h" +#include "Utils/Hash.h" +#include "Utils/IPCLoader.h" namespace { - RESOLVE_FUNC(GetPipeClient, CSteamPipeClient*, void* pEngine, HSteamPipe hSteamPipe); + RESOLVE_FUNC(GetPipeClient, CPipeClient*, void* pEngine, HSteamPipe hSteamPipe); - static CSteamPipeClient* GetPipe(void* pServer, HSteamPipe hSteamPipe) { + static CPipeClient* GetPipe(void* pServer, HSteamPipe hSteamPipe) { return oGetPipeClient ? oGetPipeClient(pServer, hSteamPipe) : nullptr; } - // ════════════════════════════════════════════════════════════════ - // Handler registry - // ════════════════════════════════════════════════════════════════ - using namespace Hooks_IPC; - - std::vector g_Handlers; + // Handler dispatch table + struct ResolvedHandler { + EIPCInterface interfaceID; + uint32 funcHash; + std::string name; // "IClientUser::GetSteamID" — for logs + uint32 fencepost; + uint32 argc; + IPCHandlerFn pre; + IPCHandlerFn post; + + ResolvedHandler(const IPCHandlerEntry& entry, const IPCLoader::Method& method) + : interfaceID(method.interfaceID), + funcHash(method.funcHash), + name(std::string(entry.interfaceName) + "::" + entry.methodName), + fencepost(method.fencepost), + argc(method.argc), + pre(entry.pre), + post(entry.post) {} + + std::string DebugString() const { + return std::format("{} -> hash=0x{:08X} fencepost=0x{:08X} argc={}", + name, funcHash, fencepost, argc); + } + }; + std::vector g_Handlers; - static const IpcHandlerEntry* FindHandler(EIPCInterface iface, uint32 funcHash) { + static ResolvedHandler* FindHandler(EIPCInterface iface, uint32 funcHash) { for (auto& e : g_Handlers) { if (e.interfaceID == iface && e.funcHash == funcHash) return &e; } return nullptr; } - // ════════════════════════════════════════════════════════════════ - // Main hook - // ════════════════════════════════════════════════════════════════ - HOOK_FUNC(IPCProcessMessage, bool, - void* pServer, HSteamPipe hSteamPipe, + struct IPCDispatch { + CPipeClient* pipe = nullptr; + ResolvedHandler* handler = nullptr; + + bool enabled() const { + return pipe && handler; + } + + std::string DebugString() const { + return std::format("{} {}",pipe ? pipe->DebugString() : "null", + handler ? handler->DebugString() : "null"); + } + }; + + static IPCDispatch ResolveDispatch(void* pServer,HSteamPipe hSteamPipe,CUtlBuffer* pRead) + { + IPCDispatch dispatch{}; + dispatch.pipe = GetPipe(pServer, hSteamPipe); + if (!dispatch.pipe) return dispatch; + + // We only care about InterfaceCall messages + IPCMessages::IPCRequest request{pRead}; + if (!request.ok()) return dispatch; + if (request.command() != EIPCCommand::InterfaceCall) return dispatch; + + // Ignore calls when appId is not resolved or not in Lua config + if (!LuaConfig::HasDepot(Hooks_Misc::ResolveAppId())) return dispatch; + + // Parse out the interface call header to find the handler + IPCMessages::IPCInterfaceCall call{request.body()}; + if (!call.ok()) return dispatch; + + // Lookup handler by interface ID + method hash + dispatch.handler = FindHandler(call.interfaceID(), call.funcHash()); + if (!dispatch.handler) return dispatch; + + LOG_IPC_DEBUG("Resolved IPC handler: {}", dispatch.DebugString()); + return dispatch; + } + + HOOK_FUNC(IPCProcessMessage, bool,void* pServer, HSteamPipe hSteamPipe, CUtlBuffer* pRead, CUtlBuffer* pWrite) { - CSteamPipeClient* pipe = GetPipe(pServer, hSteamPipe); - - // ── Parse header, find handler ────────────────────────── - const IpcHandlerEntry* entry = nullptr; - - if (pRead->TellPut() >= IPC_HEADER_SIZE) { - const uint8* data = pRead->Base(); - const auto cmd = static_cast(data[OFFSET_CMD]); - - if (cmd == EIPCCommand::Handshake) { - LOG_IPC_INFO("[Handshake]: {}", pipe->DebugString()); - } else if (cmd == EIPCCommand::InterfaceCall) { - // exclude InterfaceCall from steam - if ((pipe->m_hSteamPipe & 0xFFFF) <= 2) { - LOG_IPC_TRACE("[InterfaceCall] from steam, pipe=0x{:08X} skip handler", pipe->m_hSteamPipe); - return oIPCProcessMessage(pServer, hSteamPipe, pRead, pWrite); - } - const auto iface = static_cast(data[OFFSET_INTERFACE_ID]); - const uint32 funcHash = *reinterpret_cast(data + OFFSET_FUNC_HASH); - entry = FindHandler(iface, funcHash); - if (entry) { - LOG_IPC_DEBUG("[InterfaceCall] {} {} realAppId={},AppId={}", - entry->name, pipe->DebugString(), - Hooks_Misc::ResolveAppId(), - Hooks_Misc::GetAppIDForCurrentPipeWrap() - ); - } else { - LOG_IPC_TRACE("[InterfaceCall(unhandled)]{}::0x{:08X} {} realAppId={},AppId={}", - EIPCInterfaceName(iface), funcHash, - pipe->DebugString(), - Hooks_Misc::ResolveAppId(), - Hooks_Misc::GetAppIDForCurrentPipeWrap() - ); - } - } else { - LOG_IPC_TRACE("[{}] {}", EIPCCommandName(cmd), pipe->DebugString()); - } - } + const IPCDispatch dispatch = ResolveDispatch(pServer, hSteamPipe, pRead); + // If we didn't find a handler for this message, just pass through to the original function. + if(!dispatch.enabled()) + return oIPCProcessMessage(pServer, hSteamPipe, pRead, pWrite); - // ── Run original ──────────────────────────────────────── - const bool result = oIPCProcessMessage(pServer, hSteamPipe, pRead, pWrite); - if (!result || !entry) return result; + // If we did find a handler, run the pre-handler + if (dispatch.handler->pre) + dispatch.handler->pre(dispatch.pipe, pRead, pWrite); - // Only run handlers for apps with configured depots. - AppId_t appId = Hooks_Misc::ResolveAppId(); - if (!LuaConfig::HasDepot(appId)) { - LOG_IPC_TRACE("{}: appId={} has no configured depot, skip handler {}", - entry->name, appId, pipe->DebugString()); - return result; - } + // Then call the original function to let steamclient process the message as normal. + bool result = oIPCProcessMessage(pServer, hSteamPipe, pRead, pWrite); + + // Ultimately the post-handler can choose to modify the response. + if (result && dispatch.handler->post) + dispatch.handler->post(dispatch.pipe, pRead, pWrite); - entry->handler(pipe, pRead, pWrite); return result; } @@ -95,17 +115,16 @@ namespace { namespace Hooks_IPC { - void RegisterHandlers(const IpcHandlerEntry* entries, size_t count) { - g_Handlers.insert(g_Handlers.end(), entries, entries + count); - } - void Install() { RESOLVE_C(GetPipeClient); - // Interface modules register their handlers here. + // Each module registers a static array. Hash lookup against the + // IPCLoader metadata happens inside RegisterHandlers. Hooks_IPC_ISteamUser::Register(); Hooks_IPC_ISteamUtils::Register(); + LOG_IPC_INFO("Hooks_IPC: {} handlers registered", g_Handlers.size()); + HOOK_BEGIN(); INSTALL_HOOK_C(IPCProcessMessage); HOOK_END(); @@ -117,4 +136,16 @@ namespace Hooks_IPC { UNHOOK_END(); } + void RegisterHandlers(std::span entries) { + for (const auto& e : entries) { + const auto* m = IPCLoader::Find(e.interfaceName, e.methodName); + if (!m) { + LOG_IPC_WARN("[Handler Disabled] no IPC spec for {}",e.DebugString()); + continue; + } + auto& handler = g_Handlers.emplace_back(e,*m); + LOG_IPC_DEBUG("Hooks_IPC: resolved {}", handler.DebugString()); + } + } + } diff --git a/src/Hook/Hooks_IPC.h b/src/Hook/Hooks_IPC.h index 8206b22..666ee74 100644 --- a/src/Hook/Hooks_IPC.h +++ b/src/Hook/Hooks_IPC.h @@ -1,54 +1,36 @@ #pragma once - #include "Steam/Types.h" -#include "Steam/Enums.h" #include "Steam/Structs.h" +#include "IPCMessages.gen.h" +#include -#define ADD_IPC_HANDLER(iface, method) \ - { EIPCInterface::iface, HASH_##iface##_##method, \ - #iface "::" #method, \ - Handler_##iface##_##method } - - -// ── IPC InterfaceCall packet layout ───────────────────────────── -// offset 0: cmd (1 byte, EIPCCommand) -// offset 1: interfaceID (1 byte, EIPCInterface) -// offset 2: hSteamUser (4 bytes) -// offset 6: funcHash (4 bytes) -// offset 10: args[] (variable) -// ───────────────────────────────────────────────────────────────── -constexpr int OFFSET_CMD = 0; -constexpr int OFFSET_INTERFACE_ID = 1; -constexpr int OFFSET_FUNC_HASH = 6; -constexpr int OFFSET_ARGS = 10; -constexpr int IPC_HEADER_SIZE = 10; -constexpr uint8 RESPONSE_PREFIX = 0x0B; - -constexpr uint32 HASH_IClientUser_GetSteamID = 0xD6FC3200; -constexpr uint32 HASH_IClientUser_GetAppOwnershipTicketExtendedData = 0xC7E71245; -constexpr uint32 HASH_IClientUser_RequestEncryptedAppTicket = 0x25D6BB1D; -constexpr uint32 HASH_IClientUser_GetEncryptedAppTicket = 0xE0468CB4; - -constexpr uint32 HASH_IClientUtils_GetAppID = 0x09607EC4; -constexpr uint32 HASH_IClientUtils_GetAPICallResult = 0x2D3D3947; -constexpr uint32 HASH_IClientUtils_SetAppIDForCurrentPipe = 0x3378803C; +using IPCHandlerFn = void(*)(CPipeClient* pipe,CUtlBuffer* pRead, CUtlBuffer* pWrite); -namespace Hooks_IPC { +struct IPCHandlerEntry { + const char* interfaceName; + const char* methodName; + IPCHandlerFn pre; + IPCHandlerFn post; - void Install(); - void Uninstall(); + std::string DebugString() const { + return std::format("{}::{} pre={} post={}", interfaceName, methodName, + pre ? "yes" : "no", post ? "yes" : "no"); + } +}; - // ── Handler registry ──────────────────────────────────────── +#define ADD_IPC_PRE_HANDLER(iface, method) \ + { #iface, #method, HandlerPre_##iface##_##method, nullptr } - using IpcHandlerFn = void(*)(CSteamPipeClient* pipe,CUtlBuffer* pRead, CUtlBuffer* pWrite); +#define ADD_IPC_POST_HANDLER(iface, method) \ + { #iface, #method, nullptr, HandlerPost_##iface##_##method } - struct IpcHandlerEntry { - EIPCInterface interfaceID; - uint32 funcHash; - const char* name; - IpcHandlerFn handler; - }; +#define ADD_IPC_BOTH_HANDLER(iface, method) \ + { #iface, #method, HandlerPre_##iface##_##method, HandlerPost_##iface##_##method } - void RegisterHandlers(const IpcHandlerEntry* entries, size_t count); -} +namespace Hooks_IPC { + // IPC hooks: intercepts IPC messages between game and steam + void Install(); + void Uninstall(); + void RegisterHandlers(std::span entries); +} \ No newline at end of file diff --git a/src/Hook/Hooks_IPC_ISteamUser.cpp b/src/Hook/Hooks_IPC_ISteamUser.cpp index 4a82629..3db04c4 100644 --- a/src/Hook/Hooks_IPC_ISteamUser.cpp +++ b/src/Hook/Hooks_IPC_ISteamUser.cpp @@ -1,147 +1,121 @@ -#include "Hooks_IPC.h" -#include "Hooks_IPC_ISteamUser.h" -#include "Utils/AppTicket.h" -#include "Utils/Log.h" -#include "Hooks_Misc.h" - -namespace { - // ── eticket: hAsyncCall → appId mapping ──────────────────────── - std::unordered_map g_EticketAsyncCalls; - - // ── Handler: IClientUser::GetSteamID ────────────────────────── - // Request: no args - // Response: [uint8 prefix=0x0B][uint64 SteamID] (9 bytes) - void Handler_IClientUser_GetSteamID(CSteamPipeClient* pipe, - CUtlBuffer*, CUtlBuffer* pWrite) - { - AppId_t appId = Hooks_Misc::ResolveAppId(); - const uint64 spoofed = AppTicket::GetSpoofSteamID(appId); - if (!spoofed) { - LOG_IPC_WARN("IClientUser::GetSteamID: AppId={} no valid steamid - cannot spoof", appId); - return; - } - uint8* base = pWrite->Base(); - base[0] = RESPONSE_PREFIX; - memcpy(base + 1, &spoofed, sizeof(spoofed)); - LOG_IPC_DEBUG("IClientUser::GetSteamID: AppId={} -> Spoofed: 0x{:X}({})", appId, spoofed, spoofed); - } - - // ── Handler: IClientUser::GetAppOwnershipTicketExtendedData ─── - void Handler_IClientUser_GetAppOwnershipTicketExtendedData( - CSteamPipeClient* pipe, CUtlBuffer* pRead, CUtlBuffer* pWrite) - { - const uint8* reqData = pRead->Base(); - const int32 reqSize = pRead->m_Put; - if (reqSize < OFFSET_ARGS + 8) return; - const uint8* args = reqData + OFFSET_ARGS; - const uint32 reqAppID = *reinterpret_cast(args); - const int32 reqBufSize = *reinterpret_cast(args + 4); - - LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData: req AppID={} bufSize={}", - reqAppID, reqBufSize); - - std::vector ticket = AppTicket::GetAppOwnershipTicketFromRegistry(reqAppID); - if (ticket.empty() || ticket.size() < 4) return; - - const uint32 ticketSize = static_cast(ticket.size()); - const uint32 sigOffset = *reinterpret_cast(ticket.data()); - - const uint32 totalSize = 1 + 4 + reqBufSize + 16; - if (static_cast(pWrite->m_Put) < totalSize) return; - - uint8* base = pWrite->Base(); - - base[0] = RESPONSE_PREFIX; - memcpy(base + 1, &ticketSize, 4); - const uint32 copySize = (ticketSize < static_cast(reqBufSize)) - ? ticketSize : static_cast(reqBufSize); - memcpy(base + 5, ticket.data(), copySize); - if (copySize < static_cast(reqBufSize)) - memset(base + 5 + copySize, 0, reqBufSize - copySize); - - const uint32 piAppId = 16; - const uint32 piSteamId = 8; - const uint32 piSignature = sigOffset; - const uint32 pcbSignature = 128; - const uint32 outOff = 5 + reqBufSize; - memcpy(base + outOff, &piAppId, 4); - memcpy(base + outOff + 4, &piSteamId, 4); - memcpy(base + outOff + 8, &piSignature, 4); - memcpy(base + outOff + 12, &pcbSignature, 4); - - AppId_t appId = Hooks_Misc::ResolveAppId(); - LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} -> {} bytes " - "(sigOffset={})", appId, ticketSize, sigOffset); - } - - // ── Handler: IClientUser::RequestEncryptedAppTicket ────────── - void Handler_IClientUser_RequestEncryptedAppTicket( - CSteamPipeClient* pipe, CUtlBuffer*, CUtlBuffer* pWrite) - { - if (pWrite->m_Put < 9) return; - - AppId_t appId = Hooks_Misc::ResolveAppId(); - auto ticket = AppTicket::GetEncryptedTicketFromRegistry(appId); - if (ticket.empty()) { - LOG_IPC_DEBUG("RequestEncryptedAppTicket: AppId={} - no cached eticket, skip", appId); - return; - } - - uint8* base = pWrite->Base(); - uint64 hAsyncCall; - memcpy(&hAsyncCall, base + 1, sizeof(hAsyncCall)); - - g_EticketAsyncCalls[hAsyncCall] = appId; - LOG_IPC_DEBUG("RequestEncryptedAppTicket: AppId={} hAsyncCall=0x{:016X} - recorded", appId, hAsyncCall); - } - - // ── Handler: IClientUser::GetEncryptedAppTicket ─────────────── - void Handler_IClientUser_GetEncryptedAppTicket( - CSteamPipeClient* pipe, CUtlBuffer*, CUtlBuffer* pWrite) - { - AppId_t appId = Hooks_Misc::ResolveAppId(); - auto ticket = AppTicket::GetEncryptedTicketFromRegistry(appId); - if (ticket.empty()) { - LOG_IPC_DEBUG("GetEncryptedAppTicket: AppId={} - no cached eticket, skip", appId); - return; - } - - const uint32 ticketSize = static_cast(ticket.size()); - const int32 totalSize = 1 + 1 + 4 + ticketSize; - if (!Hooks_Misc::EnsureBufferSize(pWrite, totalSize)) { - LOG_IPC_DEBUG("GetEncryptedAppTicket: AppId={} - failed to ensure buffer size", appId); - return; - } - pWrite->m_Put = totalSize; - - uint8* base = pWrite->Base(); - base[0] = RESPONSE_PREFIX; - base[1] = 1; - memcpy(base + 2, &ticketSize, sizeof(ticketSize)); - memcpy(base + 6, ticket.data(), ticketSize); - - LOG_IPC_DEBUG("GetEncryptedAppTicket: AppId={} -> {} bytes", appId, ticketSize); - } - - const Hooks_IPC::IpcHandlerEntry g_Entries[] = { - ADD_IPC_HANDLER(IClientUser, GetSteamID), - ADD_IPC_HANDLER(IClientUser, GetAppOwnershipTicketExtendedData), - ADD_IPC_HANDLER(IClientUser, RequestEncryptedAppTicket), - ADD_IPC_HANDLER(IClientUser, GetEncryptedAppTicket), - }; - -} // namespace - -namespace Hooks_IPC_ISteamUser { - void Register() { - Hooks_IPC::RegisterHandlers(g_Entries, std::size(g_Entries)); - } - - AppId_t LookupEticketAsyncCall(uint64 hAsyncCall) { - auto it = g_EticketAsyncCalls.find(hAsyncCall); - return it != g_EticketAsyncCalls.end() ? it->second : 0; - } - void EraseEticketAsyncCall(uint64 hAsyncCall) { - g_EticketAsyncCalls.erase(hAsyncCall); - } -} +#include "Hooks_IPC.h" +#include "Hooks_IPC_ISteamUser.h" +#include "PendingAPICalls.h" +#include "Utils/AppTicket.h" +#include "Utils/Log.h" +#include "Hooks_Misc.h" + +namespace { + using namespace IPCMessages::IClientUser; + + // [Post-Handler]: IClientUser::GetSteamID + void HandlerPost_IClientUser_GetSteamID(CPipeClient* pipe,CUtlBuffer* pRead, CUtlBuffer* pWrite) + { + AppId_t appId = Hooks_Misc::ResolveAppId(); + const uint64 spoofed = AppTicket::GetSpoofSteamID(appId); + if (!spoofed) { + LOG_IPC_WARN("IClientUser::GetSteamID: AppId={} no valid steamid - cannot spoof", appId); + return; + } + + GetSteamIDResp resp{pWrite}; + if (!resp.ok()) return; + LOG_IPC_DEBUG("IClientUser::GetSteamID: AppId={} Original: {} -> Spoofed: 0x{:X}({})", + appId,resp.DebugString(),spoofed, spoofed); + resp.set_returnValue(spoofed); + } + + // [Post-Handler]: IClientUser::GetAppOwnershipTicketExtendedData + void HandlerPost_IClientUser_GetAppOwnershipTicketExtendedData(CPipeClient* pipe, CUtlBuffer* pRead, CUtlBuffer* pWrite) + { + GetAppOwnershipTicketExtendedDataReq req{pRead}; + if (!req.ok()) return; + + LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData:{}", req.DebugString()); + if (req.cbMaxTicket() < 0) return; + + AppTicket::AppOwnershipTicket ticket{}; + AppId_t appId = req.unAppID() == kOnlineFixAppId ? Hooks_Misc::ResolveAppId() : req.unAppID(); + + if (!AppTicket::GetAppOwnershipTicket(appId, ticket)) return; + if (ticket.data.size() > static_cast(req.cbMaxTicket())) { + LOG_IPC_WARN("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} ticket too large ({} bytes) for buffer ({} bytes)", + appId, ticket.data.size(), req.cbMaxTicket()); + return; + } + + GetAppOwnershipTicketExtendedDataResp resp{pWrite, static_cast(req.cbMaxTicket())}; + if (!resp.ok()) return; + + resp.set_returnValue(ticket.totalSize); + if (!resp.set_pTicket(ticket.data)) return; + resp.set_piAppId(ticket.appIdOffset); + resp.set_piSteamId(ticket.steamIdOffset); + resp.set_piSignature(ticket.signatureOffset); + resp.set_pcbSignature(ticket.signatureSize); + + LOG_IPC_DEBUG("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} {}", + appId,resp.DebugString()); + } + + // [Post-Handler]: IClientUser::RequestEncryptedAppTicket + // Reads the hAsyncCall steamclient already wrote into the response, + // so we know which AppId to mint an eticket for in GetAPICallResult. + void HandlerPost_IClientUser_RequestEncryptedAppTicket(CPipeClient* pipe, CUtlBuffer* pRead, CUtlBuffer* pWrite) + { + RequestEncryptedAppTicketResp resp{pWrite}; + if (!resp.ok()) return; + + AppId_t appId = Hooks_Misc::ResolveAppId(); + std::vector ticket = AppTicket::GetEncryptedTicketFromRegistry(appId); + if (ticket.empty()) { + 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); + } + + // [Post-Handler]: IClientUser::GetEncryptedAppTicket + void HandlerPost_IClientUser_GetEncryptedAppTicket(CPipeClient* pipe, CUtlBuffer* pRead, CUtlBuffer* pWrite) + { + AppId_t appId = Hooks_Misc::ResolveAppId(); + std::vector ticket = AppTicket::GetEncryptedTicketFromRegistry(appId); + if (ticket.empty()) { + LOG_IPC_DEBUG("GetEncryptedAppTicket: AppId={} - no cached eticket, skip", appId); + return; + } + + uint32 ticketSize = static_cast(ticket.size()); + uint32 newCapacity = pWrite->Capacity() + ticketSize; + if (!Hooks_Misc::EnsureBufferCapacity(pWrite, newCapacity,true)) { + LOG_IPC_DEBUG("GetEncryptedAppTicket: AppId={} - failed to ensure buffer size", appId); + return; + } + + GetEncryptedAppTicketResp resp{pWrite}; + if (!resp.ok()) return; + + resp.set_returnValue(true); + resp.set_pcbTicket(ticketSize); + if (!resp.set_pTicket(ticket)) return; + + LOG_IPC_DEBUG("GetEncryptedAppTicket: AppId={} {}", appId, resp.DebugString()); + } + +} // namespace + +namespace Hooks_IPC_ISteamUser { + void Register() { + IPCHandlerEntry UserEntries[] = { + ADD_IPC_POST_HANDLER(IClientUser, GetSteamID), + ADD_IPC_POST_HANDLER(IClientUser, GetAppOwnershipTicketExtendedData), + ADD_IPC_POST_HANDLER(IClientUser, RequestEncryptedAppTicket), + ADD_IPC_POST_HANDLER(IClientUser, GetEncryptedAppTicket), + }; + Hooks_IPC::RegisterHandlers(UserEntries); + } +} diff --git a/src/Hook/Hooks_IPC_ISteamUser.h b/src/Hook/Hooks_IPC_ISteamUser.h index 898ae37..fc528cd 100644 --- a/src/Hook/Hooks_IPC_ISteamUser.h +++ b/src/Hook/Hooks_IPC_ISteamUser.h @@ -1,13 +1,5 @@ #pragma once -#include -#include "Steam/Types.h" - namespace Hooks_IPC_ISteamUser { void Register(); - - // eticket async-call map for GetAPICallResult(154). - // LookupEticketAsyncCall returns the AppId if recorded, 0 otherwise. - AppId_t LookupEticketAsyncCall(uint64 hAsyncCall); - void EraseEticketAsyncCall(uint64 hAsyncCall); } diff --git a/src/Hook/Hooks_IPC_ISteamUtils.cpp b/src/Hook/Hooks_IPC_ISteamUtils.cpp index 704ec01..5390049 100644 --- a/src/Hook/Hooks_IPC_ISteamUtils.cpp +++ b/src/Hook/Hooks_IPC_ISteamUtils.cpp @@ -1,49 +1,45 @@ #include "Hooks_IPC.h" #include "Hooks_IPC_ISteamUtils.h" -#include "Hooks_IPC_ISteamUser.h" #include "Hooks_Misc.h" +#include "PendingAPICalls.h" #include "Steam/Callback.h" #include "Utils/Log.h" -namespace { +#include - // ── IClientUtils::GetAPICallResult request args ────────────── - struct GetAPICallResultRequest { - uint64 hSteamAPICall; // +0 - uint32 cubCallback; // +8 - uint32 iCallbackExpected; // +12 - }; +namespace { + using namespace IPCMessages::IClientUtils; - // ── Helper: write the GetAPICallResult response boilerplate ─── - template - bool WriteCallbackResponse(CUtlBuffer* pWrite, F&& fill) + template + bool WriteAPICallResult(CUtlBuffer* pWrite,uint32 callbackCapacity,const CallbackT& callback) { - constexpr int32 total = 1 + 1 + sizeof(CallbackT) + 1; - if (pWrite->m_Put < total) return false; - - uint8* base = pWrite->m_Memory.m_pMemory; - base[0] = RESPONSE_PREFIX; - base[1] = 1; - base[2 + sizeof(CallbackT)] = 0; - - auto* cb = reinterpret_cast(base + 2); - fill(*cb); + static_assert(std::is_trivially_copyable_v); + if (callbackCapacity < sizeof(CallbackT)) return false; + + GetAPICallResultResp resp{pWrite, callbackCapacity}; + if (!resp.ok()) return false; + if (!resp.set_pCallback(IPCMessages::asBytes(callback))) return false; + resp.set_returnValue(true); + resp.set_pbFailed(false); return true; } - // ── Handler: IClientUtils::GetAppID ────────────────────────── + // [Post-Handler]: IClientUtils::GetAppID // SpawnProcess rewrites pGameID to 480 for OnlineFix games, // so steamclient returns 480. Restore the real app_id. - void Handler_IClientUtils_GetAppID( - CSteamPipeClient* pipe, CUtlBuffer*, CUtlBuffer* pWrite) + // GetAppID reads and updates the response steamclient pre-filled. + void HandlerPost_IClientUtils_GetAppID(CPipeClient* pipe, CUtlBuffer* pRead, CUtlBuffer* pWrite) { AppId_t realAppId = Hooks_Misc::ResolveAppId(); - if (!realAppId || pWrite->m_Put < 5) return; + if (!realAppId) return; - AppId_t current = *reinterpret_cast(pWrite->Base() + 1); - if (current == realAppId) return; + GetAppIDResp resp{pWrite}; + if (!resp.ok()) return; - *reinterpret_cast(pWrite->Base() + 1) = realAppId; + // Read what steamclient just wrote, decide whether to spoof. + const AppId_t current = resp.returnValue(); + if (current == realAppId) return; + resp.set_returnValue(realAppId); LOG_IPC_INFO("GetAppID: spoof response {} -> {}", current, realAppId); } @@ -51,61 +47,57 @@ namespace { // GetAPICallResult per-callback handlers // ════════════════════════════════════════════════════════════════ - bool HandleCallback_EncryptedAppTicketResponse( - CUtlBuffer* pWrite, uint64 hAsyncCall, uint32 cubCallback) + bool HandleCallback_EncryptedAppTicketResponse(CUtlBuffer* pWrite, uint64 hAsyncCall, uint32 cubCallback) { - AppId_t appId = Hooks_IPC_ISteamUser::LookupEticketAsyncCall(hAsyncCall); + const auto appId = PendingAPICalls::TakeEncryptedTicket(hAsyncCall); if (!appId) return false; - LOG_IPC_DEBUG("GetAPICallResult: EncryptedAppTicketResponse hAsyncCall=0x{:016X} " - "AppId={} - injecting k_EResultOK", hAsyncCall, appId); - - if (!WriteCallbackResponse(pWrite, [](auto& cb) { - cb.m_eResult = k_EResultOK; - })) return false; - - Hooks_IPC_ISteamUser::EraseEticketAsyncCall(hAsyncCall); + EncryptedAppTicketResponse_t callback{}; + callback.m_eResult = k_EResultOK; + if (!WriteAPICallResult(pWrite, cubCallback, callback)) { + PendingAPICalls::RecordEncryptedTicket(hAsyncCall, *appId); + LOG_IPC_WARN("Failed to write EncryptedAppTicketResponse for AppId={} hAsyncCall=0x{:X}", + *appId, hAsyncCall); + return false; + } + LOG_IPC_DEBUG("Set K_EResultOK for EncryptedAppTicketResponse callback, AppId={} hAsyncCall=0x{:X}", + *appId, hAsyncCall); return true; } - struct GacrDispatchEntry { - uint32 callbackId; - bool (*handler)(CUtlBuffer* pWrite, uint64 hAsyncCall, uint32 cubCallback); + struct APICallResultHandlerEntry { + uint32 callbackId; + bool (*handler)(CUtlBuffer* pWrite, uint64 hAsyncCall, uint32 cubCallback); }; - constexpr GacrDispatchEntry g_GacrDispatch[] = { + constexpr APICallResultHandlerEntry kAPICallResultHandlers[] = { { EncryptedAppTicketResponse_t::k_iCallback, HandleCallback_EncryptedAppTicketResponse }, }; - // ── Handler: IClientUtils::GetAPICallResult ────────────────── - void Handler_IClientUtils_GetAPICallResult( - CSteamPipeClient*, CUtlBuffer* pRead, CUtlBuffer* pWrite) + // [Post-Handler]: IClientUtils::GetAPICallResult + void HandlerPost_IClientUtils_GetAPICallResult(CPipeClient*, CUtlBuffer* pRead, CUtlBuffer* pWrite) { - if (pRead->m_Put < OFFSET_ARGS + sizeof(GetAPICallResultRequest)) return; - - const auto* req = reinterpret_cast( - pRead->Base() + OFFSET_ARGS); - - AppId_t appId = Hooks_Misc::GetAppIDForCurrentPipeWrap(); - LOG_IPC_DEBUG("GetAPICallResult: hAsyncCall=0x{:016X} AppId={} iCallback={} cubCallback={}", - req->hSteamAPICall, appId, req->iCallbackExpected, req->cubCallback); - for (auto& entry : g_GacrDispatch) { - if (entry.callbackId == req->iCallbackExpected) { - entry.handler(pWrite, req->hSteamAPICall, req->cubCallback); + GetAPICallResultReq req{pRead}; + if (!req.ok()) return; + + AppId_t appId = Hooks_Misc::ResolveAppId(); + LOG_IPC_DEBUG("{}, AppId={}", req.DebugString(),appId); + for (const auto& entry : kAPICallResultHandlers) { + if (entry.callbackId == req.iCallbackExpected()) { + entry.handler(pWrite, req.hSteamAPICall(), req.cubCallback()); return; } } } - const Hooks_IPC::IpcHandlerEntry g_Entries[] = { - ADD_IPC_HANDLER(IClientUtils, GetAppID), - ADD_IPC_HANDLER(IClientUtils, GetAPICallResult), - }; - } // namespace namespace Hooks_IPC_ISteamUtils { void Register() { - Hooks_IPC::RegisterHandlers(g_Entries, std::size(g_Entries)); + IPCHandlerEntry UtilsEntries[] = { + ADD_IPC_POST_HANDLER(IClientUtils, GetAppID), + ADD_IPC_POST_HANDLER(IClientUtils, GetAPICallResult), + }; + Hooks_IPC::RegisterHandlers(UtilsEntries); } } diff --git a/src/Hook/Hooks_Misc.cpp b/src/Hook/Hooks_Misc.cpp index 34ef5ca..97ed029 100644 --- a/src/Hook/Hooks_Misc.cpp +++ b/src/Hook/Hooks_Misc.cpp @@ -5,7 +5,7 @@ namespace { // ── Resolve-only functions ───────────────────────────────────── - RESOLVE_FUNC(CUtlBufferEnsureCapacity, void*, CUtlBuffer*, int); + RESOLVE_FUNC(CUtlBufferEnsureCapacity, void*, CUtlBuffer* pCUtlBuffer, uint32 newCapacity); // ── VEH-captured functions (one-shot int3) ─────────────────────────────── // On int3 hit, ctx->Rcx is stored to the named output variable. @@ -141,15 +141,16 @@ namespace Hooks_Misc { return GetAppIDForCurrentPipeWrap(); } - bool EnsureBufferSize(CUtlBuffer* pWrite, int32 size) + bool EnsureBufferCapacity(CUtlBuffer* pWrite, uint32 newCapacity,bool updatePut) { if (oCUtlBufferEnsureCapacity) { LOG_MISC_DEBUG("Before ensuring CUtlBuffer capacity: {}", pWrite->DebugString()); - oCUtlBufferEnsureCapacity(pWrite, size); + oCUtlBufferEnsureCapacity(pWrite, newCapacity); LOG_MISC_DEBUG("After ensuring CUtlBuffer capacity: {}", pWrite->DebugString()); + if(updatePut) pWrite->m_Put = newCapacity; return true; } - LOG_MISC_WARN("EnsureBufferSize: oCUtlBufferEnsureCapacity not resolved"); + LOG_MISC_WARN("EnsureBufferCapacity: oCUtlBufferEnsureCapacity not resolved"); return false; } diff --git a/src/Hook/Hooks_Misc.h b/src/Hook/Hooks_Misc.h index f7fbafc..044bf21 100644 --- a/src/Hook/Hooks_Misc.h +++ b/src/Hook/Hooks_Misc.h @@ -16,9 +16,9 @@ namespace Hooks_Misc { // GetAppIDForCurrentPipe. AppId_t GetAppIDForCurrentPipeWrap(); - // Grow a CUtlBuffer to at least 'size' bytes and set m_Put = size. + // Grow a CUtlBuffer to at least 'newCapacity' bytes and set m_Put = newCapacity. // Uses CUtlBuffer::EnsureCapacity from steamclient, resolved on first call. - bool EnsureBufferSize(CUtlBuffer* pWrite, int32 size); + bool EnsureBufferCapacity(CUtlBuffer* pWrite, uint32 newCapacity,bool updatePut = false); // Resolve the real appid: if OnlineFix is active return real appid, // otherwise fall back to GetAppIDForCurrentPipe(). diff --git a/src/Hook/Hooks_NetPacket.cpp b/src/Hook/Hooks_NetPacket.cpp index 740022a..7971539 100644 --- a/src/Hook/Hooks_NetPacket.cpp +++ b/src/Hook/Hooks_NetPacket.cpp @@ -1,10 +1,10 @@ #include "Hooks_NetPacket.h" +#include "Utils/ManifestClient.h" #include "Hooks_Misc.h" #include "HookMacros.h" #include "dllmain.h" #include "Utils/AppTicket.h" #include "Utils/Hash.h" -#include "Utils/ManifestClient.h" #include #include #include diff --git a/src/Hook/PendingAPICalls.cpp b/src/Hook/PendingAPICalls.cpp new file mode 100644 index 0000000..2bea390 --- /dev/null +++ b/src/Hook/PendingAPICalls.cpp @@ -0,0 +1,34 @@ +#include "PendingAPICalls.h" + +#include +#include + +namespace PendingAPICalls { + +namespace { + + std::mutex g_mutex; + std::unordered_map g_encryptedTickets; + +} // namespace + +void RecordEncryptedTicket(SteamAPICall_t call, AppId_t appID) +{ + if (call == k_uAPICallInvalid || appID == k_uAppIdInvalid) return; + + std::scoped_lock lock(g_mutex); + g_encryptedTickets[call] = appID; +} + +std::optional TakeEncryptedTicket(SteamAPICall_t call) +{ + std::scoped_lock lock(g_mutex); + const auto it = g_encryptedTickets.find(call); + if (it == g_encryptedTickets.end()) return std::nullopt; + + const AppId_t appID = it->second; + g_encryptedTickets.erase(it); + return appID; +} + +} // namespace PendingAPICalls diff --git a/src/Hook/PendingAPICalls.h b/src/Hook/PendingAPICalls.h new file mode 100644 index 0000000..b57d63f --- /dev/null +++ b/src/Hook/PendingAPICalls.h @@ -0,0 +1,12 @@ +#pragma once + +#include "Steam/Types.h" + +#include + +namespace PendingAPICalls { + + void RecordEncryptedTicket(SteamAPICall_t call, AppId_t appID); + std::optional TakeEncryptedTicket(SteamAPICall_t call); + +} // namespace PendingAPICalls diff --git a/src/Steam/Callback.h b/src/Steam/Callback.h index ab854db..1289fed 100644 --- a/src/Steam/Callback.h +++ b/src/Steam/Callback.h @@ -17,7 +17,6 @@ struct EncryptedAppTicketResponse_t //----------------------------------------------------------------------------- // Purpose: Broadcast when app licenses change (additions / removals / reload). // Sent by CClientAppManager after ProcessPendingLicenseUpdates. -// Size: 0x118 (280 bytes). //----------------------------------------------------------------------------- struct AppLicensesChanged_t { @@ -30,5 +29,3 @@ struct AppLicensesChanged_t AppId_t m_rgAppsUpdated[64]; // 0x0C — batch of updated AppIds uint64 m_unAppsAdded; // 0x110 — bitmask: bit N = m_rgAppsUpdated[N] was added }; -static_assert(sizeof(AppLicensesChanged_t) == 0x118, - "AppLicensesChanged_t must be 0x118 bytes"); diff --git a/src/Steam/Enums.h b/src/Steam/Enums.h index 157c7ca..1e65035 100644 --- a/src/Steam/Enums.h +++ b/src/Steam/Enums.h @@ -1726,190 +1726,6 @@ enum EWebSocketOpCode :char k_eWebSocketOpCode_Pong = 0x0A, }; -// IPC command codes used by CIPCServer::ProcessMessage. The first byte of an -// IPC request packet identifies which kind of operation the client is making. -enum class EIPCCommand : uint8 -{ - InterfaceCall = 1, // call into a Steam interface (IClientUser, IClientApps, ...) - FlushCallbacks = 2, // SerializeCallbacks - Destroy = 5, - Heartbeat = 6, // sent by steamclient to check if the IPC server is still alive - Handshake = 9, // SteamApi_Init -}; - -// Interface IDs used in the second byte of an InterfaceCall packet to select -// which steamclient interface the funcHash belongs to -enum class EIPCInterface : uint8 -{ - IClientUser = 1, - IClientGameServer = 2, - IClientFriends = 3, - IClientUtils = 4, - IClientBilling = 5, - IClientMatchmaking = 6, - // 7 = unused - IClientApps = 8, - // 9 = unused - // 10 = unused - IClientUserStats = 11, - IClientNetworking = 12, - IClientRemoteStorage = 13, - // 14 = unused - // 15 = unused - IClientDepotBuilder = 16, - IClientAppManager = 17, - IClientConfigStore = 18, - IClientGameCoordinator = 19, - IClientGameServerStats = 20, - IClientGameStats = 21, - IClientHTTP = 22, - IClientScreenshots = 23, - IClientAudio = 24, - IClientUnifiedMessages = 25, - IClientStreamLauncher = 26, - IClientParentalSettings = 27, - // 28 = unused - IClientNetworkDeviceManager = 29, - IClientMusic = 30, - IClientRemoteClientManager = 31, - IClientUGC = 32, - IClientStreamClient = 33, - IClientProductBuilder = 34, - IClientShortcuts = 35, - // 36 = unused - IClientGameNotifications = 37, - IClientVideo = 38, - IClientInventory = 39, - IClientVR = 40, - IClientControllerSerialized = 41, - IClientAppDisableUpdate = 42, - // 43 = unused - IClientSharedConnection = 44, - IClientShader = 45, - IClientNetworkingSocketsSerialized = 46, - // 47 = unused - IClientCompat = 48, - IClientParties = 49, - IClientNetworkingUtilsSerialized = 50, - // 51 = unused - IClientRemotePlay = 52, - IClientGameServerPacketHandler = 53, - IClientSystemManager = 54, - // 55 = unused - // 56 = unused - IClientSystemPerfManager = 57, - IClientSystemDockManager = 58, - IClientSystemAudioManager = 59, - IClientSystemDisplayManager = 60, - IClientTimeline = 61, -}; - -inline const char* EIPCCommandName(EIPCCommand cmd) { - switch (cmd) { - case EIPCCommand::InterfaceCall: return "InterfaceCall"; - case EIPCCommand::FlushCallbacks: return "FlushCallbacks"; - case EIPCCommand::Destroy: return "Destroy"; - case EIPCCommand::Heartbeat: return "Heartbeat"; - case EIPCCommand::Handshake: return "Handshake"; - default: - { - static thread_local char buf[4]; - uint8 v = static_cast(cmd); - if (v >= 100) { - buf[0]='0'+v/100; - buf[1]='0'+(v/10)%10; - buf[2]='0'+v%10; - buf[3]=0; - } - else if (v >= 10) { - buf[0]='0'+v/10; - buf[1]='0'+v%10; - buf[2]=0; - } - else { - buf[0]='0'+v; - buf[1]=0; - } - return buf; - } - } -} - -inline const char* EIPCInterfaceName(EIPCInterface iface) { - switch (iface) { - case EIPCInterface::IClientUser: return "IClientUser"; - case EIPCInterface::IClientGameServer: return "IClientGameServer"; - case EIPCInterface::IClientFriends: return "IClientFriends"; - case EIPCInterface::IClientUtils: return "IClientUtils"; - case EIPCInterface::IClientBilling: return "IClientBilling"; - case EIPCInterface::IClientMatchmaking: return "IClientMatchmaking"; - case EIPCInterface::IClientApps: return "IClientApps"; - case EIPCInterface::IClientUserStats: return "IClientUserStats"; - case EIPCInterface::IClientNetworking: return "IClientNetworking"; - case EIPCInterface::IClientRemoteStorage: return "IClientRemoteStorage"; - case EIPCInterface::IClientDepotBuilder: return "IClientDepotBuilder"; - case EIPCInterface::IClientAppManager: return "IClientAppManager"; - case EIPCInterface::IClientConfigStore: return "IClientConfigStore"; - case EIPCInterface::IClientGameCoordinator: return "IClientGameCoordinator"; - case EIPCInterface::IClientGameServerStats: return "IClientGameServerStats"; - case EIPCInterface::IClientGameStats: return "IClientGameStats"; - case EIPCInterface::IClientHTTP: return "IClientHTTP"; - case EIPCInterface::IClientScreenshots: return "IClientScreenshots"; - case EIPCInterface::IClientAudio: return "IClientAudio"; - case EIPCInterface::IClientUnifiedMessages: return "IClientUnifiedMessages"; - case EIPCInterface::IClientStreamLauncher: return "IClientStreamLauncher"; - case EIPCInterface::IClientParentalSettings: return "IClientParentalSettings"; - case EIPCInterface::IClientNetworkDeviceManager: return "IClientNetworkDeviceManager"; - case EIPCInterface::IClientMusic: return "IClientMusic"; - case EIPCInterface::IClientRemoteClientManager: return "IClientRemoteClientManager"; - case EIPCInterface::IClientUGC: return "IClientUGC"; - case EIPCInterface::IClientStreamClient: return "IClientStreamClient"; - case EIPCInterface::IClientProductBuilder: return "IClientProductBuilder"; - case EIPCInterface::IClientShortcuts: return "IClientShortcuts"; - case EIPCInterface::IClientGameNotifications: return "IClientGameNotifications"; - case EIPCInterface::IClientVideo: return "IClientVideo"; - case EIPCInterface::IClientInventory: return "IClientInventory"; - case EIPCInterface::IClientVR: return "IClientVR"; - case EIPCInterface::IClientControllerSerialized: return "IClientControllerSerialized"; - case EIPCInterface::IClientAppDisableUpdate: return "IClientAppDisableUpdate"; - case EIPCInterface::IClientSharedConnection: return "IClientSharedConnection"; - case EIPCInterface::IClientShader: return "IClientShader"; - case EIPCInterface::IClientNetworkingSocketsSerialized: return "IClientNetworkingSocketsSerialized"; - case EIPCInterface::IClientCompat: return "IClientCompat"; - case EIPCInterface::IClientParties: return "IClientParties"; - case EIPCInterface::IClientNetworkingUtilsSerialized: return "IClientNetworkingUtilsSerialized"; - case EIPCInterface::IClientRemotePlay: return "IClientRemotePlay"; - case EIPCInterface::IClientGameServerPacketHandler: return "IClientGameServerPacketHandler"; - case EIPCInterface::IClientSystemManager: return "IClientSystemManager"; - case EIPCInterface::IClientSystemPerfManager: return "IClientSystemPerfManager"; - case EIPCInterface::IClientSystemDockManager: return "IClientSystemDockManager"; - case EIPCInterface::IClientSystemAudioManager: return "IClientSystemAudioManager"; - case EIPCInterface::IClientSystemDisplayManager: return "IClientSystemDisplayManager"; - case EIPCInterface::IClientTimeline: return "IClientTimeline"; - default: - { - static thread_local char buf[4]; - uint8 v = static_cast(iface); - if (v >= 100) { - buf[0]='0'+v/100; - buf[1]='0'+(v/10)%10; - buf[2]='0'+v%10; - buf[3]=0; - } - else if (v >= 10) { - buf[0]='0'+v/10; - buf[1]='0'+v%10; - buf[2]=0; - } - else { - buf[0]='0'+v; - buf[1]=0; - } - return buf; - } - } -} - enum class EAppChangeFlags { AddedOrCreated = 0x0001, AppInfoOrConfig = 0x0002, @@ -1954,4 +1770,13 @@ enum EUniverse k_EUniverseDev = 4, // k_EUniverseRC = 5, // no such universe anymore k_EUniverseMax +}; + +enum EConfigStore +{ + k_EConfigStoreInvalid = 0, + k_EConfigStoreInstall = 1, + k_EConfigStoreUserRoaming = 2, + k_EConfigStoreUserLocal = 3, + k_EConfigStoreMax = 4, }; \ No newline at end of file diff --git a/src/Steam/IPCMessages.steamd b/src/Steam/IPCMessages.steamd new file mode 100644 index 0000000..e6d7c82 --- /dev/null +++ b/src/Steam/IPCMessages.steamd @@ -0,0 +1,137 @@ +// IPCMessages.steamd - Steam IPC wire schema for OpenSteamTool. +// +// This file is the source of truth for IPC enums, transport envelopes, and +// per-method bodies. Generated Req/Resp facades hide the envelope split from +// handlers while preserving the exact packed wire layout. + +enum EIPCCommand : uint8 { + InterfaceCall = 1; + FlushCallbacks = 2; + Destroy = 5; + Heartbeat = 6; + Handshake = 9; +}; + +enum EIPCResult : uint8 { + OK = 0x0B; + Failed = 0x0C; +}; + +enum EIPCInterface : uint8 { + IClientUser = 1; + IClientGameServer = 2; + IClientFriends = 3; + IClientUtils = 4; + IClientBilling = 5; + IClientMatchmaking = 6; + IClientApps = 8; + IClientUserStats = 11; + IClientNetworking = 12; + IClientRemoteStorage = 13; + IClientDepotBuilder = 16; + IClientAppManager = 17; + IClientConfigStore = 18; + IClientGameCoordinator = 19; + IClientGameServerStats = 20; + IClientGameStats = 21; + IClientHTTP = 22; + IClientScreenshots = 23; + IClientAudio = 24; + IClientUnifiedMessages = 25; + IClientStreamLauncher = 26; + IClientParentalSettings = 27; + IClientNetworkDeviceManager = 29; + IClientMusic = 30; + IClientRemoteClientManager = 31; + IClientUGC = 32; + IClientStreamClient = 33; + IClientProductBuilder = 34; + IClientShortcuts = 35; + IClientGameNotifications = 37; + IClientVideo = 38; + IClientInventory = 39; + IClientVR = 40; + IClientControllerSerialized = 41; + IClientAppDisableUpdate = 42; + IClientSharedConnection = 44; + IClientShader = 45; + IClientNetworkingSocketsSerialized = 46; + IClientCompat = 48; + IClientParties = 49; + IClientNetworkingUtilsSerialized = 50; + IClientRemotePlay = 52; + IClientGameServerPacketHandler = 53; + IClientSystemManager = 54; + IClientSystemPerfManager = 57; + IClientSystemDockManager = 58; + IClientSystemAudioManager = 59; + IClientSystemDisplayManager = 60; + IClientTimeline = 61; +}; + +struct IPCRequestHeader { + EIPCCommand command; +}; + +struct IPCInterfaceCallHeader { + EIPCInterface interfaceID; + uint32 hSteamUser; + uint32 funcHash; +}; + +struct IPCResponseHeader { + EIPCResult result; +}; + +protocol IPC { + request IPCRequestHeader; + + command IPCInterfaceCall = EIPCCommand::InterfaceCall { + request { + IPCInterfaceCallHeader header; + payload body; + uint32 fencepost; + } + + response { + IPCResponseHeader header; + payload body; + } + } +} + +interface IClientUser { + CSteamID GetSteamID(); + + uint32 GetAppOwnershipTicketExtendedData( + in uint32 unAppID, + in int32 cbMaxTicket, + out bytes pTicket[cbMaxTicket], + out uint32 piAppId, + out uint32 piSteamId, + out uint32 piSignature, + out uint32 pcbSignature + ); + + SteamAPICall_t RequestEncryptedAppTicket( + in int32 cbData, + in bytes pData[cbData] + ); + + bool GetEncryptedAppTicket( + out uint32 pcbTicket, + out bytes pTicket[pcbTicket] + ); +} + +interface IClientUtils { + AppId_t GetAppID(); + + bool GetAPICallResult( + in uint64 hSteamAPICall, + in uint32 cubCallback, + in uint32 iCallbackExpected, + out bytes pCallback[cubCallback], + out bool pbFailed + ); +} diff --git a/src/Steam/Structs.h b/src/Steam/Structs.h index b63efd8..28fcd28 100644 --- a/src/Steam/Structs.h +++ b/src/Steam/Structs.h @@ -239,8 +239,8 @@ struct ExtendedMsgHdr }; #pragma pack(pop) -// ── CSteamPipeClient ──────────────────────────────────────────── -struct CSteamPipeClient { +// ── CPipeClient ──────────────────────────────────────────── +struct CPipeClient { void* m_pServer; // +0 void* m_pClient; // +8 uint32 m_hSteamPipe; // +16 diff --git a/src/Utils/AppTicket.cpp b/src/Utils/AppTicket.cpp index 935be29..e7616b6 100644 --- a/src/Utils/AppTicket.cpp +++ b/src/Utils/AppTicket.cpp @@ -1,8 +1,10 @@ #include "AppTicket.h" +#include "Hook/Hooks_Decryption.h" #include namespace AppTicket { + constexpr AppId_t kLocalAppTicketSourceAppId = 7; static uint64_t GetSteamIDFromRegistryString(AppId_t appId) { HKEY hKey; @@ -77,6 +79,54 @@ namespace AppTicket { return value; } + // Exploit steamdrmp's off-by-four ticket parsing vulnerability: + static std::vector ForgeLocalAppOwnershipTicket(AppId_t appId) { + std::vector source = Hooks_Decryption::GetCacheAppOwnershipTicket(kLocalAppTicketSourceAppId); + if (source.size() <= kAppTicketSignatureSize) { + LOG_DEBUG("ForgeLocalAppOwnershipTicket for AppId {}: no source appticket", appId); + return {}; + } + + const size_t signedSize = source.size() - kAppTicketSignatureSize; + std::vector ticket; + ticket.reserve(source.size() + sizeof(AppId_t)); + ticket.insert(ticket.end(), source.begin(), source.begin() + signedSize); + + const uint8_t* appIdBytes = reinterpret_cast(&appId); + ticket.insert(ticket.end(), appIdBytes, appIdBytes + sizeof(AppId_t)); + ticket.insert(ticket.end(), source.begin() + signedSize, source.end()); + + LOG_INFO("Forged App Ownership Ticket, AppId: {}, SourceAppId: {}, Physical Size: {}, Total Size: {}", + appId, kLocalAppTicketSourceAppId, ticket.size(), source.size()); + return ticket; + } + + bool GetAppOwnershipTicket(AppId_t appId, AppOwnershipTicket& ticket) { + ticket = {}; + + ticket.data = GetAppOwnershipTicketFromRegistry(appId); + if (!ticket.data.empty() && ticket.data.size() >= sizeof(uint32)) { + ticket.totalSize = static_cast(ticket.data.size()); + ticket.appIdOffset = kAppTicketAppIdOffset; + ticket.steamIdOffset = kAppTicketSteamIdOffset; + ticket.signatureOffset = *reinterpret_cast(ticket.data.data()); + ticket.signatureSize = kAppTicketSignatureSize; + return true; + } + + ticket.data = ForgeLocalAppOwnershipTicket(appId); + if (ticket.data.empty()) { + return false; + } + + ticket.totalSize = static_cast(ticket.data.size() - sizeof(AppId_t)); + ticket.appIdOffset = ticket.totalSize - kAppTicketSignatureSize; + ticket.steamIdOffset = kAppTicketSteamIdOffset; + ticket.signatureOffset = ticket.appIdOffset + sizeof(AppId_t); + ticket.signatureSize = kAppTicketSignatureSize; + return true; + } + std::vector GetEncryptedTicketFromRegistry(AppId_t appId) { LOG_DEBUG("appid={}", appId); // exclude those appids that are not in addappid diff --git a/src/Utils/AppTicket.h b/src/Utils/AppTicket.h index 348b1ad..2ece5c8 100644 --- a/src/Utils/AppTicket.h +++ b/src/Utils/AppTicket.h @@ -3,11 +3,26 @@ #include "dllmain.h" namespace AppTicket { + inline constexpr uint32 kAppTicketSteamIdOffset = 8; + inline constexpr uint32 kAppTicketAppIdOffset = 16; + inline constexpr uint32 kAppTicketSignatureSize = 128; + + struct AppOwnershipTicket { + std::vector data; + uint32 totalSize = 0; + uint32 appIdOffset = kAppTicketAppIdOffset; + uint32 steamIdOffset = kAppTicketSteamIdOffset; + uint32 signatureOffset = 0; + uint32 signatureSize = kAppTicketSignatureSize; + }; + // Reads the app ownership ticket cached by Steam under // HKCU\Software\Valve\Steam\Apps\\AppTicket (REG_BINARY) // Returns an empty vector when no ticket is available. std::vector GetAppOwnershipTicketFromRegistry(AppId_t appId); + bool GetAppOwnershipTicket(AppId_t appId, AppOwnershipTicket& ticket); + // Reads the encrypted app ticket cached by Steam under // HKCU\Software\Valve\Steam\Apps\\ETicket (REG_BINARY) // Returns an empty vector when no ticket is available. @@ -21,4 +36,4 @@ namespace AppTicket { // Write ETicket binary data to registry. bool WriteEncryptedTicket(AppId_t appId, const std::vector& data); -} \ No newline at end of file +} diff --git a/src/Utils/Config.cpp b/src/Utils/Config.cpp index f566766..7401862 100644 --- a/src/Utils/Config.cpp +++ b/src/Utils/Config.cpp @@ -56,18 +56,14 @@ namespace Config { } } - // [pattern] - if (auto pattern = tbl["pattern"].as_table()) { - if (auto val = (*pattern)["mirror"].value()) { - patternMirror = *val; - // Strip a trailing slash so PatternLoader can append - // "//.toml" without producing "//". - while (!patternMirror.empty() && patternMirror.back() == '/') - patternMirror.pop_back(); - } - } - - LOG_INFO("Config loaded: manifest.url={} log.level={} lua.paths={} pattern.mirror={}", + // [remote] + if (auto remote = tbl["remote"].as_table()) { + if (auto val = (*remote)["url_template"].value()) { + remoteUrlTemplate = *val; + } + } + + LOG_INFO("Config loaded: manifest.url={} log.level={} lua.paths={} remote.url_template={}", ManifestClient::ActiveProviderName(), [&](){ switch (logLevel) { @@ -80,7 +76,7 @@ namespace Config { } }(), (uint32_t)luaPaths.size(), - patternMirror.empty() ? "" : patternMirror); + remoteUrlTemplate.empty() ? "" : remoteUrlTemplate); } catch (const toml::parse_error& e) { LOG_WARN("Config parse error: {}", e.what()); diff --git a/src/Utils/Config.h b/src/Utils/Config.h index c1f02f9..02a3753 100644 --- a/src/Utils/Config.h +++ b/src/Utils/Config.h @@ -25,11 +25,7 @@ namespace Config { // [lua] inline std::vector luaPaths; - // [pattern] - // Base URL for the per-DLL pattern TOML files. Final URL = - // //.toml - // Empty → built-in default (raw.githubusercontent.com). Users in regions - // where the default is blocked or slow can point this at a mirror. - inline std::string patternMirror; + // [remote] + inline std::string remoteUrlTemplate; } diff --git a/src/Utils/IPCLoader.cpp b/src/Utils/IPCLoader.cpp new file mode 100644 index 0000000..953f376 --- /dev/null +++ b/src/Utils/IPCLoader.cpp @@ -0,0 +1,220 @@ +#include "IPCLoader.h" +#include "IPCMessages.gen.h" +#include "Log.h" +#include "RemoteToml.h" +#include "SteamDiagnostics.h" + +#include +#include + +#include + +namespace IPCLoader { + +namespace { + + struct Registry { + std::vector interfaces; + std::unordered_map byID; + std::unordered_map byName; + + void Clear() + { + interfaces.clear(); + byID.clear(); + byName.clear(); + } + + void Add(Interface iface) + { + const size_t index = interfaces.size(); + byID[iface.id] = index; + byName[iface.name] = index; + interfaces.push_back(std::move(iface)); + } + + const Method* Find(EIPCInterface interfaceID, uint32_t funcHash) const + { + const auto it = byID.find(interfaceID); + if (it == byID.end()) return nullptr; + + for (const auto& method : interfaces[it->second].methods) { + if (method.funcHash == funcHash) return &method; + } + return nullptr; + } + + const Method* Find(std::string_view interfaceName, + std::string_view methodName) const + { + const auto it = byName.find(std::string(interfaceName)); + if (it == byName.end()) return nullptr; + + for (const auto& method : interfaces[it->second].methods) { + if (method.name == methodName) return &method; + } + return nullptr; + } + + size_t MethodCount() const + { + size_t count = 0; + for (const auto& iface : interfaces) + count += iface.methods.size(); + return count; + } + }; + + Registry g_registry; + + // ---- TOML helpers ---- + + static bool ParseHexU32(std::string_view s, uint32_t& out) + { + try { + size_t pos = 0; + uint64_t v = std::stoull(std::string(s), &pos, 16); + if (v > UINT32_MAX) return false; + out = static_cast(v); + return true; + } catch (...) { + return false; + } + + } + + static bool ParseInterfaceTable(std::string_view name, + const toml::table& tbl, + Interface& out) + { + out.name = std::string(name); + + const auto expected = EIPCInterfaceFromName(name); + if (!expected) { + LOG_WARN("IPCLoader: [{}] is not declared in EIPCInterface", name); + return false; + } + out.id = *expected; + + // Backward-compatible metadata: EIPCInterface is authoritative. + if (auto v = tbl["interface_id"].value()) { + if (*v < 0 || *v > 0xFF) { + LOG_WARN("IPCLoader: [{}] interface_id out of range ({})", name, *v); + return false; + } + if (static_cast(*v) != out.id) { + LOG_WARN("IPCLoader: [{}] interface_id {} disagrees with generated EIPCInterface value {}", + name, *v, static_cast(out.id)); + return false; + } + } + + if (auto v = tbl["vtable_rva"].value()) + ParseHexU32(*v, out.vtableRva); + + // Walk dotted sub-tables — each is a method. + for (auto& [methodKey, methodVal] : tbl) { + if (!methodVal.is_table()) continue; + const auto& mtbl = *methodVal.as_table(); + + Method m; + m.interfaceID = out.id; + m.name = std::string(methodKey.str()); + + if (auto v = mtbl["funcHash"].value()) { + if (!ParseHexU32(*v, m.funcHash)) { + LOG_WARN("IPCLoader: [{}.{}] bad funcHash '{}'", + name, m.name, *v); + continue; + } + } else { + LOG_WARN("IPCLoader: [{}.{}] missing funcHash", name, m.name); + continue; + } + + if (auto v = mtbl["fencepost"].value()) + ParseHexU32(*v, m.fencepost); + if (auto v = mtbl["argc"].value()) + m.argc = static_cast(*v); + out.methods.push_back(std::move(m)); + } + return true; + } + + static void ShowMissingPopup(const std::string& sha256) + { + SteamDiagnostics::ShowWarning( + "OpenSteamTool - IPC spec missing", + "OpenSteamTool: IPC spec file not found.\n\n" + "IPC interception is disabled for this session; pattern-based " + "hooks are unaffected.\n\n" + "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" + " 3. Check upstream:\n" + " https://github.com/OpenSteam001/steam-monitor/tree/ipc/steamclient"); + } + +} // namespace + +constexpr const char* kIPCChannel = "ipc"; + +bool Load(const std::string& steamclientPath) +{ + g_registry.Clear(); + + RemoteToml::Result r = RemoteToml::Fetch({ + kIPCChannel, + "steamclient", + steamclientPath, + }); + + if (!r.ok) { + ShowMissingPopup(r.sha256.empty() ? "(hash failed)" : r.sha256); + return false; + } + + toml::table root; + try { + root = toml::parse(r.body); + } catch (const toml::parse_error& e) { + LOG_WARN("IPCLoader: TOML parse error: {}", e.description()); + ShowMissingPopup(r.sha256); + return false; + } + + for (auto& [key, val] : root) { + if (!val.is_table()) continue; + Interface iface; + if (!ParseInterfaceTable(key.str(), *val.as_table(), iface)) continue; + + g_registry.Add(std::move(iface)); + } + + LOG_INFO("IPCLoader: loaded {} methods across {} interfaces ({})", + MethodCount(), InterfaceCount(), + r.fromCache ? "cache fallback" : "remote"); + return true; +} + +const Method* Find(EIPCInterface interfaceID, uint32_t funcHash) +{ + return g_registry.Find(interfaceID, funcHash); +} + +const Method* Find(std::string_view ifaceName, std::string_view methodName) +{ + return g_registry.Find(ifaceName, methodName); +} + +size_t InterfaceCount() +{ + return g_registry.interfaces.size(); +} + +size_t MethodCount() { + return g_registry.MethodCount(); +} + +} // namespace IPCLoader diff --git a/src/Utils/IPCLoader.h b/src/Utils/IPCLoader.h new file mode 100644 index 0000000..f533288 --- /dev/null +++ b/src/Utils/IPCLoader.h @@ -0,0 +1,35 @@ +#pragma once +#include "IPCMessages.gen.h" +#include +#include +#include +#include + +namespace IPCLoader { + + struct Method { + EIPCInterface interfaceID = static_cast(0); + std::string name; + uint32_t funcHash = 0; + uint32_t fencepost = 0; + uint32_t argc = 0; + }; + + struct Interface { + EIPCInterface id = static_cast(0); + std::string name; + uint32_t vtableRva = 0; + std::vector methods; + }; + + // Fetch and parse metadata before installing IPC hooks. + bool Load(const std::string& steamclientPath); + + // Return nullptr when metadata is unavailable for a method. + const Method* Find(EIPCInterface interfaceID, uint32_t funcHash); + const Method* Find(std::string_view ifaceName, std::string_view methodName); + + size_t InterfaceCount(); + size_t MethodCount(); + +} // namespace IPCLoader diff --git a/src/Utils/PatternLoader.cpp b/src/Utils/PatternLoader.cpp index c3ed8fc..b4c544d 100644 --- a/src/Utils/PatternLoader.cpp +++ b/src/Utils/PatternLoader.cpp @@ -1,429 +1,292 @@ -#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 "Hash.h" +#include "Log.h" +#include "RemoteToml.h" +#include "SteamDiagnostics.h" + +#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; + +// ---- 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 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& component) +{ + SteamDiagnostics::ShowWarning( + "OpenSteamTool - Unsupported Steam Version", + "OpenSteamTool: signature file not found for " + dllName + ".\n\n" + "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, then restart Steam.\n" + " 2. Drop a matching TOML at:\n" + " \\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" + " https://github.com/OpenSteam001/OpenSteamTool/issues"); +} + +} // namespace + +// ---- public API ---- + +namespace PatternLoader { + + constexpr const char* kPatternChannel = "pattern"; + +bool Load(HMODULE module, const std::string& dllPath, const std::string& component) +{ + namespace fs = std::filesystem; + + // Delegate fetch + cache + mirror fallback to RemoteToml. + RemoteToml::Result r = RemoteToml::Fetch({ + kPatternChannel, + component, + dllPath, + }); + + if (r.ok) { + std::string parseErr; + PatternMap map = ParsePatternString(r.body, &parseErr); + if (!map.empty()) { + LOG_INFO("PatternLoader: loaded {} patterns for {} ({})", + map.size(), component, r.fromCache ? "cache fallback" : "remote"); + g_moduleMaps[module] = std::move(map); + return true; + } + LOG_WARN("PatternLoader: TOML for {} parsed empty ({})", + component, parseErr.empty() ? "no entries" : parseErr); + } + + // Total failure — popup + disable module's hooks. + std::string dllName = fs::path(dllPath).filename().string(); + std::string sha = r.sha256.empty() ? "(hash failed)" : r.sha256; + ShowDownloadFailedPopup(dllName, sha, component); + 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(); + + SteamDiagnostics::ShowWarning( + "OpenSteamTool - Missing Signatures", + "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"); +} + +} // namespace PatternLoader diff --git a/src/Utils/PatternLoader.h b/src/Utils/PatternLoader.h index 61769a6..13c04dc 100644 --- a/src/Utils/PatternLoader.h +++ b/src/Utils/PatternLoader.h @@ -1,34 +1,16 @@ -#pragma once -#include -#include - -// Pattern-file based function locator. -// -// Call Load() once per module (steamclient64.dll, steamui.dll) during DLL -// init before any hooks are installed. It computes the SHA-256 of the DLL -// on disk, checks the local cache under /opensteamtool/pattern/, and -// downloads the matching TOML from the steam-monitor GitHub repo if needed. -// -// Each hook then calls FindPattern() instead of the old ByteSearch / FIND_SIG. -// After all hooks are installed, call ReportMissingFunctions() to surface any -// functions that had no entry in the pattern file. - -namespace PatternLoader { - - // Load pattern file for `module`. - // dllPath = full on-disk path of the DLL (used for SHA-256 and error messages) - // ghSubdir = "steamclient" or "steamui" (selects the sub-path on GitHub) - // Synchronous — may perform a network request. - // Returns false and shows a popup if the file cannot be obtained or parsed. - bool Load(HMODULE module, const std::string& dllPath, const std::string& ghSubdir); - - // Look up funcName in the pattern map for `module`. - // Priority: RVA offset → byte-signature scan. - // If neither succeeds, the name is recorded for ReportMissingFunctions(). - void* FindPattern(HMODULE module, const char* funcName); - - // Show a single popup listing every function that FindPattern() could not - // locate. Call this once after all hooks are installed. - void ReportMissingFunctions(); - -} // namespace PatternLoader +#pragma once +#include +#include + +namespace PatternLoader { + + // Load metadata before installing hooks for a module. + bool Load(HMODULE module, const std::string& dllPath, const std::string& component); + + // Resolve by RVA first, then fall back to signature scanning. + void* FindPattern(HMODULE module, const char* funcName); + + // Report unresolved functions after all hooks have been installed. + void ReportMissingFunctions(); + +} // namespace PatternLoader diff --git a/src/Utils/RemoteToml.cpp b/src/Utils/RemoteToml.cpp new file mode 100644 index 0000000..66cc67d --- /dev/null +++ b/src/Utils/RemoteToml.cpp @@ -0,0 +1,179 @@ +#include "RemoteToml.h" +#include "Config.h" +#include "Log.h" +#include "SteamDiagnostics.h" +#include "WinHttp.h" + +#include +#include +#include +#include +#include + +namespace RemoteToml { + +namespace { + constexpr const char* kGithubTemplate = + "https://raw.githubusercontent.com/OpenSteam001/steam-monitor/" + "{channel}/{component}/{sha256}.toml"; + constexpr const char* kJsdelivrTemplate = + "https://cdn.jsdelivr.net/gh/OpenSteam001/steam-monitor@" + "{channel}/{component}/{sha256}.toml"; + + static bool HasPlaceholder(std::string_view text, std::string_view placeholder) + { + return text.find(placeholder) != std::string_view::npos; + } + + static bool IsValidTemplate(std::string_view urlTemplate) + { + return HasPlaceholder(urlTemplate, "{channel}") && + HasPlaceholder(urlTemplate, "{component}") && + HasPlaceholder(urlTemplate, "{sha256}"); + } + + static void ReplaceAll(std::string& text, + std::string_view from, + std::string_view to) + { + size_t pos = 0; + while ((pos = text.find(from, pos)) != std::string::npos) { + text.replace(pos, from.size(), to); + pos += to.size(); + } + } + + static std::string ExpandTemplate(std::string urlTemplate, + const Request& request, + std::string_view sha256) + { + ReplaceAll(urlTemplate, "{channel}", request.channel); + ReplaceAll(urlTemplate, "{component}", request.component); + ReplaceAll(urlTemplate, "{sha256}", sha256); + return urlTemplate; + } + + static std::vector BuildUrlTemplates() + { + if (Config::remoteUrlTemplate.empty()) + return { kGithubTemplate, kJsdelivrTemplate }; + + if (!IsValidTemplate(Config::remoteUrlTemplate)) { + LOG_WARN("RemoteToml: remote.url_template must contain " + "{channel}, {component}, and {sha256}; remote fetch disabled"); + return {}; + } + + return { Config::remoteUrlTemplate }; + } +} // namespace + +Result Fetch(const Request& request) +{ + namespace fs = std::filesystem; + Result out; + + // 1. SHA-256 of the DLL. + const auto hashStart = std::chrono::steady_clock::now(); + out.sha256 = SteamDiagnostics::Sha256Of(request.dllPath); + const auto hashMs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - hashStart).count(); + + if (out.sha256.empty()) { + LOG_WARN("RemoteToml({}/{}): Sha256OfFile failed for {} ({} ms)", + request.channel, request.component, request.dllPath, hashMs); + return out; + } + LOG_INFO("RemoteToml({}/{}): sha256 = {} ({} ms)", + request.channel, request.component, out.sha256, hashMs); + + // 2. Cache path & dir. + fs::path steamRoot = fs::path(request.dllPath).parent_path(); + fs::path cacheDir = steamRoot / "opensteamtool" / request.channel / request.component; + fs::path cachePath = cacheDir / (out.sha256 + ".toml"); + const std::string cachePathText = cachePath.string(); + + std::error_code mkdirEc; + fs::create_directories(cacheDir, mkdirEc); + if (mkdirEc) { + LOG_WARN("RemoteToml({}/{}): could not create cache dir {} ({})", + request.channel, request.component, cacheDir.string(), mkdirEc.message()); + } + + // 3. Try remote (mirror chain with early-out on 404). + const std::vector urlTemplates = BuildUrlTemplates(); + WinHttp::Result http; + std::string lastUrl; + + for (size_t i = 0; i < urlTemplates.size(); ++i) { + lastUrl = ExpandTemplate(urlTemplates[i], request, out.sha256); + LOG_INFO("RemoteToml({}/{}): downloading {}", + request.channel, request.component, lastUrl); + + http = WinHttp::Execute(L"GET", lastUrl.c_str(), + nullptr, 0, nullptr); + + if (http.ok && http.status == 200) break; + + if (http.ok && http.status == 404) { + LOG_WARN("RemoteToml({}/{}): mirror has no such file (HTTP 404): {}", + request.channel, request.component, lastUrl); + break; // all mirrors serve same data + } + + if (i + 1 < urlTemplates.size()) { + LOG_WARN("RemoteToml({}/{}): mirror failed ({} ok={} HTTP={}), falling back", + request.channel, request.component, lastUrl, http.ok, http.status); + } + } + + // 4. Remote OK → write cache, return body. + if (http.ok && http.status == 200 && !http.body.empty()) { + std::ofstream ofs(cachePath, std::ios::binary); + if (ofs) { + ofs.write(http.body.data(), + static_cast(http.body.size())); + LOG_INFO("RemoteToml({}/{}): cached to {}", + request.channel, request.component, cachePathText); + } else { + LOG_WARN("RemoteToml({}/{}): could not open {} for writing", + request.channel, request.component, cachePathText); + } + out.body = std::move(http.body); + out.ok = true; + return out; + } + + // 5. Remote failed → fall back to whatever is cached for this exact SHA. + if (fs::exists(cachePath)) { + LOG_WARN("RemoteToml({}/{}): remote failed (last URL {} HTTP {}); " + "falling back to local cache {}", + request.channel, request.component, + lastUrl.empty() ? "" : lastUrl, http.status, cachePathText); + + std::ifstream ifs(cachePath, std::ios::binary); + if (ifs) { + std::string buf((std::istreambuf_iterator(ifs)), + std::istreambuf_iterator()); + if (!buf.empty()) { + out.body = std::move(buf); + out.ok = true; + out.fromCache = true; + return out; + } + LOG_WARN("RemoteToml({}/{}): cache file empty: {}", + request.channel, request.component, cachePathText); + } else { + LOG_WARN("RemoteToml({}/{}): could not open cache file: {}", + request.channel, request.component, cachePathText); + } + } + + // 6. Total failure — caller handles popup / degraded mode. + LOG_WARN("RemoteToml({}/{}): no source available (last URL: {} HTTP {})", + request.channel, request.component, + lastUrl.empty() ? "" : lastUrl, http.status); + return out; +} + +} // namespace RemoteToml diff --git a/src/Utils/RemoteToml.h b/src/Utils/RemoteToml.h new file mode 100644 index 0000000..e5d56c9 --- /dev/null +++ b/src/Utils/RemoteToml.h @@ -0,0 +1,22 @@ +#pragma once +#include + +namespace RemoteToml { + + struct Request { + std::string channel; // "pattern" or "ipc" + std::string component; // "steamclient" or "steamui" + std::string dllPath; + }; + + struct Result { + bool ok = false; + bool fromCache = false; + std::string body; + std::string sha256; + }; + + // Fetch remote TOML first, then fall back to the exact local cache entry. + Result Fetch(const Request& request); + +} // namespace RemoteToml diff --git a/src/Utils/SteamDiagnostics.cpp b/src/Utils/SteamDiagnostics.cpp new file mode 100644 index 0000000..15cc1b7 --- /dev/null +++ b/src/Utils/SteamDiagnostics.cpp @@ -0,0 +1,102 @@ +#include "SteamDiagnostics.h" +#include "Hash.h" +#include "Log.h" + +#include +#include +#include +#include + +namespace SteamDiagnostics { + +namespace { + + struct Snapshot { + std::string buildID = "(unavailable)"; + std::string steamclientPath; + std::string steamclientSha256 = "(unavailable)"; + std::string steamUIPath; + std::string steamUISha256 = "(unavailable)"; + }; + + Snapshot g_snapshot; + + static std::string DetectSteamBuildID() + { + using GetBootstrapperVersion_t = int64_t (*)(); + + const HMODULE steam = GetModuleHandleA("steam.exe"); + if (!steam) { + LOG_WARN("SteamDiagnostics: steam.exe module not loaded; build id unavailable"); + return "(unavailable)"; + } + + const auto getBootstrapperVersion = + reinterpret_cast( + GetProcAddress(steam, "GetBootstrapperVersion")); + if (!getBootstrapperVersion) { + LOG_WARN("SteamDiagnostics: steam.exe!GetBootstrapperVersion not exported"); + return "(unavailable)"; + } + + return std::to_string(getBootstrapperVersion()); + } + + static std::string HashOrUnavailable(const std::string& path) + { + std::string sha256 = Sha256OfFile(path); + return sha256.empty() ? "(unavailable)" : std::move(sha256); + } + + static std::string AppendSnapshot(std::string message) + { + message += + "\n\nSteam diagnostics:\n" + " Build ID: " + g_snapshot.buildID + "\n" + " steamclient64.dll SHA: " + g_snapshot.steamclientSha256 + "\n" + " steamui.dll SHA: " + g_snapshot.steamUISha256; + return message; + } + +} // namespace + +void Initialize(const std::string& steamclientPath, + const std::string& steamUIPath) +{ + g_snapshot.buildID = DetectSteamBuildID(); + g_snapshot.steamclientPath = steamclientPath; + g_snapshot.steamclientSha256 = HashOrUnavailable(steamclientPath); + g_snapshot.steamUIPath = steamUIPath; + g_snapshot.steamUISha256 = HashOrUnavailable(steamUIPath); + + LOG_INFO("SteamDiagnostics: build={} steamclient64.sha256={} steamui.sha256={}", + g_snapshot.buildID, + g_snapshot.steamclientSha256, + g_snapshot.steamUISha256); +} + +std::string Sha256Of(const std::string& path) +{ + if (path == g_snapshot.steamclientPath) + return g_snapshot.steamclientSha256 == "(unavailable)" + ? std::string{} + : g_snapshot.steamclientSha256; + + if (path == g_snapshot.steamUIPath) + return g_snapshot.steamUISha256 == "(unavailable)" + ? std::string{} + : g_snapshot.steamUISha256; + + return Sha256OfFile(path); +} + +void ShowWarning(std::string title, std::string message) +{ + std::thread([title = std::move(title), + message = AppendSnapshot(std::move(message))]() { + MessageBoxA(nullptr, message.c_str(), title.c_str(), + MB_OK | MB_ICONWARNING | MB_TOPMOST); + }).detach(); +} + +} // namespace SteamDiagnostics diff --git a/src/Utils/SteamDiagnostics.h b/src/Utils/SteamDiagnostics.h new file mode 100644 index 0000000..3bb60f0 --- /dev/null +++ b/src/Utils/SteamDiagnostics.h @@ -0,0 +1,17 @@ +#pragma once + +#include + +namespace SteamDiagnostics { + + // Capture the Steam build id and DLL hashes used in support popups. + void Initialize(const std::string& steamclientPath, + const std::string& steamUIPath); + + // Reuse captured hashes for known Steam DLLs and hash other files on demand. + std::string Sha256Of(const std::string& path); + + // Display a warning asynchronously with the captured Steam diagnostics. + void ShowWarning(std::string title, std::string message); + +} // namespace SteamDiagnostics diff --git a/src/dllmain.cpp b/src/dllmain.cpp index 9db12a9..d0b8f43 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -1,92 +1,97 @@ -#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/IPCLoader.h" +#include "Utils/PatternLoader.h" +#include "Utils/SteamDiagnostics.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(); + SteamDiagnostics::Initialize(SteamclientPath, SteamUIPath); + + // 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"); + + // IPC method metadata (funcHash, fencepost, argc, ...) + IPCLoader::Load(SteamclientPath); + + 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(); + + 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 ec81dd6..c6576a0 100644 --- a/src/dllmain.h +++ b/src/dllmain.h @@ -26,7 +26,6 @@ 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] = {}; diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt new file mode 100644 index 0000000..84c34cd --- /dev/null +++ b/tools/CMakeLists.txt @@ -0,0 +1,18 @@ +cmake_minimum_required(VERSION 3.20) +project(OpenSteamToolTools LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CONFIGURATION_TYPES "Debug;Release" CACHE STRING "" FORCE) + +add_executable(ipc_codegen + ipc_codegen/ipc_codegen.cpp +) +target_compile_features(ipc_codegen PRIVATE cxx_std_20) + + +add_executable(extract_tickets + extract_tickets/extract_tickets.cpp +) +target_compile_features(extract_tickets PRIVATE cxx_std_20) + diff --git a/tools/cmake/IPCCodegen.cmake b/tools/cmake/IPCCodegen.cmake new file mode 100644 index 0000000..f091b45 --- /dev/null +++ b/tools/cmake/IPCCodegen.cmake @@ -0,0 +1,42 @@ +# IPCCodegen.cmake - protoc-style wrapper for the ipc_codegen host tool. +# +# The ipc_codegen target itself is defined once in tools/CMakeLists.txt; consumers +# pull it in with add_subdirectory() and then invoke it through this helper, the +# same way protobuf_generate invokes the protoc target. + +# opensteamtool_add_ipc_codegen( IDL CPP_OUT ) +# +# Generates /.gen.h from the IDL and returns the generated +# header path in , suitable for listing as a target source. +function(opensteamtool_add_ipc_codegen out_var) + set(one_value_args IDL CPP_OUT) + cmake_parse_arguments(IPC "" "${one_value_args}" "" ${ARGN}) + + foreach(_required IDL CPP_OUT) + if(NOT IPC_${_required}) + message(FATAL_ERROR "opensteamtool_add_ipc_codegen requires ${_required}") + endif() + endforeach() + + if(NOT TARGET ipc_codegen) + message(FATAL_ERROR + "opensteamtool_add_ipc_codegen: target 'ipc_codegen' is not defined; " + "add_subdirectory() the tools directory before calling this function") + endif() + + get_filename_component(_idl_name "${IPC_IDL}" NAME) + get_filename_component(_idl_stem "${IPC_IDL}" NAME_WE) + set(_header "${_idl_stem}.gen.h") + set(_output "${IPC_CPP_OUT}/${_header}") + + add_custom_command( + OUTPUT "${_output}" + COMMAND ${CMAKE_COMMAND} -E make_directory "${IPC_CPP_OUT}" + COMMAND $ "--cpp_out=${IPC_CPP_OUT}" "${IPC_IDL}" + DEPENDS "${IPC_IDL}" ipc_codegen + COMMENT "Generating ${_header} from ${_idl_name}" + VERBATIM + ) + + set(${out_var} "${_output}" PARENT_SCOPE) +endfunction() diff --git a/tools/extract_tickets/extract_tickets.cpp b/tools/extract_tickets/extract_tickets.cpp new file mode 100644 index 0000000..a13ab83 --- /dev/null +++ b/tools/extract_tickets/extract_tickets.cpp @@ -0,0 +1,453 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "steam.h" + +namespace { + +bool IsDecimal(std::string_view value) { + if (value.empty()) return false; + for (char ch : value) { + if (!std::isdigit(static_cast(ch))) return false; + } + return true; +} + +std::optional ParseAppId(const std::string& value) { + if (!IsDecimal(value)) return std::nullopt; + + unsigned long parsed{0}; + try { + size_t consumed{0}; + parsed = std::stoul(value, &consumed, 10); + if (consumed != value.size() || parsed > 0xFFFFFFFFul) return std::nullopt; + } catch (...) { + return std::nullopt; + } + + return static_cast(parsed); +} + +std::optional ReadAppIdFromConsole() { + std::cout << "AppID: "; + + std::string input; + if (!std::getline(std::cin, input)) return std::nullopt; + return ParseAppId(input); +} + +std::optional QueryRegistryString(HKEY root, const char* subKey, const char* valueName) { + HKEY key{nullptr}; + if (RegOpenKeyExA(root, subKey, 0, KEY_READ | KEY_WOW64_32KEY, &key) != ERROR_SUCCESS) { + return std::nullopt; + } + + DWORD valueType{0}; + DWORD valueSize{0}; + LSTATUS status{RegQueryValueExA(key, valueName, nullptr, &valueType, nullptr, &valueSize)}; + if (status != ERROR_SUCCESS || valueType != REG_SZ || valueSize == 0) { + RegCloseKey(key); + return std::nullopt; + } + + std::string value(valueSize, '\0'); + status = RegQueryValueExA( + key, + valueName, + nullptr, + nullptr, + reinterpret_cast(value.data()), + &valueSize); + RegCloseKey(key); + + if (status != ERROR_SUCCESS) return std::nullopt; + if (!value.empty() && value.back() == '\0') value.pop_back(); + return value; +} + +std::optional FindSteamInstallPath() { + constexpr const char* kSteamKey{"Software\\Valve\\Steam"}; + + if (auto path{QueryRegistryString(HKEY_CURRENT_USER, kSteamKey, "SteamPath")}) { + std::cout << "Found SteamPath in HKEY_CURRENT_USER: " << *path << "\n"; + return path; + } + + return std::nullopt; +} + +std::string JoinPath(std::string base, const char* name) { + for (char& ch : base) { + if (ch == '/') ch = '\\'; + } + if (!base.empty() && base.back() != '\\') base += '\\'; + base += name; + return base; +} + +std::string NormalizeDir(std::string dir) { + for (char& ch : dir) { + if (ch == '/') ch = '\\'; + } + if (!dir.empty() && dir.back() == '\\') dir.pop_back(); + return dir; +} + +HMODULE LoadSteamClient64(std::string& loadedPath) { + auto steamPath{FindSteamInstallPath()}; + if (!steamPath) { + std::cerr << "Failed to find Steam install path in registry.\n"; + return nullptr; + } + + const std::string steamDir{NormalizeDir(*steamPath)}; + loadedPath = JoinPath(*steamPath, "steamclient64.dll"); + + // steamclient64.dll pulls in tier0_s64.dll / vstdlib_s64.dll from the Steam + // directory. Add that directory to the search path and load with + // LOAD_WITH_ALTERED_SEARCH_PATH so those dependencies resolve; otherwise the + // load fails with ERROR_MOD_NOT_FOUND (126). + SetDllDirectoryA(steamDir.c_str()); + HMODULE module{LoadLibraryExA(loadedPath.c_str(), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH)}; + if (!module) { + std::cerr << "Failed to load " << loadedPath << " (GetLastError=" << GetLastError() << ").\n"; + return nullptr; + } + + return module; +} + +ISteamClient* CreateSteamClient(HMODULE module) { + auto createInterface{reinterpret_cast(GetProcAddress(module, "CreateInterface"))}; + if (!createInterface) { + std::cerr << "steamclient64.dll has no CreateInterface export.\n"; + return nullptr; + } + + int returnCode{0}; + auto* client{reinterpret_cast(createInterface(kSteamClientInterfaceVersion, &returnCode))}; + if (!client) { + std::cerr << "CreateInterface(" << kSteamClientInterfaceVersion + << ") failed (returnCode=" << returnCode << ").\n"; + return nullptr; + } + return client; +} + +// Open a pipe and attach to the already-running global user +bool OpenSession(ISteamClient* client, HSteamPipe& pipe, HSteamUser& user) { + pipe = client->CreateSteamPipe(); + if (!pipe) { + std::cerr << "CreateSteamPipe failed. Is Steam running?\n"; + return false; + } + + user = client->ConnectToGlobalUser(pipe); + if (!user) { + std::cerr << "ConnectToGlobalUser failed. Is a user logged in?\n"; + client->BReleaseSteamPipe(pipe); + pipe = 0; + return false; + } + + return true; +} + +// App ownership ticket: ISteamAppTicket hands back the raw signed buffer plus +// offsets into it. nAppID is explicit, so this works for any owned app. +std::optional> ExtractAppOwnershipTicket( + ISteamClient* client, HSteamPipe pipe, HSteamUser user, uint32_t appId) { + auto* appTicket{reinterpret_cast( + client->GetISteamGenericInterface(user, pipe, kSteamAppTicketInterfaceVersion))}; + if (!appTicket) { + std::cerr << "GetISteamGenericInterface(" << kSteamAppTicketInterfaceVersion + << ") returned null.\n"; + return std::nullopt; + } + + std::vector buffer(2048); + uint32_t appIdOffset{0}; + uint32_t steamIdOffset{0}; + uint32_t signatureOffset{0}; + uint32_t signatureSize{0}; + const uint32_t written{appTicket->GetAppOwnershipTicketData( + appId, + buffer.data(), + static_cast(buffer.size()), + &appIdOffset, + &steamIdOffset, + &signatureOffset, + &signatureSize)}; + + if (written == 0 || written > buffer.size()) { + std::cerr << "GetAppOwnershipTicketData returned no ticket for AppID " << appId + << " (own the app and have it cached locally?).\n"; + return std::nullopt; + } + + buffer.resize(written); + std::cout << "Ownership ticket " << written << " bytes" + << " (appIdOffset=" << appIdOffset + << " steamIdOffset=" << steamIdOffset + << " signatureOffset=" << signatureOffset + << " signatureSize=" << signatureSize << ")\n"; + return buffer; +} + +// Encrypted app ticket: asynchronous request whose result arrives as +// EncryptedAppTicketResponse_t. We have no callback dispatcher, so we poll +// ISteamUtils::IsAPICallCompleted and then read the result + ticket. +// See https://partner.steamgames.com/doc/api/ISteamUser#RequestEncryptedAppTicket +std::optional> ExtractEncryptedAppTicket( + ISteamClient* client, HSteamPipe pipe, HSteamUser user, uint32_t appId) { + auto* utils{client->GetISteamUtils(pipe, kSteamUtilsInterfaceVersion)}; + auto* steamUser{client->GetISteamUser(user, pipe, kSteamUserInterfaceVersion)}; + if (!utils || !steamUser) { + std::cerr << "GetISteamUtils/GetISteamUser returned null.\n"; + return std::nullopt; + } + + const SteamAPICall_t hCall{steamUser->RequestEncryptedAppTicket(nullptr, 0)}; + if (!hCall) { + std::cerr << "RequestEncryptedAppTicket failed to start for AppID " << appId << ".\n"; + return std::nullopt; + } + + // Bounded poll so a wedged client can never hang the tool. + constexpr int kMaxWaitMs{15000}; + constexpr int kStepMs{50}; + bool failed{false}; + int waited{0}; + while (!utils->IsAPICallCompleted(hCall, &failed)) { + if (waited >= kMaxWaitMs) { + std::cerr << "Timed out waiting for EncryptedAppTicketResponse_t.\n"; + return std::nullopt; + } + Sleep(kStepMs); + waited += kStepMs; + } + + EncryptedAppTicketResponse_t response{}; + const bool gotResult{utils->GetAPICallResult( + hCall, + &response, + sizeof(response), + EncryptedAppTicketResponse_t::k_iCallback, + &failed)}; + if (!gotResult || failed) { + std::cerr << "GetAPICallResult failed for EncryptedAppTicketResponse_t.\n"; + return std::nullopt; + } + if (response.m_eResult != k_EResultOK) { + std::cerr << "RequestEncryptedAppTicket returned EResult " + << static_cast(response.m_eResult) << ".\n"; + return std::nullopt; + } + + // Pass a null buffer first to learn the size, then fetch. + uint32_t cbTicket{0}; + steamUser->GetEncryptedAppTicket(nullptr, 0, &cbTicket); + if (cbTicket == 0) { + std::cerr << "Encrypted app ticket is empty.\n"; + return std::nullopt; + } + + std::vector buffer(cbTicket); + if (!steamUser->GetEncryptedAppTicket(buffer.data(), static_cast(buffer.size()), &cbTicket)) { + std::cerr << "GetEncryptedAppTicket failed.\n"; + return std::nullopt; + } + + buffer.resize(cbTicket); + std::cout << "Encrypted ticket " << cbTicket << " bytes\n"; + return buffer; +} + +// Dump the raw ticket bytes as a classic hex view: offset, 16 hex bytes, ASCII. +void PrintHex(const char* label, const std::vector& data) { + std::cout << label << " (" << data.size() << " bytes):\n"; + + constexpr size_t kBytesPerRow{16}; + static const char kHex[]{"0123456789abcdef"}; + + for (size_t row{0}; row < data.size(); row += kBytesPerRow) { + // Offset column. + std::string line; + for (int shift{12}; shift >= 0; shift -= 4) { + line += kHex[(row >> shift) & 0xF]; + } + line += " "; + + // Hex column. + std::string ascii; + for (size_t col{0}; col < kBytesPerRow; ++col) { + if (row + col < data.size()) { + const uint8_t byte{data[row + col]}; + line += kHex[byte >> 4]; + line += kHex[byte & 0xF]; + line += ' '; + ascii += (byte >= 0x20 && byte < 0x7F) ? static_cast(byte) : '.'; + } else { + line += " "; + } + if (col == 7) line += ' '; + } + + std::cout << line << " " << ascii << "\n"; + } +} + +std::string ToHexString(const std::vector& data) { + static const char kHex[]{"0123456789abcdef"}; + std::string out; + out.reserve(data.size() * 2); + for (uint8_t byte : data) { + out += kHex[byte >> 4]; + out += kHex[byte & 0xF]; + } + return out; +} + +bool WriteBinaryFile(const std::string& path, const std::vector& data) { + std::ofstream output{path, std::ios::binary | std::ios::trunc}; + if (!output) { + std::cerr << "Failed to create " << path << ".\n"; + return false; + } + output.write(reinterpret_cast(data.data()), + static_cast(data.size())); + if (!output) { + std::cerr << "Failed to write " << path << ".\n"; + return false; + } + return true; +} + +// Build the plain-text summary line for one ticket. Present -> the hex string, +// absent -> "null". +std::string TicketLine(const char* name, const std::optional>& ticket) { + if (!ticket) return std::string{name} + ":null\n"; + return std::string{name} + "(" + std::to_string(ticket->size()) + "bytes):" + + ToHexString(*ticket) + "\n"; +} + +// Everything lands in a single folder: the raw binary tickets +// (only when present) plus a plain-text summary file. +bool WriteOutputs(uint32_t appId, + const std::optional>& ownership, + const std::optional>& encrypted) { + 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 + << " (GetLastError=" << GetLastError() << ").\n"; + return false; + } + + bool ok{true}; + 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)}; + + const std::string textPath{JoinPath(dir, "tickets.txt")}; + std::ofstream summary{textPath, std::ios::trunc}; + if (!summary || !(summary << text)) { + std::cerr << "Failed to write " << textPath << ".\n"; + return false; + } + + std::cout << "Wrote " << dir << "\\\n"; + return ok; +} + +void WaitForExit() { + std::cout << "\nPress Enter to exit..."; + std::string dummy; + std::getline(std::cin, dummy); +} + +#if defined(_WIN64) +int Run(int argc, char** argv) { + std::optional appId; + if (argc >= 2) { + appId = ParseAppId(argv[1]); + if (!appId) { + std::cerr << "Invalid AppID: " << argv[1] << "\n"; + return 1; + } + } else { + appId = ReadAppIdFromConsole(); + if (!appId) { + std::cerr << "Invalid AppID.\n"; + return 1; + } + } + + // Run in the target app's context so GetAppID and RequestEncryptedAppTicket + // resolve to this AppID. Must be set before steamclient64.dll initializes. + const std::string appIdStr{std::to_string(*appId)}; + SetEnvironmentVariableA("SteamAppId", appIdStr.c_str()); + SetEnvironmentVariableA("SteamGameId", appIdStr.c_str()); + + std::string steamClientPath; + HMODULE steamClient{LoadSteamClient64(steamClientPath)}; + if (!steamClient) return 1; + + std::cout << "Loaded " << steamClientPath << "\n"; + + ISteamClient* client{CreateSteamClient(steamClient)}; + if (!client) { + FreeLibrary(steamClient); + return 1; + } + + HSteamPipe pipe{0}; + HSteamUser user{0}; + if (!OpenSession(client, pipe, user)) { + FreeLibrary(steamClient); + return 1; + } + + if (auto* utils{client->GetISteamUtils(pipe, kSteamUtilsInterfaceVersion)}) { + std::cout << "ConnectedUniverse=" << static_cast(utils->GetConnectedUniverse()) + << " ClientAppID=" << utils->GetAppID() << "\n"; + } + + auto ownership{ExtractAppOwnershipTicket(client, pipe, user, *appId)}; + if (ownership) PrintHex("Ownership ticket", *ownership); + + auto encrypted{ExtractEncryptedAppTicket(client, pipe, user, *appId)}; + if (encrypted) PrintHex("Encrypted ticket", *encrypted); + + const bool ok{WriteOutputs(*appId, ownership, encrypted)}; + + client->BReleaseSteamPipe(pipe); + FreeLibrary(steamClient); + return ok ? 0 : 1; +} +#endif + +} // namespace + +int main(int argc, char** argv) { +#if !defined(_WIN64) + std::cerr << "extract_tickets must be built as a 64-bit Windows executable.\n"; + WaitForExit(); + return 1; +#else + const int rc{Run(argc, argv)}; + WaitForExit(); + return rc; +#endif +} diff --git a/tools/extract_tickets/steam.h b/tools/extract_tickets/steam.h new file mode 100644 index 0000000..f17dad1 --- /dev/null +++ b/tools/extract_tickets/steam.h @@ -0,0 +1,125 @@ +// steam.h - minimal Steam client interface subset for extract_tickets. + +#pragma once + +// Steam-specific scalar types (steamtypes.h, Win32 branch). +typedef unsigned char uint8; +typedef unsigned __int16 uint16; +typedef __int32 int32; +typedef unsigned __int32 uint32; +typedef unsigned __int64 uint64; + +typedef int32 HSteamPipe; +typedef int32 HSteamUser; +typedef uint32 AppId_t; +typedef uint64 SteamAPICall_t; + +// Steam universes (steamuniverse.h). +enum EUniverse { + k_EUniverseInvalid = 0, + k_EUniversePublic = 1, + k_EUniverseBeta = 2, + k_EUniverseInternal = 3, + k_EUniverseDev = 4, + k_EUniverseMax +}; + +// EResult subset (steamclientpublic.h); only success is checked by name. +enum EResult { + k_EResultOK = 1, +}; + +// Result delivered for ISteamUser::RequestEncryptedAppTicket (isteamuser.h). +// k_iSteamUserCallbacks == 100. +struct EncryptedAppTicketResponse_t { + enum { k_iCallback = 100 + 54 }; + EResult m_eResult; +}; + +// Interface versions requested via CreateInterface / GetISteamGenericInterface +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"; + +// Interfaces returned by ISteamClient getters we never dereference; declared +// opaque so the vtable slots keep their SDK signatures. +class ISteamGameServer; +class ISteamFriends; +class ISteamMatchmaking; +class ISteamMatchmakingServers; +class ISteamUser; + +// isteamutils.h +class ISteamUtils { +public: + virtual uint32 GetSecondsSinceAppActive() = 0; + virtual uint32 GetSecondsSinceComputerActive() = 0; + virtual EUniverse GetConnectedUniverse() = 0; + virtual uint32 GetServerRealTime() = 0; + virtual const char* GetIPCountry() = 0; + virtual bool GetImageSize(int iImage, uint32* pnWidth, uint32* pnHeight) = 0; + virtual bool GetImageRGBA(int iImage, uint8* pubDest, int nDestBufferSize) = 0; + virtual bool GetCSERIPPort(uint32* unIP, uint16* usPort) = 0; + virtual uint8 GetCurrentBatteryPower() = 0; + virtual uint32 GetAppID() = 0; + virtual void SetOverlayNotificationPosition(int eNotificationPosition) = 0; + virtual bool IsAPICallCompleted(SteamAPICall_t hSteamAPICall, bool* pbFailed) = 0; + virtual int GetAPICallFailureReason(SteamAPICall_t hSteamAPICall) = 0; + virtual bool GetAPICallResult(SteamAPICall_t hSteamAPICall, void* pCallback, int cubCallback, int iCallbackExpected, bool* pbFailed) = 0; +}; + +// isteamclient.h +class ISteamClient { +public: + virtual HSteamPipe CreateSteamPipe() = 0; + virtual bool BReleaseSteamPipe(HSteamPipe hSteamPipe) = 0; + virtual HSteamUser ConnectToGlobalUser(HSteamPipe hSteamPipe) = 0; + virtual HSteamUser CreateLocalUser(HSteamPipe* phSteamPipe, int eAccountType) = 0; + virtual void ReleaseUser(HSteamPipe hSteamPipe, HSteamUser hUser) = 0; + virtual ISteamUser* GetISteamUser(HSteamUser hSteamUser, HSteamPipe hSteamPipe, const char* pchVersion) = 0; + virtual ISteamGameServer* GetISteamGameServer(HSteamUser hSteamUser, HSteamPipe hSteamPipe, const char* pchVersion) = 0; + virtual void SetLocalIPBinding(const void* unIP, uint16 usPort) = 0; + virtual ISteamFriends* GetISteamFriends(HSteamUser hSteamUser, HSteamPipe hSteamPipe, const char* pchVersion) = 0; + virtual ISteamUtils* GetISteamUtils(HSteamPipe hSteamPipe, const char* pchVersion) = 0; + virtual ISteamMatchmaking* GetISteamMatchmaking(HSteamUser hSteamUser, HSteamPipe hSteamPipe, const char* pchVersion) = 0; + virtual ISteamMatchmakingServers* GetISteamMatchmakingServers(HSteamUser hSteamUser, HSteamPipe hSteamPipe, const char* pchVersion) = 0; + virtual void* GetISteamGenericInterface(HSteamUser hSteamUser, HSteamPipe hSteamPipe, const char* pchVersion) = 0; +}; + +// isteamuser.h +class ISteamUser { +public: + virtual HSteamUser GetHSteamUser() = 0; + virtual bool BLoggedOn() = 0; + virtual uint64 GetSteamID() = 0; + virtual int InitiateGameConnection_DEPRECATED(void*, int, uint64, uint32, uint16, bool) = 0; + virtual void TerminateGameConnection_DEPRECATED(uint32, uint16) = 0; + virtual void TrackAppUsageEvent(uint64, int, const char*) = 0; + virtual bool GetUserDataFolder(char*, int) = 0; + virtual void StartVoiceRecording() = 0; + virtual void StopVoiceRecording() = 0; + virtual int GetAvailableVoice(uint32*, uint32*, uint32) = 0; + virtual int GetVoice(bool, void*, uint32, uint32*, bool, void*, uint32, uint32*, uint32) = 0; + virtual int DecompressVoice(const void*, uint32, void*, uint32, uint32*, uint32) = 0; + virtual uint32 GetVoiceOptimalSampleRate() = 0; + virtual uint32 GetAuthSessionTicket(void*, int, uint32*, const void*) = 0; + virtual uint32 GetAuthTicketForWebApi(const char*) = 0; + virtual int BeginAuthSession(const void*, int, uint64) = 0; + virtual void EndAuthSession(uint64) = 0; + virtual void CancelAuthTicket(uint32) = 0; + virtual int UserHasLicenseForApp(uint64, AppId_t) = 0; + virtual bool BIsBehindNAT() = 0; + virtual void AdvertiseGame(uint64, uint32, uint16) = 0; + virtual SteamAPICall_t RequestEncryptedAppTicket(void* pDataToInclude, int cbDataToInclude) = 0; + virtual bool GetEncryptedAppTicket(void* pTicket, int cbMaxTicket, uint32* pcbTicket) = 0; +}; + +// isteamappticket.h +class ISteamAppTicket +{ +public: + virtual uint32 GetAppOwnershipTicketData( uint32 nAppID, void *pvBuffer, uint32 cbBufferLength, uint32 *piAppId, uint32 *piSteamId, uint32 *piSignature, uint32 *pcbSignature ) = 0; +}; + +typedef void* (*CreateInterfaceFn)(const char* pName, int* pReturnCode); diff --git a/tools/ipc_codegen/ipc_codegen.cpp b/tools/ipc_codegen/ipc_codegen.cpp new file mode 100644 index 0000000..ac55208 --- /dev/null +++ b/tools/ipc_codegen/ipc_codegen.cpp @@ -0,0 +1,1096 @@ +// ipc_codegen - Steam IPC IDL to C++ generator for OpenSteamTool. +// +// Usage: ipc_codegen +// +// The generated header contains the IPC enums, transport envelope wrappers, +// and per-method Req/Resp facades. The tool intentionally has no external +// dependencies and is built as a small C++20 host utility. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +enum class Dir { In, Out }; + +struct Field { + Dir dir = Dir::In; + std::string type; + std::string name; + std::string lenRef; + int line = 0; +}; + +struct EnumMember { + std::string name; + std::string value; + int line = 0; +}; + +struct EnumDecl { + std::string name; + std::string underlying; + std::vector members; + int line = 0; +}; + +struct StructDecl { + std::string name; + std::vector fields; + int line = 0; +}; + +struct FrameField { + bool payload = false; + std::string type; + std::string name; + int line = 0; +}; + +struct FrameDecl { + std::vector fields; +}; + +struct CommandDecl { + std::string name; + std::string enumValue; + FrameDecl request; + FrameDecl response; + int line = 0; +}; + +struct ProtocolDecl { + std::string name; + std::string requestHeader; + std::vector commands; + int line = 0; +}; + +struct Method { + std::string name; + std::string retType; + std::vector params; + int line = 0; +}; + +struct Interface { + std::string name; + std::vector methods; + int line = 0; +}; + +struct File { + std::vector enums; + std::vector structs; + std::vector protocols; + std::vector interfaces; +}; + +enum class Tok { + Ident, + Int, + LBrace, + RBrace, + LParen, + RParen, + LBrack, + RBrack, + Comma, + Semi, + Colon, + Scope, + Equal, + End, +}; + +struct Token { + Tok kind; + std::string text; + int line = 0; + int col = 0; +}; + +class Lexer { +public: + Lexer(std::string_view src, std::string filename) + : src_(src), filename_(std::move(filename)) {} + + Token next() { + skipWs(); + const int startLine = line_; + const int startCol = column(); + if (pos_ >= src_.size()) return {Tok::End, "", startLine, startCol}; + + const char c = src_[pos_]; + if (c == '{') { ++pos_; return {Tok::LBrace, "{", startLine, startCol}; } + if (c == '}') { ++pos_; return {Tok::RBrace, "}", startLine, startCol}; } + if (c == '(') { ++pos_; return {Tok::LParen, "(", startLine, startCol}; } + if (c == ')') { ++pos_; return {Tok::RParen, ")", startLine, startCol}; } + if (c == '[') { ++pos_; return {Tok::LBrack, "[", startLine, startCol}; } + if (c == ']') { ++pos_; return {Tok::RBrack, "]", startLine, startCol}; } + if (c == ',') { ++pos_; return {Tok::Comma, ",", startLine, startCol}; } + if (c == ';') { ++pos_; return {Tok::Semi, ";", startLine, startCol}; } + if (c == '=') { ++pos_; return {Tok::Equal, "=", startLine, startCol}; } + if (c == ':' && pos_ + 1 < src_.size() && src_[pos_ + 1] == ':') { + pos_ += 2; + return {Tok::Scope, "::", startLine, startCol}; + } + if (c == ':') { ++pos_; return {Tok::Colon, ":", startLine, startCol}; } + + if (std::isalpha(static_cast(c)) || c == '_') { + const size_t start = pos_++; + while (pos_ < src_.size()) { + const char next = src_[pos_]; + if (!std::isalnum(static_cast(next)) && next != '_') break; + ++pos_; + } + return {Tok::Ident, std::string(src_.substr(start, pos_ - start)), startLine, startCol}; + } + + if (std::isdigit(static_cast(c))) { + const size_t start = pos_++; + while (pos_ < src_.size() && + std::isalnum(static_cast(src_[pos_]))) { + ++pos_; + } + return {Tok::Int, std::string(src_.substr(start, pos_ - start)), startLine, startCol}; + } + + die(startLine, startCol, "unexpected character '" + std::string(1, c) + "'"); + } + + [[noreturn]] void die(int line, int col, const std::string& message) const { + std::fprintf(stderr, "%s:%d:%d: error: %s\n", + filename_.c_str(), line, col, message.c_str()); + std::exit(1); + } + +private: + void skipWs() { + while (pos_ < src_.size()) { + const char c = src_[pos_]; + if (c == '\n') { + ++line_; + ++pos_; + lineStart_ = pos_; + } else if (std::isspace(static_cast(c))) { + ++pos_; + } else if (c == '/' && pos_ + 1 < src_.size() && src_[pos_ + 1] == '/') { + while (pos_ < src_.size() && src_[pos_] != '\n') ++pos_; + } else if (c == '/' && pos_ + 1 < src_.size() && src_[pos_ + 1] == '*') { + pos_ += 2; + while (pos_ + 1 < src_.size() && + !(src_[pos_] == '*' && src_[pos_ + 1] == '/')) { + if (src_[pos_] == '\n') { + ++line_; + lineStart_ = pos_ + 1; + } + ++pos_; + } + if (pos_ + 1 >= src_.size()) die(line_, column(), "unterminated block comment"); + pos_ += 2; + } else { + break; + } + } + } + + int column() const { return static_cast(pos_ - lineStart_) + 1; } + + std::string_view src_; + std::string filename_; + size_t pos_ = 0; + size_t lineStart_ = 0; + int line_ = 1; +}; + +class Parser { +public: + explicit Parser(Lexer& lexer) : lexer_(lexer), token_(lexer.next()) {} + + File parse() { + File file; + while (token_.kind != Tok::End) { + const Token kind = expect(Tok::Ident, "declaration"); + if (kind.text == "enum") { + file.enums.push_back(parseEnum()); + } else if (kind.text == "struct") { + file.structs.push_back(parseStruct()); + } else if (kind.text == "protocol") { + file.protocols.push_back(parseProtocol()); + } else if (kind.text == "interface") { + file.interfaces.push_back(parseInterface()); + } else { + fail(kind.line, "unknown declaration '" + kind.text + "'"); + } + } + validate(file); + return file; + } + +private: + EnumDecl parseEnum() { + EnumDecl decl; + decl.line = token_.line; + decl.name = expect(Tok::Ident, "enum name").text; + expect(Tok::Colon); + decl.underlying = expect(Tok::Ident, "enum underlying type").text; + expect(Tok::LBrace); + std::unordered_set names; + while (token_.kind != Tok::RBrace) { + EnumMember member; + member.line = token_.line; + member.name = expect(Tok::Ident, "enum member").text; + ensureUnique(names, member.name, "enum member", member.line); + expect(Tok::Equal); + member.value = expect(Tok::Int, "enum value").text; + expect(Tok::Semi); + decl.members.push_back(std::move(member)); + } + expect(Tok::RBrace); + optionalSemi(); + return decl; + } + + StructDecl parseStruct() { + StructDecl decl; + decl.line = token_.line; + decl.name = expect(Tok::Ident, "struct name").text; + expect(Tok::LBrace); + std::unordered_set names; + while (token_.kind != Tok::RBrace) { + Field field = parseField(false); + ensureUnique(names, field.name, "struct field", field.line); + expect(Tok::Semi); + decl.fields.push_back(std::move(field)); + } + expect(Tok::RBrace); + optionalSemi(); + return decl; + } + + ProtocolDecl parseProtocol() { + ProtocolDecl decl; + decl.line = token_.line; + decl.name = expect(Tok::Ident, "protocol name").text; + expect(Tok::LBrace); + std::unordered_set commandNames; + while (token_.kind != Tok::RBrace) { + const Token keyword = expect(Tok::Ident, "'request' or 'command'"); + if (keyword.text == "request") { + if (!decl.requestHeader.empty()) fail(keyword.line, "duplicate protocol request header"); + decl.requestHeader = expect(Tok::Ident, "request header type").text; + expect(Tok::Semi); + continue; + } + if (keyword.text != "command") { + fail(keyword.line, "expected 'request' or 'command', got '" + keyword.text + "'"); + } + CommandDecl command; + command.line = token_.line; + command.name = expect(Tok::Ident, "command name").text; + ensureUnique(commandNames, command.name, "command", command.line); + expect(Tok::Equal); + command.enumValue = parseScopedName(); + expect(Tok::LBrace); + bool hasRequest = false; + bool hasResponse = false; + while (token_.kind != Tok::RBrace) { + const Token section = expect(Tok::Ident, "'request' or 'response'"); + if (section.text == "request") { + if (hasRequest) fail(section.line, "duplicate command request section"); + command.request = parseFrame(); + hasRequest = true; + } else if (section.text == "response") { + if (hasResponse) fail(section.line, "duplicate command response section"); + command.response = parseFrame(); + hasResponse = true; + } else { + fail(section.line, "expected 'request' or 'response', got '" + section.text + "'"); + } + } + expect(Tok::RBrace); + optionalSemi(); + if (!hasRequest || !hasResponse) fail(command.line, "command requires request and response sections"); + decl.commands.push_back(std::move(command)); + } + expect(Tok::RBrace); + optionalSemi(); + return decl; + } + + FrameDecl parseFrame() { + FrameDecl frame; + expect(Tok::LBrace); + std::unordered_set names; + bool hasPayload = false; + while (token_.kind != Tok::RBrace) { + FrameField field; + field.line = token_.line; + field.type = expect(Tok::Ident, "frame field type").text; + field.payload = field.type == "payload"; + field.name = expect(Tok::Ident, "frame field name").text; + ensureUnique(names, field.name, "frame field", field.line); + if (field.payload) { + if (hasPayload) fail(field.line, "frame may contain at most one payload"); + hasPayload = true; + } + expect(Tok::Semi); + frame.fields.push_back(std::move(field)); + } + expect(Tok::RBrace); + return frame; + } + + Interface parseInterface() { + Interface iface; + iface.line = token_.line; + iface.name = expect(Tok::Ident, "interface name").text; + expect(Tok::LBrace); + std::unordered_set methodNames; + while (token_.kind != Tok::RBrace) { + Method method = parseMethod(); + ensureUnique(methodNames, method.name, "method", method.line); + iface.methods.push_back(std::move(method)); + } + expect(Tok::RBrace); + optionalSemi(); + return iface; + } + + Method parseMethod() { + Method method; + method.line = token_.line; + method.retType = expect(Tok::Ident, "return type").text; + method.name = expect(Tok::Ident, "method name").text; + expect(Tok::LParen); + std::unordered_set names; + if (token_.kind != Tok::RParen) { + for (;;) { + Field field = parseField(true); + ensureUnique(names, field.name, "method parameter", field.line); + method.params.push_back(std::move(field)); + if (token_.kind != Tok::Comma) break; + advance(); + } + } + expect(Tok::RParen); + expect(Tok::Semi); + return method; + } + + Field parseField(bool allowDirection) { + Field field; + field.line = token_.line; + if (token_.kind == Tok::Ident && (token_.text == "in" || token_.text == "out")) { + if (!allowDirection) fail("direction marker is not allowed here"); + field.dir = token_.text == "out" ? Dir::Out : Dir::In; + advance(); + } + field.type = expect(Tok::Ident, "field type").text; + field.name = expect(Tok::Ident, "field name").text; + if (token_.kind == Tok::LBrack) { + advance(); + field.lenRef = expect(Tok::Ident, "byte length field").text; + expect(Tok::RBrack); + } + if (field.type == "bytes" && field.lenRef.empty()) + fail(field.line, "bytes field '" + field.name + "' requires a length field"); + if (field.type != "bytes" && !field.lenRef.empty()) + fail(field.line, "only bytes fields may declare a length field"); + return field; + } + + std::string parseScopedName() { + std::string result = expect(Tok::Ident, "identifier").text; + while (token_.kind == Tok::Scope) { + advance(); + result += "::" + expect(Tok::Ident, "identifier").text; + } + return result; + } + + void validate(const File& file) { + std::unordered_set typeNames; + for (const auto& decl : file.enums) ensureUnique(typeNames, decl.name, "type", decl.line); + for (const auto& decl : file.structs) ensureUnique(typeNames, decl.name, "type", decl.line); + + std::unordered_set protocolNames; + for (const auto& decl : file.protocols) + ensureUnique(protocolNames, decl.name, "protocol", decl.line); + + std::unordered_set interfaceNames; + for (const auto& iface : file.interfaces) + ensureUnique(interfaceNames, iface.name, "interface", iface.line); + + const EnumDecl* interfaceEnum = findEnum(file, "EIPCInterface"); + if (!interfaceEnum) fail("missing EIPCInterface enum"); + std::unordered_set interfaceMembers; + for (const auto& member : interfaceEnum->members) interfaceMembers.insert(member.name); + for (const auto& iface : file.interfaces) { + if (!interfaceMembers.contains(iface.name)) + fail(iface.line, "interface '" + iface.name + "' has no EIPCInterface member"); + for (const auto& method : iface.methods) validateMethod(method); + } + + if (file.protocols.size() != 1 || file.protocols.front().name != "IPC") + fail("exactly one 'protocol IPC' declaration is required"); + const ProtocolDecl& protocol = file.protocols.front(); + if (!findStruct(file, protocol.requestHeader)) + fail(protocol.line, "unknown protocol request header '" + protocol.requestHeader + "'"); + if (protocol.commands.size() != 1 || protocol.commands.front().name != "IPCInterfaceCall") + fail("protocol IPC must declare exactly one IPCInterfaceCall command"); + + const CommandDecl& command = protocol.commands.front(); + validateFrame(file, command.request, "request"); + validateFrame(file, command.response, "response"); + if (!framePayload(command.request) || !framePayload(command.response)) + fail(command.line, "IPCInterfaceCall request and response require a payload"); + if (command.request.fields.size() != 3 || + command.request.fields[0].name != "header" || + command.request.fields[1].name != "body" || + command.request.fields[2].name != "fencepost") { + fail(command.line, "IPCInterfaceCall request must be: header, payload body, fencepost"); + } + if (command.response.fields.size() != 2 || + command.response.fields[0].name != "header" || + command.response.fields[1].name != "body") { + fail(command.line, "IPCInterfaceCall response must be: header, payload body"); + } + } + + void validateMethod(const Method& method) { + std::unordered_map prior; + size_t inBlobs = 0; + size_t outBlobs = 0; + for (const auto& field : method.params) { + if (!field.lenRef.empty()) { + auto it = prior.find(field.lenRef); + if (it == prior.end()) + fail(field.line, "bytes field '" + field.name + + "' references missing or later length field '" + field.lenRef + "'"); + if (!isIntegral(it->second->type)) + fail(field.line, "bytes length field '" + field.lenRef + "' must be integral"); + size_t& count = field.dir == Dir::In ? inBlobs : outBlobs; + if (++count > 1) + fail(field.line, "only one bytes field per request or response is supported"); + } + prior[field.name] = &field; + } + } + + void validateFrame(const File& file, const FrameDecl& frame, const char* label) { + for (const auto& field : frame.fields) { + if (!field.payload && !findStruct(file, field.type) && !isIntegral(field.type)) + fail(field.line, std::string("unknown ") + label + " frame type '" + field.type + "'"); + } + } + + static bool isIntegral(std::string_view type) { + static const std::unordered_set types = { + "bool", "byte", "uint8", "int8", "uint16", "int16", + "uint32", "int32", "uint64", "int64", "size_t", + }; + return types.contains(type); + } + + static const EnumDecl* findEnum(const File& file, std::string_view name) { + for (const auto& decl : file.enums) if (decl.name == name) return &decl; + return nullptr; + } + + static const StructDecl* findStruct(const File& file, std::string_view name) { + for (const auto& decl : file.structs) if (decl.name == name) return &decl; + return nullptr; + } + + static const FrameField* framePayload(const FrameDecl& frame) { + for (const auto& field : frame.fields) if (field.payload) return &field; + return nullptr; + } + + void optionalSemi() { + if (token_.kind == Tok::Semi) advance(); + } + + void advance() { token_ = lexer_.next(); } + + Token expect(Tok kind, const char* description = nullptr) { + if (token_.kind != kind) { + fail("expected " + std::string(description ? description : tokenName(kind)) + + ", got '" + token_.text + "'"); + } + Token result = token_; + advance(); + return result; + } + + static const char* tokenName(Tok kind) { + switch (kind) { + case Tok::Ident: return "identifier"; + case Tok::Int: return "integer"; + case Tok::LBrace: return "'{'"; + case Tok::RBrace: return "'}'"; + case Tok::LParen: return "'('"; + case Tok::RParen: return "')'"; + case Tok::LBrack: return "'['"; + case Tok::RBrack: return "']'"; + case Tok::Comma: return "','"; + case Tok::Semi: return "';'"; + case Tok::Colon: return "':'"; + case Tok::Scope: return "'::'"; + case Tok::Equal: return "'='"; + case Tok::End: return "end of file"; + } + return "token"; + } + + void ensureUnique(std::unordered_set& names, + const std::string& name, + const char* kind, + int line) { + if (!names.insert(name).second) + fail(line, "duplicate " + std::string(kind) + " '" + name + "'"); + } + + [[noreturn]] void fail(const std::string& message) const { + lexer_.die(token_.line, token_.col, message); + } + + [[noreturn]] void fail(int line, const std::string& message) const { + lexer_.die(line, 1, message); + } + + Lexer& lexer_; + Token token_; +}; + +class Emitter { +public: + Emitter(std::ostream& out, const File& file) : out_(out), file_(file) {} + + void emit(const std::string& inputName, const std::string& outputName) { + out_ << "// " << outputName << " - AUTO-GENERATED by tools/ipc_codegen.\n" + "// Source: " << inputName << "\n" + "// Do not edit by hand. Edit the .steamd source and rebuild.\n" + "#pragma once\n" + "#include \"Steam/Structs.h\"\n" + "#include \n" + "#include \n" + "#include \n" + "#include \n" + "#include \n" + "#include \n" + "#include \n" + "#include \n" + "#include \n" + "#include \n" + "#include \n" + "#include \n\n"; + + for (const auto& decl : file_.enums) emitEnum(decl); + + out_ << "namespace IPCMessages {\n\n"; + emitHelpers(); + emitLayouts(); + emitIPCRequest(); + emitIPCInterfaceCall(); + emitIPCResponse(); + out_ << "template \n" + "std::span asBytes(const T& value) {\n" + " static_assert(std::is_trivially_copyable_v);\n" + " return {reinterpret_cast(&value), sizeof(T)};\n" + "}\n\n"; + for (const auto& iface : file_.interfaces) emitInterface(iface); + out_ << "} // namespace IPCMessages\n"; + } + +private: + void emitEnum(const EnumDecl& decl) { + out_ << "enum class " << decl.name << " : " << decl.underlying << " {\n"; + for (const auto& member : decl.members) + out_ << " " << member.name << " = " << member.value << ",\n"; + out_ << "};\n\n" + << "inline const char* " << decl.name << "Name(" << decl.name << " value) {\n" + " switch (value) {\n"; + for (const auto& member : decl.members) + out_ << " case " << decl.name << "::" << member.name << ": return \"" + << member.name << "\";\n"; + out_ << " default: return \"Unknown\";\n" + " }\n" + "}\n\n" + << "inline std::optional<" << decl.name << "> " << decl.name + << "FromName(std::string_view name) {\n"; + for (const auto& member : decl.members) + out_ << " if (name == \"" << member.name << "\") return " + << decl.name << "::" << member.name << ";\n"; + out_ << " return std::nullopt;\n" + "}\n\n" + << "inline std::ostream& operator<<(std::ostream& os, " << decl.name + << " value) {\n" + " return os << " << decl.name << "Name(value);\n" + "}\n\n"; + } + + void emitHelpers() { + out_ << "namespace detail {\n" + "inline std::span BufferBytes(CUtlBuffer* buffer) {\n" + " if (!buffer || !buffer->Base() || buffer->m_Put < 0) return {};\n" + " return {buffer->Base(), static_cast(buffer->m_Put)};\n" + "}\n\n" + "template \n" + "T Read(std::span bytes, size_t offset) {\n" + " T value{};\n" + " if (offset <= bytes.size() && sizeof(T) <= bytes.size() - offset)\n" + " std::memcpy(&value, bytes.data() + offset, sizeof(T));\n" + " return value;\n" + "}\n\n" + "template \n" + "void Write(std::span bytes, size_t offset, const T& value) {\n" + " if (offset <= bytes.size() && sizeof(T) <= bytes.size() - offset)\n" + " std::memcpy(bytes.data() + offset, &value, sizeof(T));\n" + "}\n\n" + "template \n" + "size_t ByteCount(T value) {\n" + " static_assert(std::is_integral_v);\n" + " if constexpr (std::is_signed_v) {\n" + " if (value < 0) return (std::numeric_limits::max)();\n" + " }\n" + " return static_cast(value);\n" + "}\n\n" + "inline size_t Sum(std::initializer_list values) {\n" + " size_t result = 0;\n" + " for (size_t value : values) {\n" + " if (value > (std::numeric_limits::max)() - result)\n" + " return (std::numeric_limits::max)();\n" + " result += value;\n" + " }\n" + " return result;\n" + "}\n\n" + "inline bool Fits(std::span bytes, size_t size) {\n" + " return size != (std::numeric_limits::max)() && bytes.size() >= size;\n" + "}\n\n" + "inline std::span Slice(std::span bytes, size_t offset, size_t size) {\n" + " if (offset > bytes.size() || size > bytes.size() - offset) return {};\n" + " return bytes.subspan(offset, size);\n" + "}\n\n" + "inline std::span Slice(std::span bytes, size_t offset, size_t size) {\n" + " if (offset > bytes.size() || size > bytes.size() - offset) return {};\n" + " return bytes.subspan(offset, size);\n" + "}\n\n" + "inline bool CopyBytes(std::span bytes, size_t offset, size_t capacity,\n" + " std::span value) {\n" + " auto dst = Slice(bytes, offset, capacity);\n" + " if (dst.size() != capacity) return false;\n" + " const size_t count = (std::min)(dst.size(), value.size());\n" + " if (count) std::memcpy(dst.data(), value.data(), count);\n" + " if (count < dst.size()) std::memset(dst.data() + count, 0, dst.size() - count);\n" + " return true;\n" + "}\n\n" + "template \n" + "void AppendField(std::ostringstream& os, const char* name, const T& value) {\n" + " os << name << '=' << value;\n" + "}\n\n" + "inline void AppendBytes(std::ostringstream& os, const char* name,\n" + " std::span value) {\n" + " static constexpr char kHex[] = \"0123456789abcdef\";\n" + " auto appendHexByte = [&](unsigned byte) {\n" + " os << kHex[(byte >> 4) & 0x0F] << kHex[byte & 0x0F];\n" + " };\n" + " auto appendHexOffset = [&](size_t offset) {\n" + " for (int shift = static_cast((sizeof(size_t) * 8) - 4); shift >= 0; shift -= 4) {\n" + " const size_t nibble = (offset >> shift) & 0x0F;\n" + " if (nibble || shift <= 12) os << kHex[nibble];\n" + " }\n" + " };\n" + " os << name << '(' << value.size() << \"B)=hex[\";\n" + " if (!value.empty()) os << '\\n';\n" + " for (size_t i = 0; i < value.size(); ++i) {\n" + " if ((i % 16) == 0) {\n" + " os << \" \";\n" + " appendHexOffset(i);\n" + " os << ':';\n" + " }\n" + " os << ' ';\n" + " appendHexByte(static_cast(value[i]));\n" + " if ((i % 16) == 15 && i + 1 < value.size()) os << '\\n';\n" + " }\n" + " if (!value.empty()) os << '\\n';\n" + " os << ']';\n" + "}\n\n"; + } + + void emitLayouts() { + out_ << "#pragma pack(push, 1)\n"; + for (const auto& decl : file_.structs) { + out_ << "struct " << decl.name << "Layout {\n"; + for (const auto& field : decl.fields) + out_ << " " << field.type << " " << field.name << ";\n"; + out_ << "};\n\n"; + } + out_ << "#pragma pack(pop)\n" + "} // namespace detail\n\n"; + } + + void emitIPCRequest() { + out_ << "class IPCRequest {\n" + "public:\n" + " explicit IPCRequest(CUtlBuffer* buffer) : IPCRequest(detail::BufferBytes(buffer)) {}\n" + " explicit IPCRequest(std::span bytes) : bytes_(bytes) {}\n" + " bool ok() const { return bytes_.size() >= sizeof(detail::IPCRequestHeaderLayout); }\n" + " EIPCCommand command() const { return detail::Read(bytes_, offsetof(detail::IPCRequestHeaderLayout, command)); }\n" + " void set_command(EIPCCommand value) { detail::Write(bytes_, offsetof(detail::IPCRequestHeaderLayout, command), value); }\n" + " std::span body() { return detail::Slice(bytes_, sizeof(detail::IPCRequestHeaderLayout), bytes_.size() >= sizeof(detail::IPCRequestHeaderLayout) ? bytes_.size() - sizeof(detail::IPCRequestHeaderLayout) : 0); }\n" + " std::span body() const { return detail::Slice(std::span(bytes_), sizeof(detail::IPCRequestHeaderLayout), bytes_.size() >= sizeof(detail::IPCRequestHeaderLayout) ? bytes_.size() - sizeof(detail::IPCRequestHeaderLayout) : 0); }\n" + " std::string DebugString() const { std::ostringstream os; os << \"IPCRequest{\"; detail::AppendField(os, \"command\", command()); os << '}'; return os.str(); }\n" + "private:\n" + " std::span bytes_;\n" + "};\n\n"; + } + + void emitIPCInterfaceCall() { + out_ << "class IPCInterfaceCall {\n" + "public:\n" + " explicit IPCInterfaceCall(std::span bytes) : bytes_(bytes) {}\n" + " bool ok() const { return bytes_.size() >= sizeof(detail::IPCInterfaceCallHeaderLayout) + sizeof(uint32); }\n" + " EIPCInterface interfaceID() const { return detail::Read(bytes_, offsetof(detail::IPCInterfaceCallHeaderLayout, interfaceID)); }\n" + " void set_interfaceID(EIPCInterface value) { detail::Write(bytes_, offsetof(detail::IPCInterfaceCallHeaderLayout, interfaceID), value); }\n" + " uint32 hSteamUser() const { return detail::Read(bytes_, offsetof(detail::IPCInterfaceCallHeaderLayout, hSteamUser)); }\n" + " void set_hSteamUser(uint32 value) { detail::Write(bytes_, offsetof(detail::IPCInterfaceCallHeaderLayout, hSteamUser), value); }\n" + " uint32 funcHash() const { return detail::Read(bytes_, offsetof(detail::IPCInterfaceCallHeaderLayout, funcHash)); }\n" + " void set_funcHash(uint32 value) { detail::Write(bytes_, offsetof(detail::IPCInterfaceCallHeaderLayout, funcHash), value); }\n" + " uint32 fencepost() const { return detail::Read(bytes_, bytes_.size() >= sizeof(uint32) ? bytes_.size() - sizeof(uint32) : 0); }\n" + " void set_fencepost(uint32 value) { if (bytes_.size() >= sizeof(uint32)) detail::Write(bytes_, bytes_.size() - sizeof(uint32), value); }\n" + " std::span body() { const size_t prefix = sizeof(detail::IPCInterfaceCallHeaderLayout); const size_t suffix = sizeof(uint32); return detail::Slice(bytes_, prefix, bytes_.size() >= prefix + suffix ? bytes_.size() - prefix - suffix : 0); }\n" + " std::span body() const { const size_t prefix = sizeof(detail::IPCInterfaceCallHeaderLayout); const size_t suffix = sizeof(uint32); return detail::Slice(std::span(bytes_), prefix, bytes_.size() >= prefix + suffix ? bytes_.size() - prefix - suffix : 0); }\n" + " std::string DebugString() const { std::ostringstream os; os << \"IPCInterfaceCall{\"; detail::AppendField(os, \"interfaceID\", interfaceID()); os << ' '; detail::AppendField(os, \"hSteamUser\", hSteamUser()); os << ' '; detail::AppendField(os, \"funcHash\", funcHash()); os << ' '; detail::AppendField(os, \"fencepost\", fencepost()); os << '}'; return os.str(); }\n" + "private:\n" + " std::span bytes_;\n" + "};\n\n"; + } + + void emitIPCResponse() { + out_ << "class IPCResponse {\n" + "public:\n" + " explicit IPCResponse(CUtlBuffer* buffer) : IPCResponse(detail::BufferBytes(buffer)) {}\n" + " explicit IPCResponse(std::span bytes) : bytes_(bytes) {}\n" + " bool ok() const { return bytes_.size() >= sizeof(detail::IPCResponseHeaderLayout); }\n" + " EIPCResult result() const { return detail::Read(bytes_, offsetof(detail::IPCResponseHeaderLayout, result)); }\n" + " void set_result(EIPCResult value) { detail::Write(bytes_, offsetof(detail::IPCResponseHeaderLayout, result), value); }\n" + " std::span body() { return detail::Slice(bytes_, sizeof(detail::IPCResponseHeaderLayout), bytes_.size() >= sizeof(detail::IPCResponseHeaderLayout) ? bytes_.size() - sizeof(detail::IPCResponseHeaderLayout) : 0); }\n" + " std::span body() const { return detail::Slice(std::span(bytes_), sizeof(detail::IPCResponseHeaderLayout), bytes_.size() >= sizeof(detail::IPCResponseHeaderLayout) ? bytes_.size() - sizeof(detail::IPCResponseHeaderLayout) : 0); }\n" + " std::string DebugString() const { std::ostringstream os; os << \"IPCResponse{\"; detail::AppendField(os, \"result\", result()); os << '}'; return os.str(); }\n" + "private:\n" + " std::span bytes_;\n" + "};\n\n"; + } + + void emitInterface(const Interface& iface) { + out_ << "namespace " << iface.name << " {\n" + << "inline constexpr EIPCInterface kInterface = EIPCInterface::" << iface.name << ";\n\n"; + for (const auto& method : iface.methods) emitReq(iface, method); + for (const auto& method : iface.methods) emitResp(method); + out_ << "} // namespace " << iface.name << "\n\n"; + } + + void emitReq(const Interface& iface, const Method& method) { + const std::vector fields = fieldsFor(method, Dir::In, false); + const std::string cls = method.name + "Req"; + out_ << "class " << cls << " {\n" + "public:\n" + << " explicit " << cls << "(CUtlBuffer* buffer) : " << cls + << "(detail::BufferBytes(buffer)) {}\n" + << " explicit " << cls << "(std::span bytes) : request_(bytes), call_(request_.body()) {}\n" + " bool ok() const { return request_.ok() && request_.command() == EIPCCommand::InterfaceCall && call_.ok() && call_.interfaceID() == kInterface && detail::Fits(call_.body(), bodySize()); }\n" + " uint32 fencepost() const { return call_.fencepost(); }\n" + " void set_fencepost(uint32 value) { call_.set_fencepost(value); }\n"; + emitFieldAccessors(fields, fields, {}); + emitDebugString(cls, fields); + out_ << "private:\n" + << " size_t bodySize() const { return " << sumExpr(fields, fields, {}, false) << "; }\n" + " std::span body() { return call_.body(); }\n" + " std::span body() const { return call_.body(); }\n" + " IPCRequest request_;\n" + " IPCInterfaceCall call_;\n" + "};\n\n"; + } + + void emitResp(const Method& method) { + std::vector fields = fieldsFor(method, Dir::Out, true); + const std::vector external = externalRefs(method, fields); + const std::string cls = method.name + "Resp"; + out_ << "class " << cls << " {\n" + "public:\n" + << " explicit " << cls << "(CUtlBuffer* buffer"; + emitCtorParams(external); + out_ << ") : " << cls << "(detail::BufferBytes(buffer)"; + emitCtorArgs(external); + out_ << ") {}\n" + << " explicit " << cls << "(std::span bytes"; + emitCtorParams(external); + out_ << ") : response_(bytes)"; + for (const auto& name : external) out_ << ", " << name << "_(" << name << ")"; + out_ << " {}\n" + " bool ok() const { return response_.ok() && detail::Fits(response_.body(), minimumBodySize()); }\n" + " EIPCResult result() const { return response_.result(); }\n" + " void set_result(EIPCResult value) { response_.set_result(value); }\n"; + emitFieldAccessors(fields, fields, external); + emitDebugString(cls, fields); + out_ << "private:\n" + << " size_t minimumBodySize() const { return " + << sumExpr(fields, fields, external, true) << "; }\n" + " std::span body() { return response_.body(); }\n" + " std::span body() const { return response_.body(); }\n" + " IPCResponse response_;\n"; + for (const auto& name : external) out_ << " size_t " << name << "_;\n"; + out_ << "};\n\n"; + } + + void emitFieldAccessors(const std::vector& fields, + const std::vector& sideFields, + const std::vector& external) { + for (size_t i = 0; i < fields.size(); ++i) { + const Field& field = fields[i]; + const std::string offset = offsetExpr(fields, i, sideFields, external); + if (field.type == "bytes") { + const std::string length = lengthExpr(field, sideFields, external); + out_ << " std::span " << field.name << "() { return detail::Slice(body(), " + << offset << ", " << length << "); }\n" + << " std::span " << field.name + << "() const { return detail::Slice(body(), " << offset << ", " << length << "); }\n" + << " bool set_" << field.name + << "(std::span value) { return detail::CopyBytes(body(), " + << offset << ", " << length << ", value); }\n"; + } else { + out_ << " " << field.type << " " << field.name + << "() const { return detail::Read<" << field.type << ">(body(), " << offset << "); }\n" + << " void set_" << field.name << "(" << field.type + << " value) { detail::Write(body(), " << offset << ", value); }\n"; + } + } + } + + void emitDebugString(const std::string& cls, const std::vector& fields) { + out_ << " std::string DebugString() const {\n" + " std::ostringstream os;\n" + << " os << \"" << cls << "{\";\n"; + for (size_t i = 0; i < fields.size(); ++i) { + if (i) out_ << " os << ' ';\n"; + if (fields[i].type == "bytes") + out_ << " detail::AppendBytes(os, \"" << fields[i].name + << "\", " << fields[i].name << "());\n"; + else + out_ << " detail::AppendField(os, \"" << fields[i].name + << "\", " << fields[i].name << "());\n"; + } + out_ << " os << '}';\n" + " return os.str();\n" + " }\n"; + } + + static std::vector fieldsFor(const Method& method, Dir direction, bool includeReturn) { + std::vector fields; + if (includeReturn && method.retType != "void") { + Field result; + result.type = method.retType; + result.name = "returnValue"; + fields.push_back(result); + } + for (const auto& field : method.params) + if (field.dir == direction) fields.push_back(field); + return fields; + } + + static std::vector externalRefs(const Method& method, + const std::vector& fields) { + std::vector result; + for (const auto& field : fields) { + if (field.lenRef.empty() || containsField(fields, field.lenRef)) continue; + if (std::find(result.begin(), result.end(), field.lenRef) == result.end()) + result.push_back(field.lenRef); + } + return result; + } + + static bool containsField(const std::vector& fields, std::string_view name) { + return std::any_of(fields.begin(), fields.end(), + [name](const Field& field) { return field.name == name; }); + } + + static bool containsName(const std::vector& names, std::string_view name) { + return std::find(names.begin(), names.end(), name) != names.end(); + } + + static std::string lengthExpr(const Field& field, + const std::vector& sideFields, + const std::vector& external) { + if (containsField(sideFields, field.lenRef)) + return "detail::ByteCount(" + field.lenRef + "())"; + if (containsName(external, field.lenRef)) return field.lenRef + "_"; + return "0"; + } + + static std::string offsetExpr(const std::vector& fields, + size_t index, + const std::vector& sideFields, + const std::vector& external) { + std::vector terms; + for (size_t i = 0; i < index; ++i) { + if (fields[i].type == "bytes") + terms.push_back(lengthExpr(fields[i], sideFields, external)); + else + terms.push_back("sizeof(" + fields[i].type + ")"); + } + return sumExpr(terms); + } + + static std::string sumExpr(const std::vector& fields, + const std::vector& sideFields, + const std::vector& external, + bool minimum) { + std::vector terms; + for (const auto& field : fields) { + if (field.type != "bytes") { + terms.push_back("sizeof(" + field.type + ")"); + } else if (!minimum || !containsField(sideFields, field.lenRef)) { + terms.push_back(lengthExpr(field, sideFields, external)); + } + } + return sumExpr(terms); + } + + static std::string sumExpr(const std::vector& terms) { + if (terms.empty()) return "0"; + std::string result = "detail::Sum({"; + for (size_t i = 0; i < terms.size(); ++i) { + if (i) result += ", "; + result += terms[i]; + } + return result + "})"; + } + + void emitCtorParams(const std::vector& external) { + for (const auto& name : external) out_ << ", size_t " << name; + } + + void emitCtorArgs(const std::vector& external) { + for (const auto& name : external) out_ << ", " << name; + } + + std::ostream& out_; + const File& file_; +}; + +namespace { + +constexpr const char* kVersion = "ipc_codegen 1.0.0"; + +void printUsage(std::FILE* out) { + std::fprintf(out, + "usage: ipc_codegen [options] ...\n" + " --cpp_out=DIR write generated .gen.h into DIR (default: .)\n" + " --version print version and exit\n" + " -h, --help print this help and exit\n"); +} + +std::string stem(const std::string& path) { + const size_t slash = path.find_last_of("/\\"); + const size_t start = slash == std::string::npos ? 0 : slash + 1; + const size_t dot = path.find_last_of('.'); + const size_t end = (dot == std::string::npos || dot < start) ? path.size() : dot; + return path.substr(start, end - start); +} + +bool generate(const std::string& inputPath, const std::string& cppOut) { + std::ifstream input(inputPath, std::ios::binary); + if (!input) { + std::fprintf(stderr, "ipc_codegen: cannot open %s\n", inputPath.c_str()); + return false; + } + std::ostringstream buffer; + buffer << input.rdbuf(); + const std::string source = buffer.str(); + + Lexer lexer(source, inputPath); + Parser parser(lexer); + File file = parser.parse(); + + const std::string header = stem(inputPath) + ".gen.h"; + const std::string outputPath = cppOut.empty() ? header : cppOut + "/" + header; + + std::ostringstream generated; + Emitter emitter(generated, file); + emitter.emit(inputPath, header); + + std::ofstream output(outputPath, std::ios::binary | std::ios::trunc); + if (!output) { + std::fprintf(stderr, "ipc_codegen: cannot write %s\n", outputPath.c_str()); + return false; + } + output << generated.str(); + if (!output) { + std::fprintf(stderr, "ipc_codegen: failed to write %s\n", outputPath.c_str()); + return false; + } + + std::fprintf(stderr, "ipc_codegen: wrote %s (%zu interfaces)\n", + outputPath.c_str(), file.interfaces.size()); + return true; +} + +} // namespace + +int main(int argc, char** argv) { + std::string cppOut = "."; + std::vector inputs; + + for (int i = 1; i < argc; ++i) { + const std::string_view arg = argv[i]; + if (arg == "--version") { + std::printf("%s\n", kVersion); + return 0; + } + if (arg == "-h" || arg == "--help") { + printUsage(stdout); + return 0; + } + if (arg.rfind("--cpp_out=", 0) == 0) { + cppOut = std::string(arg.substr(std::string_view("--cpp_out=").size())); + continue; + } + if (arg == "--cpp_out") { + if (i + 1 >= argc) { + std::fprintf(stderr, "ipc_codegen: --cpp_out requires a directory\n"); + return 1; + } + cppOut = argv[++i]; + continue; + } + if (!arg.empty() && arg.front() == '-') { + std::fprintf(stderr, "ipc_codegen: unknown option '%s'\n", argv[i]); + printUsage(stderr); + return 1; + } + inputs.emplace_back(arg); + } + + if (inputs.empty()) { + printUsage(stderr); + return 1; + } + + for (const std::string& input : inputs) { + if (!generate(input, cppOut)) return 1; + } + return 0; +}