From a5cdee89743947044faa13db9ac4a7951528bedf Mon Sep 17 00:00:00 2001 From: Ran-Mewo <43445785+Ran-Mewo@users.noreply.github.com> Date: Sat, 6 Jun 2026 16:38:13 +1000 Subject: [PATCH] Add onlinefix support for games that use EOS matchmaking service. --- README.md | 456 +++++++++++++++++----------------- opensteamtool.example.toml | 6 + src/CMakeLists.txt | 327 ++++++++++++------------ src/Hook/HookManager.cpp | 3 + src/Hook/Hooks_Inject.cpp | 179 +++++++++++++ src/Hook/Hooks_Inject.h | 10 + src/Hook/Hooks_Misc.cpp | 5 +- src/Payload/EosBridge.cpp | 159 ++++++++++++ src/Payload/EosBridge.h | 8 + src/Payload/EosTypes.h | 85 +++++++ src/Payload/PayloadLog.cpp | 44 ++++ src/Payload/PayloadLog.h | 18 ++ src/Payload/SelfPropagate.cpp | 85 +++++++ src/Payload/SelfPropagate.h | 8 + src/Payload/payload.cpp | 83 +++++++ src/Utils/Config.cpp | 164 ++++++------ src/Utils/Config.h | 60 ++--- src/Utils/RemoteInject.h | 33 +++ src/Utils/ost_log_modules.h | 1 + src/dllmain.cpp | 1 + src/dllmain.h | 79 +++--- 21 files changed, 1287 insertions(+), 527 deletions(-) create mode 100644 src/Hook/Hooks_Inject.cpp create mode 100644 src/Hook/Hooks_Inject.h create mode 100644 src/Payload/EosBridge.cpp create mode 100644 src/Payload/EosBridge.h create mode 100644 src/Payload/EosTypes.h create mode 100644 src/Payload/PayloadLog.cpp create mode 100644 src/Payload/PayloadLog.h create mode 100644 src/Payload/SelfPropagate.cpp create mode 100644 src/Payload/SelfPropagate.h create mode 100644 src/Payload/payload.cpp create mode 100644 src/Utils/RemoteInject.h diff --git a/README.md b/README.md index eaa34ae..d2aef86 100644 --- a/README.md +++ b/README.md @@ -1,225 +1,231 @@ -# OpenSteamTool - -![cpp](https://img.shields.io/badge/cpp-20%2B-green?logo=cplusplus) -![CMake](https://img.shields.io/badge/CMake-3.20%2B-green?logo=cmake) -![OnlyWindows](https://img.shields.io/badge/windows%20only-red?style=for-the-badge) - -[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/OpenSteam001/OpenSteamTool) - -OpenSteamTool is a Windows DLL project built with CMake. - -## 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. - -### 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. In `HKEY_CURRENT_USER\Software\Valve\Steam\Apps\{AppId}`, both `AppTicket` and `ETicket` are `REG_BINARY` values. -- Use `setAppTicket(appid, "hex")` and `setETicket(appid, "hex")` in Lua config to write these values to the registry automatically. -- AppTicket priority: explicit tickets have the highest priority, including tickets configured by `setAppTicket` and existing `AppTicket` registry values. If no explicit AppTicket is available, OpenSteamTool falls back to the forged local ConfigStore ticket path. -- SteamID priority: read `SteamID` as `REG_SZ` (numeric-only) first; if missing, parse from explicit `AppTicket`. - -#### Extracting tickets with `extract_tickets` - -The `extract_tickets` tool dumps the `AppTicket` and `ETicket` hex strings you need for `setAppTicket` / `setETicket`. Run it on a machine where Steam is running and logged into an account that **owns** the target game. - -1. Build the tools (see [Build](#build)); the binary lands in `build/tools/Release/extract_tickets.exe`. -2. Run it with the target AppId (or run it with no argument and type the AppId when prompted): - ```powershell - extract_tickets.exe 1361510 - ``` -3. It reads the Steam install path from the registry, loads `steamclient64.dll`, and writes everything into an `/` folder next to the executable: - - `appticket.bin` — raw app ownership ticket (binary) - - `eticket.bin` — raw encrypted app ticket (binary) - - `tickets.txt` — plain-text summary with the hex strings: - ``` - appid:1361510 - appticket(184 bytes):14000000... - eticket(143 bytes):... - ``` - A ticket that could not be obtained is reported as `appticket:null` / `eticket:null`. -4. Paste the hex strings from `tickets.txt` into your Lua config: - ```lua - setAppTicket(1361510, "14000000...") - setETicket(1361510, "...") - ``` - -> **Note:** Tickets are only valid when extracted from an account that **genuinely owns** the game. - -### Stats and Achievements -- Enable stats and achievements for unowned games. -- Uses `setStat(appid, "steamid")` to configure which SteamID's achievement data to pull. -- If no `setStat` is configured for an app, falls back to the hardcoded default 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 -- For games protected by Denuvo and SteamStub, find a safe timing to switch `GetSteamID` (see `src/Hook/Hooks_IPC.cpp#Handler_IClientUser_GetSteamID` TODO) so save files are not affected.(**Suggestions welcome — when is the earliest point after game initialization that we can safely switch the - SteamID without affecting save file binding?**) -- Steam Cloud synchronization support.(This is a huge project) -- Add Auto Denuvo Authorization Sharing for Legitimate Accounts. - -## 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 (REG_BINARY) to HKCU\Software\Valve\Steam\Apps\1361510\AppTicket - -setETicket(1361510,"0100000000000000...") -- write ETicket (REG_BINARY) to HKCU\Software\Valve\Steam\Apps\1361510\ETicket - -setStat(1361510, "76561197960287930") -- use the specified SteamID's achievement data for appid 1361510 --- If not configured, 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. - -```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 - -# 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 = [] - -# 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,Utils | -| `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 | -| `winhttp.log` | `LOG_WINHTTP_*` | HTTP requests | -| `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) | - -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 + +![cpp](https://img.shields.io/badge/cpp-20%2B-green?logo=cplusplus) +![CMake](https://img.shields.io/badge/CMake-3.20%2B-green?logo=cmake) +![OnlyWindows](https://img.shields.io/badge/windows%20only-red?style=for-the-badge) + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/OpenSteam001/OpenSteamTool) + +OpenSteamTool is a Windows DLL project built with CMake. + +## 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. + +### 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. In `HKEY_CURRENT_USER\Software\Valve\Steam\Apps\{AppId}`, both `AppTicket` and `ETicket` are `REG_BINARY` values. +- Use `setAppTicket(appid, "hex")` and `setETicket(appid, "hex")` in Lua config to write these values to the registry automatically. +- AppTicket priority: explicit tickets have the highest priority, including tickets configured by `setAppTicket` and existing `AppTicket` registry values. If no explicit AppTicket is available, OpenSteamTool falls back to the forged local ConfigStore ticket path. +- SteamID priority: read `SteamID` as `REG_SZ` (numeric-only) first; if missing, parse from explicit `AppTicket`. + +#### Extracting tickets with `extract_tickets` + +The `extract_tickets` tool dumps the `AppTicket` and `ETicket` hex strings you need for `setAppTicket` / `setETicket`. Run it on a machine where Steam is running and logged into an account that **owns** the target game. + +1. Build the tools (see [Build](#build)); the binary lands in `build/tools/Release/extract_tickets.exe`. +2. Run it with the target AppId (or run it with no argument and type the AppId when prompted): + ```powershell + extract_tickets.exe 1361510 + ``` +3. It reads the Steam install path from the registry, loads `steamclient64.dll`, and writes everything into an `/` folder next to the executable: + - `appticket.bin` — raw app ownership ticket (binary) + - `eticket.bin` — raw encrypted app ticket (binary) + - `tickets.txt` — plain-text summary with the hex strings: + ``` + appid:1361510 + appticket(184 bytes):14000000... + eticket(143 bytes):... + ``` + A ticket that could not be obtained is reported as `appticket:null` / `eticket:null`. +4. Paste the hex strings from `tickets.txt` into your Lua config: + ```lua + setAppTicket(1361510, "14000000...") + setETicket(1361510, "...") + ``` + +> **Note:** Tickets are only valid when extracted from an account that **genuinely owns** the game. + +### Stats and Achievements +- Enable stats and achievements for unowned games. +- Uses `setStat(appid, "steamid")` to configure which SteamID's achievement data to pull. +- If no `setStat` is configured for an app, falls back to the hardcoded default 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. +- Games that use EOS matchmaking instead of Steam are also supported. + +## Future +- For games protected by Denuvo and SteamStub, find a safe timing to switch `GetSteamID` (see `src/Hook/Hooks_IPC.cpp#Handler_IClientUser_GetSteamID` TODO) so save files are not affected.(**Suggestions welcome — when is the earliest point after game initialization that we can safely switch the + SteamID without affecting save file binding?**) +- Steam Cloud synchronization support.(This is a huge project) +- Add Auto Denuvo Authorization Sharing for Legitimate Accounts. + +## 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 (REG_BINARY) to HKCU\Software\Valve\Steam\Apps\1361510\AppTicket + +setETicket(1361510,"0100000000000000...") -- write ETicket (REG_BINARY) to HKCU\Software\Valve\Steam\Apps\1361510\ETicket + +setStat(1361510, "76561197960287930") -- use the specified SteamID's achievement data for appid 1361510 +-- If not configured, 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. + +```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 + +# 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 = [] + +# Optional metadata mirror. See "Steam version compatibility" below. +[remote] +# url_template = "https://your.server/{channel}/{component}/{sha256}.toml" + +# Disable in-game payload injection used by -onlinefix (EOS matchmaking). +# The 480 AppId swap for Steam-matchmaking games is unaffected. +[inject] +enabled = true +``` + +### 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,Utils | +| `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 | +| `winhttp.log` | `LOG_WINHTTP_*` | HTTP requests | +| `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) | + +The log level is controlled by `[log] level` in `opensteamtool.toml`. + +## Build + +### Requirements +- Windows 10/11 +- CMake 3.20+ +- Visual Studio 2022 with MSVC (x64 toolchain) + +### Runtime requirements +- Outbound HTTPS access to `raw.githubusercontent.com` on first launch after a Steam update (see [Steam version compatibility](#steam-version-compatibility)). Cached afterwards. + +### Quick build +```powershell +build.bat +``` + +### Output +- Debug: `build/Debug/OpenSteamTool.dll`, `build/Debug/dwmapi.dll`, `build/Debug/xinput1_4.dll` +- Release: `build/Release/OpenSteamTool.dll`, `build/Release/dwmapi.dll`, `build/Release/xinput1_4.dll` + +## Disclaimer +This project is provided for research and educational purposes only. You are responsible for complying with local laws, platform terms of service, and software licenses. diff --git a/opensteamtool.example.toml b/opensteamtool.example.toml index b456ffb..827d208 100644 --- a/opensteamtool.example.toml +++ b/opensteamtool.example.toml @@ -72,3 +72,9 @@ timeout_recv_ms = 10000 # # url_template = "https://your.server/{channel}/{component}/{sha256}.toml" # url_template = "https://fast.jsdelivr.net/gh/OpenSteam001/steam-monitor@{channel}/{component}/{sha256}.toml" + +[inject] +# In-game payload injected by -onlinefix for games whose multiplayer runs on +# EOS matchmaking instead of Steam. Set to false to disable the +# payload entirely; the 480 AppId swap for Steam-matchmaking games still works. +enabled = true diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4e9bb68..d7dfe67 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,154 +1,173 @@ -cmake_minimum_required(VERSION 3.20) -project(OpenSteamTool VERSION 1.0.0 LANGUAGES C CXX) - -set(CMAKE_CXX_STANDARD 20) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_C_STANDARD 11) - -# Allow CMAKE_MSVC_RUNTIME_LIBRARY to control runtime selection for all targets, -# including dependencies pulled in via FetchContent. -if(POLICY CMP0091) - cmake_policy(SET CMP0091 NEW) -endif() - -# Static MSVC runtime everywhere, so the resulting DLL has no extra runtime -# dependencies. Must be set BEFORE FetchContent_MakeAvailable so the fetched -# deps (Lua, Detours, spdlog) inherit it. -set(CMAKE_CONFIGURATION_TYPES "Debug;Release" CACHE STRING "" FORCE) -set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") -set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) - -# --------------------------------------------------------------------------- -# Dependency recipes (FetchContent-backed, cached at /.deps). -# --------------------------------------------------------------------------- -list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") -set(OPENSTEAMTOOL_TOOLS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../tools") -include(Lua) -include(Detours) -include(Spdlog) -include(Protobuf) -include(Tomlplusplus) -include(LogMacros) - -# Build the host code generators (ipc_codegen) in their own binary subtree so -# their executables never land in the shipped output directory. EXCLUDE_FROM_ALL -# means only the tools actually depended on (ipc_codegen) get built here. -add_subdirectory("${OPENSTEAMTOOL_TOOLS_DIR}" "${CMAKE_BINARY_DIR}/tools" EXCLUDE_FROM_ALL) -include("${OPENSTEAMTOOL_TOOLS_DIR}/cmake/IPCCodegen.cmake") - -set(IPC_IDL "${CMAKE_CURRENT_SOURCE_DIR}/Steam/IPCMessages.steamd") -# Per-config output dir, mirroring the protobuf recipe below: a Multi-Config -# build invokes the generator once per config, and a single shared output file -# would ping-pong between the Release/Debug graphs and force needless rebuilds. -opensteamtool_add_ipc_codegen(IPC_GEN - IDL "${IPC_IDL}" - CPP_OUT "${CMAKE_CURRENT_BINARY_DIR}/generated/$" -) - -# --------------------------------------------------------------------------- -# Protobuf code generation — one variant per config, from the same .proto: -# -# Debug → full Message (protoc --cpp_out) → links libprotobuf -# Release → lite MessageLite (protoc --cpp_out=lite:) → links libprotobuf-lite -# -# Output lands in the per-config generated/ dir (same root as the IPC header), -# so each config owns its own files. A shared output path would ping-pong -# between the Release/Debug graphs (the command embeds the config-specific -# protoc path) and force a regen + recompile on every config switch. -# --------------------------------------------------------------------------- -set(PROTO_SRC "${CMAKE_CURRENT_SOURCE_DIR}/proto/steam_messages.proto") -set(PROTO_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated/$") - -add_custom_command( - OUTPUT "${PROTO_GEN_DIR}/steam_messages.pb.cc" - "${PROTO_GEN_DIR}/steam_messages.pb.h" - COMMAND ${CMAKE_COMMAND} -E make_directory "${PROTO_GEN_DIR}" - COMMAND $ - "--cpp_out=$<$:lite:>${PROTO_GEN_DIR}" - "-I${CMAKE_CURRENT_SOURCE_DIR}/proto" - "${PROTO_SRC}" - DEPENDS "${PROTO_SRC}" protoc - COMMENT "Generating protobuf C++ sources" - VERBATIM -) - -# --------------------------------------------------------------------------- -# OpenSteamTool — the hook DLL injected into Steam (always 64-bit). -# --------------------------------------------------------------------------- -add_library(OpenSteamTool SHARED - dllmain.cpp - - # Shared utilities - Utils/AppTicket.cpp - Utils/ByteSearch.cpp - Utils/PatternLoader.cpp - Utils/Log.cpp - Utils/Config.cpp - Utils/LuaConfig.cpp - Utils/VehCommon.cpp - Utils/WinHttp.cpp - Utils/FileWatcher.cpp - Utils/ManifestClient.cpp - Utils/RemoteToml.cpp - Utils/IPCLoader.cpp - Utils/SteamDiagnostics.cpp - - # Generated IPC message structs (depends on IPCMessages.steamd via add_custom_command) - "${IPC_GEN}" - - # Per-category hook modules - Hook/HookManager.cpp - Hook/Hooks_CallBack.cpp - Hook/Hooks_Decryption.cpp - Hook/Hooks_IPC.cpp - Hook/Hooks_IPC_ISteamUser.cpp - Hook/Hooks_IPC_ISteamUtils.cpp - Hook/Hooks_KeyValues.cpp - Hook/Hooks_Manifest.cpp - Hook/Hooks_Misc.cpp - Hook/Hooks_NetPacket.cpp - Hook/PendingAPICalls.cpp - Hook/Hooks_SteamUI.cpp - Hook/Hooks_Package.cpp - - # protobuf generated sources (full in Debug, lite in Release) - "${PROTO_GEN_DIR}/steam_messages.pb.cc" -) - -# Header search path — per-config include directory -target_include_directories(OpenSteamTool PRIVATE - ${CMAKE_CURRENT_SOURCE_DIR} - ${CMAKE_CURRENT_BINARY_DIR}/generated - ${CMAKE_CURRENT_BINARY_DIR}/generated/$ -) - -target_link_libraries(OpenSteamTool PRIVATE - lua_static - detours - winhttp - Bcrypt - $<$:libprotobuf> - $<$:libprotobuf-lite> - tomlplusplus::tomlplusplus - $<$:spdlog::spdlog> -) - -# Logging is compiled in only for Debug; Release reduces LOG_* to no-ops. -target_compile_definitions(OpenSteamTool PRIVATE - $<$:OPENSTEAMTOOL_LOGGING_ENABLED> -) - -# --------------------------------------------------------------------------- -# dwmapi.dll hijack — small loader DLL placed alongside Steam. -# --------------------------------------------------------------------------- -add_library(dwmapi SHARED - dwmapi/dwmapi.cpp -) - -# --------------------------------------------------------------------------- -# xinput1_4.dll hijack — secondary loader DLL placed alongside Steam. -# --------------------------------------------------------------------------- -add_library(xinput1_4 SHARED - xinput1_4/xinput1_4.cpp - xinput1_4/xinput1_4.def -) +cmake_minimum_required(VERSION 3.20) +project(OpenSteamTool VERSION 1.0.0 LANGUAGES C CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_C_STANDARD 11) + +# Allow CMAKE_MSVC_RUNTIME_LIBRARY to control runtime selection for all targets, +# including dependencies pulled in via FetchContent. +if(POLICY CMP0091) + cmake_policy(SET CMP0091 NEW) +endif() + +# Static MSVC runtime everywhere, so the resulting DLL has no extra runtime +# dependencies. Must be set BEFORE FetchContent_MakeAvailable so the fetched +# deps (Lua, Detours, spdlog) inherit it. +set(CMAKE_CONFIGURATION_TYPES "Debug;Release" CACHE STRING "" FORCE) +set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + +# --------------------------------------------------------------------------- +# Dependency recipes (FetchContent-backed, cached at /.deps). +# --------------------------------------------------------------------------- +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") +set(OPENSTEAMTOOL_TOOLS_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../tools") +include(Lua) +include(Detours) +include(Spdlog) +include(Protobuf) +include(Tomlplusplus) +include(LogMacros) + +# Build the host code generators (ipc_codegen) in their own binary subtree so +# their executables never land in the shipped output directory. EXCLUDE_FROM_ALL +# means only the tools actually depended on (ipc_codegen) get built here. +add_subdirectory("${OPENSTEAMTOOL_TOOLS_DIR}" "${CMAKE_BINARY_DIR}/tools" EXCLUDE_FROM_ALL) +include("${OPENSTEAMTOOL_TOOLS_DIR}/cmake/IPCCodegen.cmake") + +set(IPC_IDL "${CMAKE_CURRENT_SOURCE_DIR}/Steam/IPCMessages.steamd") +# Per-config output dir, mirroring the protobuf recipe below: a Multi-Config +# build invokes the generator once per config, and a single shared output file +# would ping-pong between the Release/Debug graphs and force needless rebuilds. +opensteamtool_add_ipc_codegen(IPC_GEN + IDL "${IPC_IDL}" + CPP_OUT "${CMAKE_CURRENT_BINARY_DIR}/generated/$" +) + +# --------------------------------------------------------------------------- +# Protobuf code generation — one variant per config, from the same .proto: +# +# Debug → full Message (protoc --cpp_out) → links libprotobuf +# Release → lite MessageLite (protoc --cpp_out=lite:) → links libprotobuf-lite +# +# Output lands in the per-config generated/ dir (same root as the IPC header), +# so each config owns its own files. A shared output path would ping-pong +# between the Release/Debug graphs (the command embeds the config-specific +# protoc path) and force a regen + recompile on every config switch. +# --------------------------------------------------------------------------- +set(PROTO_SRC "${CMAKE_CURRENT_SOURCE_DIR}/proto/steam_messages.proto") +set(PROTO_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/generated/$") + +add_custom_command( + OUTPUT "${PROTO_GEN_DIR}/steam_messages.pb.cc" + "${PROTO_GEN_DIR}/steam_messages.pb.h" + COMMAND ${CMAKE_COMMAND} -E make_directory "${PROTO_GEN_DIR}" + COMMAND $ + "--cpp_out=$<$:lite:>${PROTO_GEN_DIR}" + "-I${CMAKE_CURRENT_SOURCE_DIR}/proto" + "${PROTO_SRC}" + DEPENDS "${PROTO_SRC}" protoc + COMMENT "Generating protobuf C++ sources" + VERBATIM +) + +# --------------------------------------------------------------------------- +# OpenSteamTool — the hook DLL injected into Steam (always 64-bit). +# --------------------------------------------------------------------------- +add_library(OpenSteamTool SHARED + dllmain.cpp + + # Shared utilities + Utils/AppTicket.cpp + Utils/ByteSearch.cpp + Utils/PatternLoader.cpp + Utils/Log.cpp + Utils/Config.cpp + Utils/LuaConfig.cpp + Utils/VehCommon.cpp + Utils/WinHttp.cpp + Utils/FileWatcher.cpp + Utils/ManifestClient.cpp + Utils/RemoteToml.cpp + Utils/IPCLoader.cpp + Utils/SteamDiagnostics.cpp + + # Generated IPC message structs (depends on IPCMessages.steamd via add_custom_command) + "${IPC_GEN}" + + # Per-category hook modules + Hook/HookManager.cpp + Hook/Hooks_CallBack.cpp + Hook/Hooks_Decryption.cpp + Hook/Hooks_Inject.cpp + Hook/Hooks_IPC.cpp + Hook/Hooks_IPC_ISteamUser.cpp + Hook/Hooks_IPC_ISteamUtils.cpp + Hook/Hooks_KeyValues.cpp + Hook/Hooks_Manifest.cpp + Hook/Hooks_Misc.cpp + Hook/Hooks_NetPacket.cpp + Hook/PendingAPICalls.cpp + Hook/Hooks_SteamUI.cpp + Hook/Hooks_Package.cpp + + # protobuf generated sources (full in Debug, lite in Release) + "${PROTO_GEN_DIR}/steam_messages.pb.cc" +) + +# Header search path — per-config include directory +target_include_directories(OpenSteamTool PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_BINARY_DIR}/generated + ${CMAKE_CURRENT_BINARY_DIR}/generated/$ +) + +target_link_libraries(OpenSteamTool PRIVATE + lua_static + detours + winhttp + Bcrypt + $<$:libprotobuf> + $<$:libprotobuf-lite> + tomlplusplus::tomlplusplus + $<$:spdlog::spdlog> +) + +# Logging is compiled in only for Debug; Release reduces LOG_* to no-ops. +target_compile_definitions(OpenSteamTool PRIVATE + $<$:OPENSTEAMTOOL_LOGGING_ENABLED> +) + +# --------------------------------------------------------------------------- +# dwmapi.dll hijack — small loader DLL placed alongside Steam. +# --------------------------------------------------------------------------- +add_library(dwmapi SHARED + dwmapi/dwmapi.cpp +) + +# --------------------------------------------------------------------------- +# xinput1_4.dll hijack — secondary loader DLL placed alongside Steam. +# --------------------------------------------------------------------------- +add_library(xinput1_4 SHARED + xinput1_4/xinput1_4.cpp + xinput1_4/xinput1_4.def +) + +# --------------------------------------------------------------------------- +# OnlineFix — DLL injected into a game launched with -onlinefix +# --------------------------------------------------------------------------- +add_library(OnlineFix SHARED + Payload/payload.cpp + Payload/PayloadLog.cpp + Payload/EosBridge.cpp + Payload/SelfPropagate.cpp +) +set_target_properties(OnlineFix PROPERTIES PREFIX "" OUTPUT_NAME "OnlineFix") +target_include_directories(OnlineFix PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(OnlineFix PRIVATE detours psapi) + +# Logging is compiled in only for Debug, same as the host DLL. +target_compile_definitions(OnlineFix PRIVATE + $<$:OPENSTEAMTOOL_LOGGING_ENABLED> +) diff --git a/src/Hook/HookManager.cpp b/src/Hook/HookManager.cpp index 0d6bc63..f16beac 100644 --- a/src/Hook/HookManager.cpp +++ b/src/Hook/HookManager.cpp @@ -1,6 +1,7 @@ #include "HookManager.h" #include "Hooks_CallBack.h" #include "Hooks_Decryption.h" +#include "Hooks_Inject.h" #include "Hooks_IPC.h" #include "Hooks_KeyValues.h" #include "Hooks_Manifest.h" @@ -26,6 +27,7 @@ namespace SteamClient { void CoreHook() { Hooks_CallBack::Install(); Hooks_Decryption::Install(); + Hooks_Inject::Install(); Hooks_IPC::Install(); // Hooks_KeyValues::Install(); Hooks_Manifest::Install(); @@ -37,6 +39,7 @@ namespace SteamClient { void CoreUnhook() { Hooks_CallBack::Uninstall(); Hooks_Decryption::Uninstall(); + Hooks_Inject::Uninstall(); Hooks_IPC::Uninstall(); // Hooks_KeyValues::Uninstall(); Hooks_Manifest::Uninstall(); diff --git a/src/Hook/Hooks_Inject.cpp b/src/Hook/Hooks_Inject.cpp new file mode 100644 index 0000000..be35b62 --- /dev/null +++ b/src/Hook/Hooks_Inject.cpp @@ -0,0 +1,179 @@ +#include "Hooks_Inject.h" +#include "HookMacros.h" +#include "Utils/RemoteInject.h" + +#include +#include +#include +#include +#include + +namespace { + + // Lowercased exe basename → real AppId: filled by QueueInjection (from the + // SpawnProcess hook), consumed by the CreateProcessW hook. Keyed by basename + // because Steam runs the real spawn on a different thread than the hook that + // sees the launch, so there's nothing else to correlate the two by. + std::mutex g_pendingMutex; + std::unordered_map g_pending; + + std::wstring LowerBasename(LPCWSTR path) { + if (!path || !*path) return {}; + std::wstring name = std::filesystem::path(path).filename().wstring(); + std::transform(name.begin(), name.end(), name.begin(), + [](wchar_t c){ return static_cast(towlower(c)); }); + return name; + } + + // lpApplicationName is often null and the exe is the first token of + // lpCommandLine instead. Peel it off (quoted or unquoted) so we can + // still derive a basename in that case. + std::wstring ExeFromCmd(LPCWSTR cmd) { + if (!cmd) return {}; + while (*cmd == L' ' || *cmd == L'\t') ++cmd; + std::wstring out; + if (*cmd == L'"') { + for (++cmd; *cmd && *cmd != L'"'; ++cmd) out.push_back(*cmd); + } else { + for (; *cmd && *cmd != L' ' && *cmd != L'\t'; ++cmd) out.push_back(*cmd); + } + return out; + } + + AppId_t ClaimPending(LPCWSTR app, LPCWSTR cmd) { + std::wstring key = LowerBasename(app); + if (key.empty()) key = LowerBasename(ExeFromCmd(cmd).c_str()); + if (key.empty()) return 0; + + std::lock_guard lk(g_pendingMutex); + auto it = g_pending.find(key); + if (it == g_pending.end()) return 0; + AppId_t id = it->second; + g_pending.erase(it); + return id; + } + + // ── CreateProcessW / CreateProcessAsUserW hooks ──────────────────── + using CreateProcessW_t = BOOL(WINAPI*)(LPCWSTR, LPWSTR, LPSECURITY_ATTRIBUTES, + LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCWSTR, + LPSTARTUPINFOW, LPPROCESS_INFORMATION); + using CreateProcessAsUserW_t = BOOL(WINAPI*)(HANDLE, LPCWSTR, LPWSTR, + LPSECURITY_ATTRIBUTES, LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, + LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION); + + CreateProcessW_t oCreateProcessW = nullptr; + CreateProcessAsUserW_t oCreateProcessAsUserW = nullptr; + + BOOL Spawn(HANDLE token, LPCWSTR app, LPWSTR cmd, LPSECURITY_ATTRIBUTES pa, + LPSECURITY_ATTRIBUTES ta, BOOL inherit, DWORD flags, LPVOID env, + LPCWSTR cwd, LPSTARTUPINFOW si, LPPROCESS_INFORMATION pi) + { + auto fwd = [&](DWORD f) { + return token + ? oCreateProcessAsUserW(token, app, cmd, pa, ta, inherit, f, env, cwd, si, pi) + : oCreateProcessW(app, cmd, pa, ta, inherit, f, env, cwd, si, pi); + }; + + AppId_t appId = ClaimPending(app, cmd); + if (!appId || PayloadPath[0] == 0) return fwd(flags); + + // Start suspended so the payload loads before the game's first + // instruction, then resume unless the caller already wanted it held. + BOOL ok = fwd(flags | CREATE_SUSPENDED); + if (!ok) { + LOG_INJECT_WARN("appid={} spawn failed err={}", appId, GetLastError()); + return ok; + } + + wchar_t wpayload[MAX_PATH] = {}; + MultiByteToWideChar(CP_ACP, 0, PayloadPath, -1, wpayload, MAX_PATH); + const bool injected = RemoteInject::LoadDll(pi->hProcess, wpayload); + LOG_INJECT_INFO("appid={} pid={} payload {}", appId, pi->dwProcessId, + injected ? "loaded" : "FAILED"); + + // Injection failure must never bring the game down with us. + if (!(flags & CREATE_SUSPENDED)) ResumeThread(pi->hThread); + return ok; + } + + BOOL WINAPI hkCreateProcessW(LPCWSTR app, LPWSTR cmd, LPSECURITY_ATTRIBUTES pa, + LPSECURITY_ATTRIBUTES ta, BOOL inherit, DWORD flags, LPVOID env, + LPCWSTR cwd, LPSTARTUPINFOW si, LPPROCESS_INFORMATION pi) + { + return Spawn(nullptr, app, cmd, pa, ta, inherit, flags, env, cwd, si, pi); + } + + BOOL WINAPI hkCreateProcessAsUserW(HANDLE token, LPCWSTR app, LPWSTR cmd, + LPSECURITY_ATTRIBUTES pa, LPSECURITY_ATTRIBUTES ta, BOOL inherit, DWORD flags, + LPVOID env, LPCWSTR cwd, LPSTARTUPINFOW si, LPPROCESS_INFORMATION pi) + { + return Spawn(token, app, cmd, pa, ta, inherit, flags, env, cwd, si, pi); + } + +#ifdef OPENSTEAMTOOL_LOGGING_ENABLED + // Each injected game process writes its own .log here. Wipe the + // folder once per Steam session so the files don't pile up forever. + void ResetPayloadLogs() { + std::error_code ec; + auto dir = std::filesystem::path(Config::logDir) / "payload"; + std::filesystem::remove_all(dir, ec); + std::filesystem::create_directories(dir, ec); + } +#endif +} + +namespace Hooks_Inject { + void Install() { + if (!Config::injectEnabled) { + LOG_INJECT_INFO("payload injection disabled via config"); + return; + } +#ifdef OPENSTEAMTOOL_LOGGING_ENABLED + ResetPayloadLogs(); +#endif + HMODULE k32 = GetModuleHandleW(L"kernel32.dll"); + if (!k32) return; + oCreateProcessW = reinterpret_cast (GetProcAddress(k32, "CreateProcessW")); + oCreateProcessAsUserW = reinterpret_cast(GetProcAddress(k32, "CreateProcessAsUserW")); + + HOOK_BEGIN(); + if (oCreateProcessW) + DetourAttach(reinterpret_cast(&oCreateProcessW), + reinterpret_cast(hkCreateProcessW)); + if (oCreateProcessAsUserW) + DetourAttach(reinterpret_cast(&oCreateProcessAsUserW), + reinterpret_cast(hkCreateProcessAsUserW)); + HOOK_END(); + LOG_INJECT_INFO("spawn hooks installed dll=\"{}\"", PayloadPath); + } + + void Uninstall() { + UNHOOK_BEGIN(); + if (oCreateProcessW) { + DetourDetach(reinterpret_cast(&oCreateProcessW), + reinterpret_cast(hkCreateProcessW)); + oCreateProcessW = nullptr; + } + if (oCreateProcessAsUserW) { + DetourDetach(reinterpret_cast(&oCreateProcessAsUserW), + reinterpret_cast(hkCreateProcessAsUserW)); + oCreateProcessAsUserW = nullptr; + } + UNHOOK_END(); + + std::lock_guard lk(g_pendingMutex); + g_pending.clear(); + } + + void QueueInjection(const char* exePath, AppId_t realAppId) { + if (!Config::injectEnabled || !realAppId || !exePath || !*exePath) return; + + wchar_t wexe[MAX_PATH] = {}; + MultiByteToWideChar(CP_UTF8, 0, exePath, -1, wexe, MAX_PATH); + std::wstring key = LowerBasename(wexe); + if (key.empty()) return; + + std::lock_guard lk(g_pendingMutex); + g_pending[key] = realAppId; + } +} diff --git a/src/Hook/Hooks_Inject.h b/src/Hook/Hooks_Inject.h new file mode 100644 index 0000000..fb35ad1 --- /dev/null +++ b/src/Hook/Hooks_Inject.h @@ -0,0 +1,10 @@ +#pragma once + +#include "dllmain.h" + +namespace Hooks_Inject { + void Install(); + void Uninstall(); + + void QueueInjection(const char* exePath, AppId_t realAppId); +} diff --git a/src/Hook/Hooks_Misc.cpp b/src/Hook/Hooks_Misc.cpp index 97ed029..6d79c19 100644 --- a/src/Hook/Hooks_Misc.cpp +++ b/src/Hook/Hooks_Misc.cpp @@ -1,4 +1,5 @@ #include "Hooks_Misc.h" +#include "Hooks_Inject.h" #include "HookMacros.h" #include "Utils/VehCommon.h" #include "dllmain.h" @@ -26,13 +27,15 @@ namespace { static void OnSpawnProcessHit(PCONTEXT ctx, const VehCommon::Int3Site& /*site*/) { CGameID* pGameID = VehCommon::GetArg(ctx, 5); AppId_t appId = static_cast(pGameID->AppID(true)); + const char* exePath = VehCommon::GetArg(ctx, 2); const char* cmdLine = VehCommon::GetArg(ctx, 3); - if (LuaConfig::HasDepot(appId) && cmdLine && strstr(cmdLine, "-onlinefix")) + if (LuaConfig::HasDepot(appId) && cmdLine && strstr(cmdLine, "-onlinefix")) { g_OnlineFixRealAppId = appId; pGameID->SetAppID(kOnlineFixAppId); LOG_MISC_INFO("SpawnProcess: appid {} -> {}, cmd=\"{}\"",appId, kOnlineFixAppId, cmdLine); + Hooks_Inject::QueueInjection(exePath, appId); } else { g_OnlineFixRealAppId = 0; } diff --git a/src/Payload/EosBridge.cpp b/src/Payload/EosBridge.cpp new file mode 100644 index 0000000..ebec59a --- /dev/null +++ b/src/Payload/EosBridge.cpp @@ -0,0 +1,159 @@ +// ───────────────────────────────────────────────────────────────── +// Not every game sold on Steam runs its multiplayer through Steam. Some +// ship a third-party online layer. Most such games use EOS which loads +// inside the game process and talks to its own servers; Steam is only +// the launcher. The Steam AppId 480 trick doesn't work since it never sees that traffic, +// so the only way we can make these games work is in-process, i.e. right next to the SDK. +// That's what this DLL is for. +// +// Regular EOS works like this: the game logs into EOS with a Steam session ticket +// (credential type 18). The server validates that ticket against a genuine +// Steam license for the app, and so it fails the moment ownership isn't +// real. We try to sidestep ownership entirely by redirecting the Connect login +// to an anonymous Device ID auth, which carries no account or entitlement check. +// The display name is still read from Steam so friends see a real name. +// +// Device ID auth has no Epic presence behind it, so the lobby hooks +// below drop the presence requirement that would otherwise reject us. +// ───────────────────────────────────────────────────────────────── + +#include "EosBridge.h" +#include "EosTypes.h" +#include "PayloadLog.h" + +#include +#include +#include + +namespace { + std::atomic_bool g_installed{false}; + + EOS_Connect_Login_t oLogin = nullptr; + EOS_Connect_CreateDeviceId_t oCreateDeviceId = nullptr; + EOS_IPOContainer_Add_t oIPOAdd = nullptr; + EOS_Lobby_OpFn_t oCreateLobby = nullptr; + EOS_Lobby_OpFn_t oJoinLobby = nullptr; + EOS_Lobby_OpFn_t oJoinLobbyById = nullptr; + + // Spans Connect_Login -> CreateDeviceId -> Login. Freed in the final + // Login callback (success) or the device-id callback (failure path). + struct LoginCtx { + EOS_HConnect handle; + EOS_Connect_OnLoginCb cb; + void* cbData; + std::string displayName; + }; + + // EOS Device ID login requires a display name. Steam is already loaded + // in the game process by the time EOS comes up, so walk steam_api. + std::string SteamPersonaName() { + HMODULE sa = GetModuleHandleW(L"steam_api64.dll"); + if (!sa) sa = GetModuleHandleW(L"steam_api.dll"); + + auto pFriends = sa ? reinterpret_cast(GetProcAddress(sa, "SteamFriends")) : nullptr; + auto pName = sa ? reinterpret_cast(GetProcAddress(sa, "SteamAPI_ISteamFriends_GetPersonaName")) : nullptr; + + void* friends = pFriends ? pFriends() : nullptr; + const char* name = (pName && friends) ? pName(friends) : nullptr; + return (name && *name) ? name : "Unknown Player"; + } + + void OnLoginDone(const EOS_Connect_LoginCallbackInfo* info) { + auto* ctx = static_cast(info->ClientData); + EOS_Connect_LoginCallbackInfo out = *info; + out.ClientData = ctx->cbData; + if (ctx->cb) ctx->cb(&out); + delete ctx; + } + + void OnCreateDeviceIdDone(const EOS_Connect_CreateDeviceIdCallbackInfo* info) { + auto* ctx = static_cast(info->ClientData); + const bool ready = info->ResultCode == EOS_Success + || info->ResultCode == EOS_DuplicateNotAllowed; + if (!ready) { + EOS_Connect_LoginCallbackInfo fail = {}; + fail.ResultCode = info->ResultCode; + fail.ClientData = ctx->cbData; + if (ctx->cb) ctx->cb(&fail); + delete ctx; + return; + } + + EOS_Connect_Credentials creds{ 1, nullptr, EOS_ECT_DEVICEID_ACCESS_TOKEN }; + EOS_Connect_UserLoginInfo who { 1, ctx->displayName.c_str() }; + EOS_Connect_LoginOptions opts { 2, &creds, &who }; + oLogin(ctx->handle, &opts, ctx, OnLoginDone); + } + + void hkLogin(EOS_HConnect h, const EOS_Connect_LoginOptions*, + void* cbData, EOS_Connect_OnLoginCb cb) + { + auto* ctx = new LoginCtx{ h, cb, cbData, SteamPersonaName() }; + EOS_Connect_CreateDeviceIdOptions create{ 1, "PC" }; + oCreateDeviceId(h, &create, ctx, OnCreateDeviceIdDone); + } + + EOS_EResult hkIPOAdd(EOS_HIntegratedPlatformOptionsContainer, const void*) { + return EOS_Success; + } + + // bPresenceEnabled requires an Epic account we don't have. The field + // offset differs per struct because the preceding members differ. + void StripPresence(const void* opts, size_t flagOffset, int32_t minApiVer) { + if (!opts) return; + if (*reinterpret_cast(opts) < minApiVer) return; + auto* flag = reinterpret_cast( + reinterpret_cast(opts) + flagOffset); + if (*flag) *flag = 0; + } + + void hkCreateLobby(EOS_HLobby h, const void* opts, void* cd, void* cb) { + StripPresence(opts, offsetof(EOS_Lobby_CreateLobbyOptions_Partial, bPresenceEnabled), 2); + oCreateLobby(h, opts, cd, cb); + } + void hkJoinLobby(EOS_HLobby h, const void* opts, void* cd, void* cb) { + StripPresence(opts, offsetof(EOS_Lobby_JoinLobbyOptions_Partial, bPresenceEnabled), 2); + oJoinLobby(h, opts, cd, cb); + } + void hkJoinLobbyById(EOS_HLobby h, const void* opts, void* cd, void* cb) { + StripPresence(opts, offsetof(EOS_Lobby_JoinLobbyByIdOptions_Partial, bPresenceEnabled), 1); + oJoinLobbyById(h, opts, cd, cb); + } + + template + bool Resolve(HMODULE m, const char* name, Fn& slot) { + slot = reinterpret_cast(GetProcAddress(m, name)); + if (!slot) PayloadLog::Write(std::string("missing EOS export: ") + name); + return slot != nullptr; + } +} + +namespace EosBridge { + void InstallOn(HMODULE eos) { + bool expected = false; + if (!eos || !g_installed.compare_exchange_strong(expected, true)) return; + + bool ok = Resolve(eos, "EOS_Connect_Login", oLogin) + & Resolve(eos, "EOS_Connect_CreateDeviceId", oCreateDeviceId) + & Resolve(eos, "EOS_IntegratedPlatformOptionsContainer_Add", oIPOAdd) + & Resolve(eos, "EOS_Lobby_CreateLobby", oCreateLobby) + & Resolve(eos, "EOS_Lobby_JoinLobby", oJoinLobby) + & Resolve(eos, "EOS_Lobby_JoinLobbyById", oJoinLobbyById); + if (!ok) { g_installed.store(false); return; } + + DetourTransactionBegin(); + DetourUpdateThread(GetCurrentThread()); + DetourAttach(reinterpret_cast(&oLogin), reinterpret_cast(hkLogin)); + DetourAttach(reinterpret_cast(&oIPOAdd), reinterpret_cast(hkIPOAdd)); + DetourAttach(reinterpret_cast(&oCreateLobby), reinterpret_cast(hkCreateLobby)); + DetourAttach(reinterpret_cast(&oJoinLobby), reinterpret_cast(hkJoinLobby)); + DetourAttach(reinterpret_cast(&oJoinLobbyById), reinterpret_cast(hkJoinLobbyById)); + LONG err = DetourTransactionCommit(); + if (err != NO_ERROR) { + PayloadLog::Write("DetourTransactionCommit failed err=" + std::to_string(err)); + g_installed.store(false); + return; + } + PayloadLog::Write("EOS hooks installed"); + } +} diff --git a/src/Payload/EosBridge.h b/src/Payload/EosBridge.h new file mode 100644 index 0000000..7c61de4 --- /dev/null +++ b/src/Payload/EosBridge.h @@ -0,0 +1,8 @@ +#pragma once + +#include + +// Patches EOSSDK-Win64-Shipping.dll so EOS multiplayer works. +namespace EosBridge { + void InstallOn(HMODULE eosModule); // InstallOn more than once is safe. +} diff --git a/src/Payload/EosTypes.h b/src/Payload/EosTypes.h new file mode 100644 index 0000000..909a7cd --- /dev/null +++ b/src/Payload/EosTypes.h @@ -0,0 +1,85 @@ +#pragma once + +#include + +// ───────────────────────────────────────────────────────────────── +// Minimal mirror of the EOS SDK structs, holding only the fields +// we actually read or write. Each struct starts with an ApiVersion we +// check at runtime, ensuring compatibility across SDK versions. +// ───────────────────────────────────────────────────────────────── + +using EOS_EResult = int32_t; +using EOS_Bool = int32_t; +using EOS_HConnect = void*; +using EOS_HLobby = void*; +using EOS_HIntegratedPlatformOptionsContainer = void*; +using EOS_ProductUserId = void*; +using EOS_ContinuanceToken = void*; + +constexpr EOS_EResult EOS_Success = 0; +constexpr EOS_EResult EOS_DuplicateNotAllowed = 24; // device id already exists +constexpr int32_t EOS_ECT_DEVICEID_ACCESS_TOKEN = 10; + +#pragma pack(push, 8) + +struct EOS_Connect_Credentials { + int32_t ApiVersion; + const char* Token; + int32_t Type; +}; +struct EOS_Connect_UserLoginInfo { + int32_t ApiVersion; + const char* DisplayName; +}; +struct EOS_Connect_LoginOptions { + int32_t ApiVersion; + const EOS_Connect_Credentials* Credentials; + const EOS_Connect_UserLoginInfo* UserLoginInfo; +}; +struct EOS_Connect_LoginCallbackInfo { + EOS_EResult ResultCode; + void* ClientData; + EOS_ProductUserId LocalUserId; + EOS_ContinuanceToken ContinuanceToken; +}; +struct EOS_Connect_CreateDeviceIdOptions { + int32_t ApiVersion; + const char* DeviceModel; +}; +struct EOS_Connect_CreateDeviceIdCallbackInfo { + EOS_EResult ResultCode; + void* ClientData; +}; + +// Lobby option prefixes up to and including bPresenceEnabled; everything +// past that we leave untouched by passing the original pointer through. +struct EOS_Lobby_CreateLobbyOptions_Partial { + int32_t ApiVersion; + EOS_ProductUserId LocalUserId; + uint32_t MaxLobbyMembers; + int32_t PermissionLevel; + EOS_Bool bPresenceEnabled; // v2+ +}; +struct EOS_Lobby_JoinLobbyOptions_Partial { + int32_t ApiVersion; + void* LobbyDetailsHandle; + EOS_ProductUserId LocalUserId; + EOS_Bool bPresenceEnabled; // v2+ +}; +struct EOS_Lobby_JoinLobbyByIdOptions_Partial { + int32_t ApiVersion; + const char* LobbyId; + EOS_ProductUserId LocalUserId; + EOS_Bool bPresenceEnabled; // v1+ +}; + +#pragma pack(pop) + +using EOS_Connect_OnLoginCb = void(*)(const EOS_Connect_LoginCallbackInfo*); +using EOS_Connect_OnCreateDeviceIdCb = void(*)(const EOS_Connect_CreateDeviceIdCallbackInfo*); + +using EOS_Connect_Login_t = void(*)(EOS_HConnect, const EOS_Connect_LoginOptions*, void*, EOS_Connect_OnLoginCb); +using EOS_Connect_CreateDeviceId_t = void(*)(EOS_HConnect, const EOS_Connect_CreateDeviceIdOptions*, void*, EOS_Connect_OnCreateDeviceIdCb); +using EOS_IPOContainer_Add_t = EOS_EResult(*)(EOS_HIntegratedPlatformOptionsContainer, const void*); +// CreateLobby / JoinLobby / JoinLobbyById share shape (handle, opts, cd, completion). +using EOS_Lobby_OpFn_t = void(*)(EOS_HLobby, const void*, void*, void*); diff --git a/src/Payload/PayloadLog.cpp b/src/Payload/PayloadLog.cpp new file mode 100644 index 0000000..fbd66b4 --- /dev/null +++ b/src/Payload/PayloadLog.cpp @@ -0,0 +1,44 @@ +#include "PayloadLog.h" + +#ifdef OPENSTEAMTOOL_LOGGING_ENABLED + +#include +#include +#include +#include +#include + +namespace { + std::filesystem::path g_path; + std::mutex g_mutex; + std::atomic_bool g_ready{false}; +} + +namespace PayloadLog { + void Init(HMODULE self) { + // The DLL sits in Steam's root, so this lands beside the host's logs. + wchar_t dll[MAX_PATH] = {}; + if (!GetModuleFileNameW(self, dll, MAX_PATH)) return; + auto dir = std::filesystem::path(dll).parent_path() / "opensteamtool" / "payload"; + + std::error_code ec; + std::filesystem::create_directories(dir, ec); + g_path = dir / (std::to_string(GetCurrentProcessId()) + ".log"); + g_ready.store(true); + } + + void Write(const std::string& line) { + if (!g_ready.load()) return; + std::lock_guard lock(g_mutex); + std::ofstream f(g_path, std::ios::app | std::ios::binary); + if (!f) return; + std::time_t t = std::time(nullptr); + std::tm tm{}; + localtime_s(&tm, &t); + char ts[32]; + std::strftime(ts, sizeof(ts), "%Y-%m-%d %H:%M:%S", &tm); + f << "[" << ts << "] [tid=" << GetCurrentThreadId() << "] " << line << "\n"; + } +} + +#endif // OPENSTEAMTOOL_LOGGING_ENABLED diff --git a/src/Payload/PayloadLog.h b/src/Payload/PayloadLog.h new file mode 100644 index 0000000..0f10104 --- /dev/null +++ b/src/Payload/PayloadLog.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include + +// Per-process line logger for the injected payload. Debug builds only. +// Logs go to /opensteamtool/payload/.log, one file per process. +#ifdef OPENSTEAMTOOL_LOGGING_ENABLED +namespace PayloadLog { + void Init(HMODULE self); + void Write(const std::string& line); +} +#else +namespace PayloadLog { + inline void Init(HMODULE) {} + inline void Write(const std::string&) {} +} +#endif diff --git a/src/Payload/SelfPropagate.cpp b/src/Payload/SelfPropagate.cpp new file mode 100644 index 0000000..71ae483 --- /dev/null +++ b/src/Payload/SelfPropagate.cpp @@ -0,0 +1,85 @@ +// The real game is often launched by a launcher as a separate process, and the +// EOS SDK loads in that child rather than the launcher. The payload has to follow the +// process tree down: hooking into all process-creation calls in its own host process and +// loading the same DLL into each child before that child starts running. + +#include "SelfPropagate.h" +#include "PayloadLog.h" +#include "Utils/RemoteInject.h" + +#include + +namespace { + // Full path to this payload DLL, so children load the exact same file. + wchar_t g_selfPath[MAX_PATH] = {}; + + using CreateProcessW_t = BOOL(WINAPI*)(LPCWSTR, LPWSTR, LPSECURITY_ATTRIBUTES, + LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, LPCWSTR, + LPSTARTUPINFOW, LPPROCESS_INFORMATION); + using CreateProcessAsUserW_t = BOOL(WINAPI*)(HANDLE, LPCWSTR, LPWSTR, + LPSECURITY_ATTRIBUTES, LPSECURITY_ATTRIBUTES, BOOL, DWORD, LPVOID, + LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION); + + CreateProcessW_t oCreateProcessW = nullptr; + CreateProcessAsUserW_t oCreateProcessAsUserW = nullptr; + + // CreateProcessW and CreateProcessAsUserW are separate spawn entry points, + // so both are hooked and funneled here. token is null on the plain path while + // the user token is on the AsUser path; every other argument passes through. + BOOL Spawn(HANDLE token, LPCWSTR app, LPWSTR cmd, LPSECURITY_ATTRIBUTES pa, + LPSECURITY_ATTRIBUTES ta, BOOL inherit, DWORD flags, LPVOID env, + LPCWSTR cwd, LPSTARTUPINFOW si, LPPROCESS_INFORMATION pi) + { + // Create the child suspended so we can load the payload before its + // first instruction runs. + const DWORD spawnFlags = flags | CREATE_SUSPENDED; + BOOL ok = token + ? oCreateProcessAsUserW(token, app, cmd, pa, ta, inherit, spawnFlags, env, cwd, si, pi) + : oCreateProcessW(app, cmd, pa, ta, inherit, spawnFlags, env, cwd, si, pi); + if (!ok) return ok; + + const bool injected = RemoteInject::LoadDll(pi->hProcess, g_selfPath); + PayloadLog::Write("propagate pid=" + std::to_string(pi->dwProcessId) + + (injected ? " ok" : " FAILED")); + + // We forced the suspend; let the child run unless the caller had + // already asked for it. + if (!(flags & CREATE_SUSPENDED)) ResumeThread(pi->hThread); + return ok; + } + + BOOL WINAPI hkCreateProcessW(LPCWSTR app, LPWSTR cmd, LPSECURITY_ATTRIBUTES pa, + LPSECURITY_ATTRIBUTES ta, BOOL inherit, DWORD flags, LPVOID env, + LPCWSTR cwd, LPSTARTUPINFOW si, LPPROCESS_INFORMATION pi) + { + return Spawn(nullptr, app, cmd, pa, ta, inherit, flags, env, cwd, si, pi); + } + + BOOL WINAPI hkCreateProcessAsUserW(HANDLE token, LPCWSTR app, LPWSTR cmd, + LPSECURITY_ATTRIBUTES pa, LPSECURITY_ATTRIBUTES ta, BOOL inherit, DWORD flags, + LPVOID env, LPCWSTR cwd, LPSTARTUPINFOW si, LPPROCESS_INFORMATION pi) + { + return Spawn(token, app, cmd, pa, ta, inherit, flags, env, cwd, si, pi); + } +} + +namespace SelfPropagate { + void Install(HMODULE hSelf) { + // Remember our own path; children load this exact DLL. + if (!GetModuleFileNameW(hSelf, g_selfPath, MAX_PATH)) return; + HMODULE k32 = GetModuleHandleW(L"kernel32.dll"); + if (!k32) return; + oCreateProcessW = reinterpret_cast (GetProcAddress(k32, "CreateProcessW")); + oCreateProcessAsUserW = reinterpret_cast(GetProcAddress(k32, "CreateProcessAsUserW")); + + DetourTransactionBegin(); + DetourUpdateThread(GetCurrentThread()); + if (oCreateProcessW) + DetourAttach(reinterpret_cast(&oCreateProcessW), + reinterpret_cast(hkCreateProcessW)); + if (oCreateProcessAsUserW) + DetourAttach(reinterpret_cast(&oCreateProcessAsUserW), + reinterpret_cast(hkCreateProcessAsUserW)); + DetourTransactionCommit(); + } +} diff --git a/src/Payload/SelfPropagate.h b/src/Payload/SelfPropagate.h new file mode 100644 index 0000000..40b1c11 --- /dev/null +++ b/src/Payload/SelfPropagate.h @@ -0,0 +1,8 @@ +#pragma once + +#include + +// Loads this payload into every child process the host spawns. +namespace SelfPropagate { + void Install(HMODULE hSelf); +} diff --git a/src/Payload/payload.cpp b/src/Payload/payload.cpp new file mode 100644 index 0000000..203f4c5 --- /dev/null +++ b/src/Payload/payload.cpp @@ -0,0 +1,83 @@ +// OnlineFix.dll - injected by Hooks_Inject into a game launched with +// -onlinefix. Waits for EOSSDK-Win64-Shipping.dll to load, then hands it +// to EosBridge, which does the patching. + +#include "EosBridge.h" +#include "PayloadLog.h" +#include "SelfPropagate.h" + +#include +#include +#include + +namespace { + // ── ntdll LdrRegisterDllNotification (private but stable since NT 6.1) ── + struct UNICODE_STRING_ { USHORT Length, MaximumLength; PWSTR Buffer; }; + struct LDR_DLL_NOTIF { + ULONG Flags; + const UNICODE_STRING_* FullDllName; + const UNICODE_STRING_* BaseDllName; + PVOID DllBase; + ULONG SizeOfImage; + }; + using LdrNotifyFn = VOID(CALLBACK*)(ULONG, const LDR_DLL_NOTIF*, PVOID); + using LdrRegisterFn = LONG(NTAPI*)(ULONG, LdrNotifyFn, PVOID, PVOID*); + constexpr ULONG LDR_LOADED = 1; + constexpr wchar_t kEosName[] = L"EOSSDK-Win64-Shipping.dll"; + + void TryInstall(HMODULE m) { + wchar_t base[MAX_PATH] = {}; + if (!GetModuleBaseNameW(GetCurrentProcess(), m, base, MAX_PATH)) return; + if (_wcsicmp(base, kEosName) == 0) EosBridge::InstallOn(m); + } + + VOID CALLBACK OnDllLoad(ULONG reason, const LDR_DLL_NOTIF* d, PVOID) { + if (reason != LDR_LOADED || !d || !d->BaseDllName) return; + // BaseDllName is not NUL-terminated; Length is in bytes. + const size_t chars = d->BaseDllName->Length / sizeof(wchar_t); + if (chars >= MAX_PATH) return; + wchar_t buf[MAX_PATH]; + memcpy(buf, d->BaseDllName->Buffer, d->BaseDllName->Length); + buf[chars] = L'\0'; + if (_wcsicmp(buf, kEosName) == 0) + EosBridge::InstallOn(reinterpret_cast(d->DllBase)); + } + + void SubscribeToDllLoads() { + HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); + if (!ntdll) return; + auto reg = reinterpret_cast( + GetProcAddress(ntdll, "LdrRegisterDllNotification")); + if (!reg) return; + PVOID cookie = nullptr; + reg(0, OnDllLoad, nullptr, &cookie); + } + + void ScanLoadedModules() { + HMODULE mods[1024]; + DWORD needed = 0; + if (!EnumProcessModules(GetCurrentProcess(), mods, sizeof(mods), &needed)) return; + for (DWORD i = 0; i < needed / sizeof(HMODULE); ++i) TryInstall(mods[i]); + } + + DWORD WINAPI PayloadMain(LPVOID hSelf) { + PayloadLog::Init(static_cast(hSelf)); + PayloadLog::Write("payload attached"); + // Reach EOSSDK wherever it ends up: follow the process tree into + // children, then look in this process. Subscribe before scanning so + // a load landing between the two isn't missed. + SelfPropagate::Install(static_cast(hSelf)); + SubscribeToDllLoads(); + ScanLoadedModules(); + return 0; + } +} + +BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID) { + if (reason == DLL_PROCESS_ATTACH) { + DisableThreadLibraryCalls(hModule); + if (HANDLE h = CreateThread(nullptr, 0, PayloadMain, hModule, 0, nullptr)) + CloseHandle(h); + } + return TRUE; +} diff --git a/src/Utils/Config.cpp b/src/Utils/Config.cpp index 7401862..f1a9124 100644 --- a/src/Utils/Config.cpp +++ b/src/Utils/Config.cpp @@ -1,61 +1,61 @@ -#include "Config.h" -#include "Log.h" -#include "ManifestClient.h" -#include -#include - -namespace Config { - - void Load(const std::string& configPath) { - std::filesystem::path p(configPath); - logDir = (p.parent_path() / "opensteamtool").string(); - - if (!std::filesystem::exists(configPath)) { - LOG_INFO("Config file not found, using defaults"); - return; - } - - try { - auto tbl = toml::parse_file(configPath); - - // [manifest] - if (auto manifest = tbl["manifest"].as_table()) { - if (auto val = (*manifest)["url"].value()) { - if (!ManifestClient::SetProvider(*val)) - LOG_WARN("Unknown manifest.url \"{}\", keeping default", *val); - } - if (auto val = (*manifest)["timeout_resolve_ms"].value()) - manifestTimeoutResolve = static_cast(*val); - if (auto val = (*manifest)["timeout_connect_ms"].value()) - manifestTimeoutConnect = static_cast(*val); - if (auto val = (*manifest)["timeout_send_ms"].value()) - manifestTimeoutSend = static_cast(*val); - if (auto val = (*manifest)["timeout_recv_ms"].value()) - manifestTimeoutRecv = static_cast(*val); - } - - // [log] - if (auto log = tbl["log"].as_table()) { - if (auto val = (*log)["level"].value()) { - if (*val == "trace") logLevel = LogLevel::Trace; - else if (*val == "debug") logLevel = LogLevel::Debug; - else if (*val == "info") logLevel = LogLevel::Info; - else if (*val == "warn") logLevel = LogLevel::Warn; - else if (*val == "error") logLevel = LogLevel::Error; - } - } - - // [lua] - if (auto lua = tbl["lua"].as_table()) { - if (auto arr = (*lua)["paths"].as_array()) { - for (auto& elem : *arr) { - if (auto str = elem.value()) { - luaPaths.push_back(*str); - } - } - } - } - +#include "Config.h" +#include "Log.h" +#include "ManifestClient.h" +#include +#include + +namespace Config { + + void Load(const std::string& configPath) { + std::filesystem::path p(configPath); + logDir = (p.parent_path() / "opensteamtool").string(); + + if (!std::filesystem::exists(configPath)) { + LOG_INFO("Config file not found, using defaults"); + return; + } + + try { + auto tbl = toml::parse_file(configPath); + + // [manifest] + if (auto manifest = tbl["manifest"].as_table()) { + if (auto val = (*manifest)["url"].value()) { + if (!ManifestClient::SetProvider(*val)) + LOG_WARN("Unknown manifest.url \"{}\", keeping default", *val); + } + if (auto val = (*manifest)["timeout_resolve_ms"].value()) + manifestTimeoutResolve = static_cast(*val); + if (auto val = (*manifest)["timeout_connect_ms"].value()) + manifestTimeoutConnect = static_cast(*val); + if (auto val = (*manifest)["timeout_send_ms"].value()) + manifestTimeoutSend = static_cast(*val); + if (auto val = (*manifest)["timeout_recv_ms"].value()) + manifestTimeoutRecv = static_cast(*val); + } + + // [log] + if (auto log = tbl["log"].as_table()) { + if (auto val = (*log)["level"].value()) { + if (*val == "trace") logLevel = LogLevel::Trace; + else if (*val == "debug") logLevel = LogLevel::Debug; + else if (*val == "info") logLevel = LogLevel::Info; + else if (*val == "warn") logLevel = LogLevel::Warn; + else if (*val == "error") logLevel = LogLevel::Error; + } + } + + // [lua] + if (auto lua = tbl["lua"].as_table()) { + if (auto arr = (*lua)["paths"].as_array()) { + for (auto& elem : *arr) { + if (auto str = elem.value()) { + luaPaths.push_back(*str); + } + } + } + } + // [remote] if (auto remote = tbl["remote"].as_table()) { if (auto val = (*remote)["url_template"].value()) { @@ -63,26 +63,32 @@ namespace Config { } } + // [inject] + if (auto inj = tbl["inject"].as_table()) { + if (auto val = (*inj)["enabled"].value()) + injectEnabled = *val; + } + LOG_INFO("Config loaded: manifest.url={} log.level={} lua.paths={} remote.url_template={}", - ManifestClient::ActiveProviderName(), - [&](){ - switch (logLevel) { - case LogLevel::Trace: return "trace"; - case LogLevel::Debug: return "debug"; - case LogLevel::Info: return "info"; - case LogLevel::Warn: return "warn"; - case LogLevel::Error: return "error"; - default: return "???"; - } - }(), - (uint32_t)luaPaths.size(), + ManifestClient::ActiveProviderName(), + [&](){ + switch (logLevel) { + case LogLevel::Trace: return "trace"; + case LogLevel::Debug: return "debug"; + case LogLevel::Info: return "info"; + case LogLevel::Warn: return "warn"; + case LogLevel::Error: return "error"; + default: return "???"; + } + }(), + (uint32_t)luaPaths.size(), remoteUrlTemplate.empty() ? "" : remoteUrlTemplate); - - } catch (const toml::parse_error& e) { - LOG_WARN("Config parse error: {}", e.what()); - } catch (...) { - LOG_WARN("Config load failed, using defaults"); - } - } - -} + + } catch (const toml::parse_error& e) { + LOG_WARN("Config parse error: {}", e.what()); + } catch (...) { + LOG_WARN("Config load failed, using defaults"); + } + } + +} diff --git a/src/Utils/Config.h b/src/Utils/Config.h index 02a3753..f752ae0 100644 --- a/src/Utils/Config.h +++ b/src/Utils/Config.h @@ -1,31 +1,33 @@ -#pragma once - -#include -#include -#include - -namespace Config { - - enum class LogLevel { Trace, Debug, Info, Warn, Error }; - - void Load(const std::string& configPath); - - // [manifest] — provider selection lives in ManifestClient (table-driven). - inline DWORD manifestTimeoutResolve = 5000; - inline DWORD manifestTimeoutConnect = 5000; - inline DWORD manifestTimeoutSend = 10000; - inline DWORD manifestTimeoutRecv = 10000; - - // [log] - inline LogLevel logLevel = LogLevel::Debug; - - // derived from configPath: /opensteamtool/ - inline std::string logDir; - - // [lua] - inline std::vector luaPaths; - +#pragma once + +#include +#include +#include + +namespace Config { + + enum class LogLevel { Trace, Debug, Info, Warn, Error }; + + void Load(const std::string& configPath); + + // [manifest] — provider selection lives in ManifestClient (table-driven). + inline DWORD manifestTimeoutResolve = 5000; + inline DWORD manifestTimeoutConnect = 5000; + inline DWORD manifestTimeoutSend = 10000; + inline DWORD manifestTimeoutRecv = 10000; + + // [log] + inline LogLevel logLevel = LogLevel::Debug; + + // derived from configPath: /opensteamtool/ + inline std::string logDir; + + // [lua] + inline std::vector luaPaths; + // [remote] inline std::string remoteUrlTemplate; - -} + + // [inject] + inline bool injectEnabled = true; +} diff --git a/src/Utils/RemoteInject.h b/src/Utils/RemoteInject.h new file mode 100644 index 0000000..2604f0c --- /dev/null +++ b/src/Utils/RemoteInject.h @@ -0,0 +1,33 @@ +#pragma once + +#include + +namespace RemoteInject { + + // Load a DLL into another process by running kernel32!LoadLibraryW on a + // remote thread inside it. Windows loads kernel32 at the same address in + // every process until the next reboot, so a LoadLibraryW pointer resolved + // here is valid in the target. Header-only because the host DLL and the + // payload run in separate processes and can't share compiled code. + inline bool LoadDll(HANDLE proc, LPCWSTR dllPath) { + auto loadLib = reinterpret_cast( + GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW")); + if (!loadLib) return false; + + const SIZE_T bytes = (wcslen(dllPath) + 1) * sizeof(wchar_t); + void* mem = VirtualAllocEx(proc, nullptr, bytes, + MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if (!mem) return false; + + bool ok = false; + if (WriteProcessMemory(proc, mem, dllPath, bytes, nullptr)) { + if (HANDLE t = CreateRemoteThread(proc, nullptr, 0, loadLib, mem, 0, nullptr)) { + ok = (WaitForSingleObject(t, 5000) == WAIT_OBJECT_0); + CloseHandle(t); + } + } + VirtualFreeEx(proc, mem, 0, MEM_RELEASE); + return ok; + } + +} // namespace RemoteInject diff --git a/src/Utils/ost_log_modules.h b/src/Utils/ost_log_modules.h index 0533ee3..5633e94 100644 --- a/src/Utils/ost_log_modules.h +++ b/src/Utils/ost_log_modules.h @@ -26,3 +26,4 @@ OST_MOD(OnlineFix, "onlinefix") OST_MOD(RichPresence, "richpresence") OST_MOD(Package, "package") OST_MOD(SteamUI, "steamui") +OST_MOD(Inject, "inject") diff --git a/src/dllmain.cpp b/src/dllmain.cpp index d0b8f43..e0bd3a0 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -16,6 +16,7 @@ bool InitializeSteamComponents() sprintf_s(DiversionPath, MAX_PATH, "%s\\bin\\diversion.dll", SteamInstallPath); sprintf_s(LuaDir, MAX_PATH, "%s\\config\\lua", SteamInstallPath); sprintf_s(ConfigPath, MAX_PATH, "%s\\opensteamtool.toml", SteamInstallPath); + sprintf_s(PayloadPath, MAX_PATH, "%s\\OnlineFix.dll", SteamInstallPath); client_hModule = LoadLibraryA(SteamclientPath); if (!client_hModule) { diff --git a/src/dllmain.h b/src/dllmain.h index c6576a0..9f3e822 100644 --- a/src/dllmain.h +++ b/src/dllmain.h @@ -1,39 +1,40 @@ -#ifndef DLLMAIN_H -#define DLLMAIN_H - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "Steam/Types.h" -#include "Steam/Enums.h" -#include "Steam/Structs.h" -#include "Steam/Callback.h" -#include "Utils/LuaConfig.h" -#include "Utils/Log.h" -#include "Utils/Config.h" - - -inline HMODULE client_hModule = nullptr; -inline HMODULE ui_hModule = nullptr; - -inline char SteamInstallPath[MAX_PATH] = {}; -inline char SteamclientPath[MAX_PATH] = {}; -inline char SteamUIPath[MAX_PATH] = {}; -inline char DiversionPath[MAX_PATH] = {}; -inline char LuaDir[MAX_PATH] = {}; -inline char ConfigPath[MAX_PATH] = {}; - -// The fake AppId used by -onlinefix (SpaceWar). -constexpr AppId_t kOnlineFixAppId = 480; - -#endif // DLLMAIN_H +#ifndef DLLMAIN_H +#define DLLMAIN_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "Steam/Types.h" +#include "Steam/Enums.h" +#include "Steam/Structs.h" +#include "Steam/Callback.h" +#include "Utils/LuaConfig.h" +#include "Utils/Log.h" +#include "Utils/Config.h" + + +inline HMODULE client_hModule = nullptr; +inline HMODULE ui_hModule = nullptr; + +inline char SteamInstallPath[MAX_PATH] = {}; +inline char SteamclientPath[MAX_PATH] = {}; +inline char SteamUIPath[MAX_PATH] = {}; +inline char DiversionPath[MAX_PATH] = {}; +inline char LuaDir[MAX_PATH] = {}; +inline char ConfigPath[MAX_PATH] = {}; +inline char PayloadPath[MAX_PATH] = {}; + +// The fake AppId used by -onlinefix (SpaceWar). +constexpr AppId_t kOnlineFixAppId = 480; + +#endif // DLLMAIN_H