diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 77a5fc13..c18cb2c2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -73,9 +73,16 @@ jobs: Download `OpenSteamTool-${{ github.event.inputs.version }}-Release.zip` (or the `-Debug` variant if you need logging to `/opensteamtool directory`). - Extract and copy `dwmapi.dll`, `xinput1_4.dll` and `OpenSteamTool.dll` to your Steam root directory (e.g. `C:\Program Files (x86)\Steam`). - - Create a Lua config directory (for example `C:\Program Files (x86)\Steam\config\lua`) and place your Lua scripts there. **NOT** `C:\Program Files (x86)\Steam\config\stplug-in`! + ### Method 1: Portable Mode (Recommended, ost-Injector) + 1. Extract the zip to any standalone folder outside the Steam directory. + 2. Place your Lua unlock scripts in `config/lua/` inside that folder. + 3. Run `ost-Injector.exe` to launch/inject into Steam, or run `CreateAutoInjectTask.bat` to set up background auto-injection on system startup. + *(No files are placed in or modify your Steam directory!)* + + ### Method 2: Standard Mode (DLL Hijacking) + 1. Extract and copy `dwmapi.dll`, `xinput1_4.dll` and `OpenSteamTool.dll` directly to your Steam root directory (e.g. `C:\Program Files (x86)\Steam`). + 2. Create a Lua config directory (`C:\Program Files (x86)\Steam\config\lua`) and place your Lua scripts there. **NOT** `config\stplug-in`! files: | OpenSteamTool-${{ github.event.inputs.version }}-Release.zip OpenSteamTool-${{ github.event.inputs.version }}-Debug.zip + diff --git a/README.md b/README.md index d62b9fa6..aef2defb 100644 --- a/README.md +++ b/README.md @@ -1,270 +1,313 @@ -
- OpenSteamTool logo - -

OpenSteamTool

- -

- Open-Source Steam Unlock Tool -

- -

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

- -

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

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

OpenSteamTool

+ +

+ Open-Source Steam Unlock Tool +

+ +

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

+ +

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

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