From 3db3f43d7e9abd327165d32a912e2f4553c119aa Mon Sep 17 00:00:00 2001 From: OpenSteam001 <248184252+OpenSteam001@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:45:52 +0800 Subject: [PATCH 1/4] Refactor platform module, add pipe module, denuvo auth,inject and localize README --- .github/workflows/main.yml | 2 +- README.md | 47 +- README_ES.md | 241 +++ opensteamtool.example.toml | 11 +- src/CMakeLists.txt | 336 ++-- src/Hook/HookMacros.h | 40 +- src/Hook/HookManager.cpp | 2 +- src/Hook/Hooks_IPC.cpp | 31 +- src/Hook/Hooks_IPC_ISteamUser.cpp | 30 +- src/Hook/Hooks_IPC_ISteamUtils.cpp | 2 +- src/Hook/Hooks_KeyValues.cpp | 6 +- src/Hook/Hooks_Misc.cpp | 6 +- src/Hook/Hooks_NetPacket.cpp | 8 +- src/Hook/Hooks_Package.cpp | 409 ++--- src/Hook/Hooks_SteamUI.cpp | 4 +- src/OSTPlatform/CMakeLists.txt | 53 + src/OSTPlatform/Encoding.cpp | 62 + src/OSTPlatform/Linux/.gitkeep | 5 + src/OSTPlatform/Log.cpp | 20 + src/OSTPlatform/Numbers.cpp | 102 ++ src/OSTPlatform/Windows/ByteSearch.cpp | 271 +++ src/OSTPlatform/Windows/Detour.cpp | 45 + src/OSTPlatform/Windows/Dialog.cpp | 11 + src/OSTPlatform/Windows/DirectoryWatch.cpp | 192 ++ src/OSTPlatform/Windows/DynamicLibrary.cpp | 127 ++ src/OSTPlatform/Windows/Handles.h | 150 ++ src/OSTPlatform/Windows/Hash.cpp | 115 ++ src/OSTPlatform/Windows/Http.cpp | 192 ++ src/OSTPlatform/Windows/Memory.cpp | 51 + src/OSTPlatform/Windows/NtAbi.h | 72 + src/OSTPlatform/Windows/PE.cpp | 367 ++++ src/OSTPlatform/Windows/Process.cpp | 364 ++++ src/OSTPlatform/Windows/RemoteProcess.cpp | 278 +++ .../Windows/SteamCredentialStore.cpp | 257 +++ src/OSTPlatform/Windows/Thread.cpp | 41 + src/OSTPlatform/Windows/Trap.cpp | 155 ++ src/OSTPlatform/include/ByteSearch.h | 20 + src/OSTPlatform/include/Detour.h | 10 + src/OSTPlatform/include/Dialog.h | 9 + src/OSTPlatform/include/DirectoryWatch.h | 64 + src/OSTPlatform/include/DynamicLibrary.h | 23 + src/OSTPlatform/include/Encoding.h | 11 + src/OSTPlatform/include/Hash.h | 10 + src/OSTPlatform/include/Http.h | 25 + src/OSTPlatform/include/Log.h | 45 + src/OSTPlatform/include/Memory.h | 19 + src/OSTPlatform/include/Numbers.h | 51 + src/OSTPlatform/include/PE.h | 86 + src/OSTPlatform/include/Process.h | 30 + src/OSTPlatform/include/RemoteProcess.h | 31 + .../include/SteamCredentialStore.h | 33 + src/OSTPlatform/include/Stopwatch.h | 18 + src/OSTPlatform/include/Thread.h | 13 + src/OSTPlatform/include/Trap.h | 38 + src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp | 202 +++ src/Pipe/Features/DenuvoAuth/DenuvoAuth.h | 15 + .../Features/DenuvoAuth/ProtectionScan.cpp | 364 ++++ src/Pipe/Features/DenuvoAuth/ProtectionScan.h | 54 + src/Pipe/Features/Injection/Injection.cpp | 74 + src/Pipe/Features/Injection/Injection.h | 10 + src/Pipe/PipeManager.cpp | 90 + src/Pipe/PipeManager.h | 12 + src/Pipe/PipeTypes.h | 79 + src/Pipe/ProcessInspector.cpp | 94 + src/Pipe/ProcessInspector.h | 78 + src/Steam/IPCMessages.steamd | 11 + src/Steam/Structs.h | 31 +- src/Utils/AppTicket.cpp | 220 --- src/Utils/ByteSearch.cpp | 1 - src/Utils/ByteSearch.h | 13 - src/Utils/Config.cpp | 88 - src/Utils/Config.h | 31 - src/Utils/Config/Config.cpp | 217 +++ src/Utils/Config/Config.h | 61 + src/Utils/Config/ConfigFileWatcher.cpp | 152 ++ src/Utils/Config/ConfigFileWatcher.h | 8 + src/Utils/{ => Config}/LuaConfig.cpp | 1552 +++++++++-------- src/Utils/{ => Config}/LuaConfig.h | 83 +- src/Utils/Config/LuaFileWatcher.cpp | 219 +++ .../LuaFileWatcher.h} | 17 +- src/Utils/FileWatcher.cpp | 234 --- src/Utils/Hash.h | 71 - src/Utils/{ => HookSupport}/VehCommon.cpp | 43 +- src/Utils/{ => HookSupport}/VehCommon.h | 29 +- src/Utils/Log.cpp | 86 - src/Utils/Logging/Log.cpp | 135 ++ src/Utils/{ => Logging}/Log.h | 31 +- .../LogModules.def} | 5 +- src/Utils/{ => SteamMetadata}/IPCLoader.cpp | 23 +- src/Utils/{ => SteamMetadata}/IPCLoader.h | 0 .../{ => SteamMetadata}/ManifestClient.cpp | 114 +- .../{ => SteamMetadata}/ManifestClient.h | 0 .../{ => SteamMetadata}/PatternLoader.cpp | 51 +- src/Utils/{ => SteamMetadata}/PatternLoader.h | 8 +- src/Utils/{ => SteamMetadata}/RemoteToml.cpp | 21 +- src/Utils/{ => SteamMetadata}/RemoteToml.h | 0 .../{ => SteamMetadata}/SteamDiagnostics.cpp | 24 +- .../{ => SteamMetadata}/SteamDiagnostics.h | 0 src/Utils/Support/FnvHash.h | 14 + src/Utils/Support/Stopwatch.h | 18 + src/Utils/Tickets/AppTicket.cpp | 172 ++ src/Utils/{ => Tickets}/AppTicket.h | 30 +- src/Utils/WinHttp.cpp | 202 --- src/Utils/WinHttp.h | 52 - src/cmake/LogMacros.cmake | 10 +- src/cmake/OpenSteamToolBuildInfo.h.in | 3 + src/dllmain.cpp | 59 +- src/dllmain.h | 27 +- tools/ipc_codegen/ipc_codegen.cpp | 109 +- 109 files changed, 7534 insertions(+), 2454 deletions(-) create mode 100644 README_ES.md create mode 100644 src/OSTPlatform/CMakeLists.txt create mode 100644 src/OSTPlatform/Encoding.cpp create mode 100644 src/OSTPlatform/Linux/.gitkeep create mode 100644 src/OSTPlatform/Log.cpp create mode 100644 src/OSTPlatform/Numbers.cpp create mode 100644 src/OSTPlatform/Windows/ByteSearch.cpp create mode 100644 src/OSTPlatform/Windows/Detour.cpp create mode 100644 src/OSTPlatform/Windows/Dialog.cpp create mode 100644 src/OSTPlatform/Windows/DirectoryWatch.cpp create mode 100644 src/OSTPlatform/Windows/DynamicLibrary.cpp create mode 100644 src/OSTPlatform/Windows/Handles.h create mode 100644 src/OSTPlatform/Windows/Hash.cpp create mode 100644 src/OSTPlatform/Windows/Http.cpp create mode 100644 src/OSTPlatform/Windows/Memory.cpp create mode 100644 src/OSTPlatform/Windows/NtAbi.h create mode 100644 src/OSTPlatform/Windows/PE.cpp create mode 100644 src/OSTPlatform/Windows/Process.cpp create mode 100644 src/OSTPlatform/Windows/RemoteProcess.cpp create mode 100644 src/OSTPlatform/Windows/SteamCredentialStore.cpp create mode 100644 src/OSTPlatform/Windows/Thread.cpp create mode 100644 src/OSTPlatform/Windows/Trap.cpp create mode 100644 src/OSTPlatform/include/ByteSearch.h create mode 100644 src/OSTPlatform/include/Detour.h create mode 100644 src/OSTPlatform/include/Dialog.h create mode 100644 src/OSTPlatform/include/DirectoryWatch.h create mode 100644 src/OSTPlatform/include/DynamicLibrary.h create mode 100644 src/OSTPlatform/include/Encoding.h create mode 100644 src/OSTPlatform/include/Hash.h create mode 100644 src/OSTPlatform/include/Http.h create mode 100644 src/OSTPlatform/include/Log.h create mode 100644 src/OSTPlatform/include/Memory.h create mode 100644 src/OSTPlatform/include/Numbers.h create mode 100644 src/OSTPlatform/include/PE.h create mode 100644 src/OSTPlatform/include/Process.h create mode 100644 src/OSTPlatform/include/RemoteProcess.h create mode 100644 src/OSTPlatform/include/SteamCredentialStore.h create mode 100644 src/OSTPlatform/include/Stopwatch.h create mode 100644 src/OSTPlatform/include/Thread.h create mode 100644 src/OSTPlatform/include/Trap.h create mode 100644 src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp create mode 100644 src/Pipe/Features/DenuvoAuth/DenuvoAuth.h create mode 100644 src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp create mode 100644 src/Pipe/Features/DenuvoAuth/ProtectionScan.h create mode 100644 src/Pipe/Features/Injection/Injection.cpp create mode 100644 src/Pipe/Features/Injection/Injection.h create mode 100644 src/Pipe/PipeManager.cpp create mode 100644 src/Pipe/PipeManager.h create mode 100644 src/Pipe/PipeTypes.h create mode 100644 src/Pipe/ProcessInspector.cpp create mode 100644 src/Pipe/ProcessInspector.h delete mode 100644 src/Utils/AppTicket.cpp delete mode 100644 src/Utils/ByteSearch.cpp delete mode 100644 src/Utils/ByteSearch.h delete mode 100644 src/Utils/Config.cpp delete mode 100644 src/Utils/Config.h create mode 100644 src/Utils/Config/Config.cpp create mode 100644 src/Utils/Config/Config.h create mode 100644 src/Utils/Config/ConfigFileWatcher.cpp create mode 100644 src/Utils/Config/ConfigFileWatcher.h rename src/Utils/{ => Config}/LuaConfig.cpp (80%) rename src/Utils/{ => Config}/LuaConfig.h (90%) create mode 100644 src/Utils/Config/LuaFileWatcher.cpp rename src/Utils/{FileWatcher.h => Config/LuaFileWatcher.h} (70%) delete mode 100644 src/Utils/FileWatcher.cpp delete mode 100644 src/Utils/Hash.h rename src/Utils/{ => HookSupport}/VehCommon.cpp (55%) rename src/Utils/{ => HookSupport}/VehCommon.h (91%) delete mode 100644 src/Utils/Log.cpp create mode 100644 src/Utils/Logging/Log.cpp rename src/Utils/{ => Logging}/Log.h (72%) rename src/Utils/{ost_log_modules.h => Logging/LogModules.def} (92%) rename src/Utils/{ => SteamMetadata}/IPCLoader.cpp (93%) rename src/Utils/{ => SteamMetadata}/IPCLoader.h (100%) rename src/Utils/{ => SteamMetadata}/ManifestClient.cpp (55%) rename src/Utils/{ => SteamMetadata}/ManifestClient.h (100%) rename src/Utils/{ => SteamMetadata}/PatternLoader.cpp (87%) rename src/Utils/{ => SteamMetadata}/PatternLoader.h (54%) rename src/Utils/{ => SteamMetadata}/RemoteToml.cpp (92%) rename src/Utils/{ => SteamMetadata}/RemoteToml.h (100%) rename src/Utils/{ => SteamMetadata}/SteamDiagnostics.cpp (76%) rename src/Utils/{ => SteamMetadata}/SteamDiagnostics.h (100%) create mode 100644 src/Utils/Support/FnvHash.h create mode 100644 src/Utils/Support/Stopwatch.h create mode 100644 src/Utils/Tickets/AppTicket.cpp rename src/Utils/{ => Tickets}/AppTicket.h (55%) delete mode 100644 src/Utils/WinHttp.cpp delete mode 100644 src/Utils/WinHttp.h create mode 100644 src/cmake/OpenSteamToolBuildInfo.h.in diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index edc57a5..77a5fc1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,7 +20,7 @@ jobs: - uses: ilammy/msvc-dev-cmd@v1 - name: Configure - run: cmake -S src -B build -G "Ninja Multi-Config" + run: cmake -S src -B build -G "Ninja Multi-Config" -DOPENSTEAMTOOL_VERSION="${{ github.event.inputs.version }}" - name: Build Release run: cmake --build build --config Release diff --git a/README.md b/README.md index eaa34ae..e808460 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,28 @@ ![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 +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/OpenSteam001/OpenSteamTool) + +
+ + + + + +
+ United States Flag +
+ English +
+ Spain Flag +
+ Español +
+
+ +OpenSteamTool is a Windows DLL project built with CMake. + +## Feature ### Core Unlocks - Unlock an unlimited number of unowned games. @@ -26,10 +43,11 @@ OpenSteamTool is a Windows DLL project built with CMake. ### 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`. +- 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` @@ -89,9 +107,9 @@ addtoken(1361510,"2764735786934684318") -- add access token ("276473578693468431 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 +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, default SteamID 76561198028121353 is used. @@ -101,8 +119,9 @@ All function names are **case-insensitive**. `setAppTicket`, `setappticket`, `Se ### 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. +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] diff --git a/README_ES.md b/README_ES.md new file mode 100644 index 0000000..6ef5110 --- /dev/null +++ b/README_ES.md @@ -0,0 +1,241 @@ +# 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) + +
+ + + + + +
+ United States Flag +
+ English +
+ Spain Flag +
+ Español +
+
+ +OpenSteamTool es un proyecto de DLL para Windows desarrollado con CMake. + +## Características + +### Desbloqueos principales +- Desbloquea una cantidad ilimitada de juegos que no poseas. +- Desbloquea todos los DLC para juegos que no poseas. +- Soporta la carga automática de claves de descifrado de depósitos(depots) desde la configuración de Lua. +- Soporta la descarga automática de manifiestos a través de las APIs ascendentes (upstream APIs) de `opensteamtool` / `steamrun` / `wudrm` (por defecto es opensteamtool), o mediante un endpoint personalizado de Lua (ver [Manifest a traves de Lua](#manifest-via-lua)). +- Soporta la descarga de juegos protegidos o DLCs que requieran un token de acceso. +- Soporta la vinculación de manifiestos para evitar que juegos específicos se actualicen. + +### Recarga rápida +- Añadir, modificar, eliminar o sobrescribir archivos `.lua` en cualquier directorio supervisado activa automáticamente una recarga. No se necesita reiniciar ni alternar entre modo desconectado/conectado. + +### Préstamo familiar y juego en remoto +- Omite las restricciones de Steam Family Sharing para los juegos que se hayan añadido a la biblioteca con `addappid` en Lua. Todas las cuentas de la familia de Steam que participen en el préstamo familiar deben usar OpenSteamTool para que esto funcione. + +### Compatible con juegos protegidos por Denuvo y SteamStub +- Los juegos protegidos únicamente por SteamStub no requieren la configuración de `AppTicket`. OpenSteamTool puede reutilizar el ticket local de ConfigStore de Steam y falsificar el AppId solicitado a través de una vulnerabilidad de desbordamiento por cuatro (off-by-four ticket parsing vulnerability) en SteamDRMP, sin necesidad de inyectarse en el proceso del juego. +- Los juegos protegidos por Denuvo siguen requiriendo datos explícitos del ticket. En `HKEY_CURRENT_USER\Software\Valve\Steam\Apps\{AppId}`, tanto `AppTicket` como `ETicket` son valores `REG_BINARY`. +- Utiliza `setAppTicket(appid, "hex")` y `setETicket(appid, "hex")` en la configuración de Lua para escribir estos valores en el registro de forma automática. +- La verificación de Denuvo tiene una ventana de validez de 30 minutos. Cuando esta ventana expira, la autorización puede fallar con el código de error de Denuvo `88500005`; actualiza los datos del ticket antes de volver a intentarlo. +- Prioridad de AppTicket: los tickets explícitos tienen la prioridad más alta, incluyendo los tickets configurados por `setAppTicket` y los valores de `AppTicket` ya existentes en el registro. Si no hay ningún AppTicket explícito disponible, OpenSteamTool recurre a la ruta del ticket falsificado de ConfigStore local. +- Prioridad de SteamID: primero lee `SteamID` como `REG_SZ`(únicamente numérico); si no se encuentra, lo analiza a partir del `AppTicket` explícito. + +### Extracción de tickets con `extract_tickets` +La herramienta `extract_tickets` vuelca las cadenas hexadecimales de `AppTicket` y `ETicket` que necesitas para `setAppTicket` / `setETicket`. Ejecútala en una máquina donde Steam esté abierto e iniciado sesión en una cuenta que sea **propietaria** del juego en cuestión. + +1. Compila las herramientas (Revisa [Build](#build)); el binario se generará en `build/tools/Release/extract_tickets.exe`. +2. Ejecútalo pasando el AppId del juego como argumento (o ejecútalo sin argumentos e introduce el AppId cuando se te solicite): + ```powershell + extract_tickets.exe 1361510 + ``` +3. La herramienta leerá la ruta de instalación de Steam desde el registro, cargará `steamclient64.dll` y escribirá todo dentro de una carpeta `/` junto al ejecutable: + - `appticket.bin` — ticket bruto de propiedad de la aplicación (binario) + - `eticket.bin` — ticket cifrado bruto de la aplicación (binario) + - `tickets.txt` — resumen en texto plano con las cadenas hexadecimales: + ``` + appid:1361510 + appticket(184 bytes):14000000... + eticket(143 bytes):... + ``` + Si un ticket no se puede obtener, se reportará como + `appticket:null` / `eticket:null`. +4. Pega las cadenas hexadecimales de `tickets.txt` en tu configuración de Lua: + ```lua + setAppTicket(1361510, "14000000...") + setETicket(1361510, "...") + ``` + +> **Nota:** Los tickets solo son válidos cuando se extraen de una cuenta que **realmente posee** el juego. + +### Estadísticas y logros +- Activa las estadísticas y los logros para los juegos que no poseas. +- Utiliza `setStat(appid, "steamid")` para configurar de qué SteamID se deben extraer los datos de los logros. +- Si no hay ningún `setStat` configurado para una aplicación, recurre por defecto al SteamID predefinido en el código `76561198028121353`. + +### Online Fix(Reparacion para habilitar el Online) +- Añade `-onlinefix` a los parámetros de lanzamiento de Steam para habilitar el juego en línea basado en el AppId 480 en juegos que utilizan emparejamiento (matchmaking) por salas (lobbies). La limitación actual es que solo se puede ejecutar uno de estos juegos a la vez. Para revertirlo, simplemente elimina -onlinefix de los parámetros de lanzamiento; el juego en línea volverá a la normalidad en el próximo inicio. + +## Futuro +- Para los juegos protegidos por Denuvo y SteamStub, encontrar un momento seguro para cambiar `GetSteamID` (ver el TODO en `src/Hook/Hooks_IPC.cpp#Handler_IClientUser_GetSteamID`) de modo que los archivos de guardado no se vean afectados. (**Se aceptan sugerencias: ¿cuál es el punto más temprano después de la inicialización del juego en el que podemos cambiar el SteamID de forma segura sin afectar a la vinculación de la partida guardada?**). +- Soporte para la sincronización con Steam Cloud (este es un proyecto enorme). +- Añadir la función de compartir automáticamente la autorización de Denuvo para cuentas legítimas. + +## Uso +1. Ejecuta `build.bat` desde la raíz del proyecto para compilarlo. +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: +```lua +addappid(1361510) -- desbloquea el juego con appid 1361510 + +addappid(1361511, 0,"5954562e7f5260400040a818bc29b60b335bb690066ff767e20d145a3b6b4af0") -- desbloquea el juego con appid 1361511, la clave del depósito (depotKey) es "5954562e7f5260400040a818bc29b60b335bb690066ff767e20d145a3b6b4af0" + +addtoken(1361510,"2764735786934684318") -- añade el token de acceso ("2764735786934684318") para el juego con appid 1361510 +-- Ya no está soportado: +--pinApp(1361510) -- fija el juego con appid 1361510 para evitar que se actualice + +setManifestid(1361511,"5656605350306673283") -- fija depotid:1361511 manifest_gid:5656605350306673283, el tamaño por defecto es 0 +setManifestid(1361511,"5656605350306673283", 12345678) -- lo mismo, pero con un tamaño explícito + +setAppTicket(1361510,"0100000000000000...") -- escribe AppTicket (REG_BINARY) en HKCU\Software\Valve\Steam\Apps\1361510\AppTicket + +setETicket(1361510,"0100000000000000...") -- escribe ETicket (REG_BINARY) en HKCU\Software\Valve\Steam\Apps\1361510\ETicket + +setStat(1361510, "76561197960287930") -- utiliza los datos de logros del SteamID especificado para el appid 1361510 +-- Si no se configura, se utilizará el SteamID por defecto 76561198028121353. +``` + +Los nombres de todas las funciones **no distinguen entre mayúsculas y minúsculas**. `setAppTicket`, `setappticket`, `SetAppticket`, `SETAPPTICKET`, etc., son todas equivalentes. Lo mismo se aplica a cada función registrada (`addAppId`, `AddToken`, `SETManifestid`, etc.). + +### Configuración (opcional) +Cambia el nombre de `opensteamtool.example.toml` a `opensteamtool.toml` y colócalo en el directorio raíz de Steam (junto a `steam.exe`). +Si no se encuentra ningún archivo de configuración, se utilizarán los valores predeterminados integrados; no se creará ninguno de forma automática. + +```toml +[log] +# Solo para compilaciones de depuración (Debug). Niveles: trace, debug, info, warn, error +level = "info" + +[manifest] +# API ascendente para los códigos de solicitud de manifiestos de depósito. Opciones: "opensteamtool", "steamrun", "wudrm" +url = "opensteamtool" + +# Tiempos de espera HTTP (timeouts) para las solicitudes de manifiestos (en milisegundos) +timeout_resolve_ms = 5000 +timeout_connect_ms = 5000 +timeout_send_ms = 10000 +timeout_recv_ms = 10000 + +# Directorios adicionales de configuración de Lua (opcional). +# Los archivos se cargan después de la carpeta predeterminada /config/lua. +# La carpeta predeterminada siempre se carga al final para que los archivos del usuario tengan prioridad. +[lua] +paths = [] + +# Espejo (mirror) de metadatos opcional. Consulta "Compatibilidad con versiones de Steam" más abajo. +[remote] +# url_template = "https://tu.servidor/{channel}/{component}/{sha256}.toml" +``` +### Manifiest a través de Lua + +Se admiten dos funciones de código de manifiesto: + +### `fetch_manifest_code(gid)` + +Función básica que recibe únicamente el GID del manifiesto. + +### `fetch_manifest_code_ex(app_id, depot_id, gid)` *(recomendado)* +Función extendida que recibe `app_id`, `depot_id` y `gid`. Permite construir endpoints de API que requieran la identificación de la aplicación. + +El entorno de ejecución (runtime) en C++ proporciona dos funciones auxiliares de Lua: + + +| Function | Signature | Returns | +|----------|-----------|---------| +| `http_get` | `http_get(url [, headers])` | `body, status_code` | +| `http_post` | `http_post(url, body [, headers])` | `body, status_code` | + +`headers` es una tabla opcional: `{["Key"]="Value", ...}`. + +### Compatibilidad con versiones de Steam + +OpenSteamTool ya no incluye firmas de patrones de bytes (byte-pattern signatures) dentro de la DLL. En su lugar, en cada inicio calcula el hash SHA-256 de `steamclient64.dll` y `steamui.dll` en el disco, y busca un archivo de patrones coincidente desde el rastreador ascendente en ['OpenSteam001/steam-monitor'](https://github.com/OpenSteam001/steam-monitor) (extension `pattern`). + +Orden de búsqueda (en cada inicio): + +1.**GitHub raw** — `https://raw.githubusercontent.com/OpenSteam001/steam-monitor/pattern/....` Fuente canónica. +2.**jsDelivr CDN** — alternativa automática si GitHub raw no está disponible (conexión rechazada / tiempo de espera / error 5xx). No requiere configuración. Útil en regiones donde `raw.githubusercontent.com` está bloqueado pero jsDelivr es accesible (por ejemplo, China continental). +3.**Caché local** — `\opensteamtool\pattern\\.toml`. Se utiliza **únicamente** cuando el servidor remoto no está disponible. La caché se sobrescribe tras cada consulta remota exitosa. + +Se consulta al servidor remoto en cada inicio para que los usuarios obtengan automáticamente las nuevas publicaciones del proyecto principal (por ejemplo, si el bot añade una nueva firma o corrige una existente) sin tener que limpiar ninguna caché. + +Si un paso devuelve un error **HTTP 404**, el bucle de espejos (mirrors) se detiene inmediatamente —todos los espejos sirven el mismo contenido, por lo que un 404 significa que el bot ascendente aún no ha publicado un archivo TOML para esa compilación específica de Steam—. En ese caso, el código recurre a la caché local si existe; de lo contrario, aparecerá una ventana emergente por única vez mostrando el nombre de la DLL no emparejada, su SHA-256, la ruta de caché esperada y la URL de origen. Solo se desactivarán los ganchos (hooks) vinculados a esa DLL; el resto de OpenSteamTool seguirá funcionando. + +También puedes colocar manualmente un archivo TOML de patrones en el directorio de la caché si conoces la estructura para una compilación determinada; el nombre del archivo debe ser `.toml`. La caché de reserva lo detectará la próxima vez que el servidor remoto sea inaccesible. + +> Se realiza una breve solicitud HTTPS saliente en cada inicio (una por cada DLL: `steamclient64.dll`, `steamui.dll`). Los cuerpos descargados son diminutos (~10 KB cada uno) y el proceso se ejecuta en un hilo secundario (worker thread), por lo que nunca bloquea el cargador de Steam. + +#### Uso de un espejo (mirror) diferente + +Para la mayoría de los usuarios, la alternativa integrada de **GitHub -> jsDelivr** es suficiente. Si deseas utilizar un espejo privado o un servidor de intranet, configura una plantilla de URL completa. Un espejo personalizado reemplazará las fuentes remotas integradas; la alternativa de la caché local seguirá estando disponible. + +La plantilla debe incluir obligatoriamente `{channel}`, `{component}` y `{sha256}`. Los canales utilizados actualmente son `pattern` e `ipc`. + +```toml +[remote] +url_template = "https://tu.servidor/{channel}/{component}/{sha256}.toml" +# url_template = "https://fast.jsdelivr.net/gh/OpenSteam001/steam-monitor@{channel}/{component}/{sha256}.toml" +``` + +### Registro de depuración + +Las compilaciones de depuración (Debug) escriben archivos de registro independientes por módulo dentro de `/opensteamtool/`: + +| File | Source | Content | +|------|--------|---------| +| `main.log` | General | Inicialización, carga de configuración, análisis de Lua, utilidades | +| `ipc.log` | `LOG_IPC_*` | Comandos IPC, despacho de InterfaceCall, suplantación (spoofing) | +| `netpacket.log` | `LOG_NETPACKET_*` | Envío/recepción de paquetes de red, despacho de eMsg | +| `manifest.log` | `LOG_MANIFEST_*` | Descarga de manifiestos, fetch_manifest_code, vinculación de manifiestos | +| `decryptionkey.log` | `LOG_DECRYPTIONKEY_*` | Inyección de claves de descifrado de depósitos (depots) | +| `keyvalue.log` | `LOG_KEYVALUE_*` | Parcheo de KeyValues (vinculación de manifiestos) | +| `misc.log` | `LOG_MISC_*` | Captura de punteros del motor, sugerencias de AppId | +| `winhttp.log` | `LOG_WINHTTP_*` | Solicitudes HTTP | +| `achievement.log` | `LOG_ACHIEVEMENT_*` | Solicitudes/respuestas de UserStats, suplantación de steamid | +| `pics.log` | `LOG_PICS_*` | Inyección de tokens de acceso PICS | +| `package.log` | `LOG_PACKAGE_*` | Inyección de paquetes, eventos de FileWatcher | +| `onlinefix.log` | `LOG_ONLINEFIX_*` | Parche en línea (suplantación del AppId 480) | + +El nivel de registro se controla mediante el `parámetro level en la sección [log]` dentro de `opensteamtool.toml`. + +## Compilación + +### Requisitos +- Windows 10/11 +- CMake 3.20+ +- Visual Studio 2022 with MSVC (x64 toolchain) + +### Requisitos de ejecución +- Acceso HTTPS saliente a `raw.githubusercontent.com` en el primer inicio tras una actualización de Steam (ver [Compatibilidad con versiones de Steam](#steam-version-compatibility)). Posteriormente se almacena en caché. + +### Compilación rápida +```powershell +build.bat +``` + +### Archivos de salida +- 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` + +## 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/opensteamtool.example.toml b/opensteamtool.example.toml index b456ffb..97b874c 100644 --- a/opensteamtool.example.toml +++ b/opensteamtool.example.toml @@ -1,11 +1,11 @@ # opensteamtool.toml — OpenSteamTool configuration # Place at: /opensteamtool.toml -# This file is read once at startup. Restart Steam after changes. +# This file is loaded at startup and hot-reloaded after changes. [log] # Log verbosity for all log files (Debug build only). # Valid: trace, debug, info, warn, error -level = "info" +level = "debug" [manifest] # Which upstream API to query for depot manifest request codes. @@ -65,6 +65,13 @@ timeout_recv_ms = 10000 [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" + [remote] # Optional metadata mirror. Leave unset to use GitHub with jsDelivr fallback. # A custom mirror replaces the built-in remote sources and must include all diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 4e9bb68..ec605f7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,154 +1,182 @@ -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) +set(OPENSTEAMTOOL_VERSION "${PROJECT_VERSION}" CACHE STRING "Version string embedded into OpenSteamTool.dll") + +# 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) + + +if(NOT WIN32) + message(STATUS "OpenSteamTool: no targets on non-Windows platforms; skipping.") + return() +endif() + +file(MAKE_DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/generated") +configure_file( + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/OpenSteamToolBuildInfo.h.in" + "${CMAKE_CURRENT_BINARY_DIR}/generated/OpenSteamToolBuildInfo.h" + @ONLY +) + +# --------------------------------------------------------------------------- +# 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(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 +) + +# --------------------------------------------------------------------------- +# OSTPlatform — internal static library that owns the OS-specific backends. +# Its build is defined in its own module CMakeLists so this top-level file stays +# focused on host-level products (the DLLs) and code generation. +# --------------------------------------------------------------------------- +add_subdirectory(OSTPlatform) + +# --------------------------------------------------------------------------- +# Windows host artifacts: the injected hook DLL and the two loader-hijack DLLs. +# All Windows-only by construction (PE DLL hijacking + Win32 hooking). +# --------------------------------------------------------------------------- + +# OpenSteamTool — the hook DLL injected into Steam (always 64-bit). +add_library(OpenSteamTool SHARED + dllmain.cpp + + # Shared utilities + Utils/Tickets/AppTicket.cpp + Utils/Config/Config.cpp + Utils/Config/ConfigFileWatcher.cpp + Utils/Config/LuaConfig.cpp + Utils/Config/LuaFileWatcher.cpp + Utils/HookSupport/VehCommon.cpp + Utils/Logging/Log.cpp + Utils/SteamMetadata/IPCLoader.cpp + Utils/SteamMetadata/ManifestClient.cpp + Utils/SteamMetadata/PatternLoader.cpp + Utils/SteamMetadata/RemoteToml.cpp + Utils/SteamMetadata/SteamDiagnostics.cpp + + # Pipe subsystem: generic handshake registry + ProcessInspector core, plus + # self-contained features that own their primitives. + Pipe/PipeManager.cpp + Pipe/ProcessInspector.cpp + Pipe/Features/DenuvoAuth/DenuvoAuth.cpp + Pipe/Features/DenuvoAuth/ProtectionScan.cpp + Pipe/Features/Injection/Injection.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 + OSTPlatform + lua_static + $<$:libprotobuf> + $<$:libprotobuf-lite> + tomlplusplus::tomlplusplus + $<$:spdlog::spdlog> +) + +# Logging is compiled in only for Debug; Release reduces LOG_* to no-ops. +target_compile_definitions(OpenSteamTool PRIVATE + $<$:OPENSTEAMTOOL_LOGGING_ENABLED> +) + +# --------------------------------------------------------------------------- +# dwmapi.dll hijack — small loader DLL placed alongside Steam. +# --------------------------------------------------------------------------- +add_library(dwmapi SHARED + dwmapi/dwmapi.cpp +) + +# --------------------------------------------------------------------------- +# xinput1_4.dll hijack — secondary loader DLL placed alongside Steam. +# --------------------------------------------------------------------------- +add_library(xinput1_4 SHARED + xinput1_4/xinput1_4.cpp + xinput1_4/xinput1_4.def +) diff --git a/src/Hook/HookMacros.h b/src/Hook/HookMacros.h index b8f8618..5476d36 100644 --- a/src/Hook/HookMacros.h +++ b/src/Hook/HookMacros.h @@ -7,38 +7,35 @@ // Standard macros (no _D suffix) take an explicit module argument. // ───────────────────────────────────────────────────────────────── -#include -#include -#include "Utils/ByteSearch.h" +#include "OSTPlatform/include/Detour.h" +#include "Utils/SteamMetadata/PatternLoader.h" // ── transaction helpers ───────────────────────────────────────── #define HOOK_BEGIN() \ do { \ - DetourTransactionBegin(); \ - DetourUpdateThread(GetCurrentThread()) + bool _ost_detour_transaction_ok_ = OSTPlatform::Detour::BeginTransaction() #define HOOK_END() \ - DetourTransactionCommit(); \ + _ost_detour_transaction_ok_ = OSTPlatform::Detour::CommitTransaction() && _ost_detour_transaction_ok_; \ } while (0) #define UNHOOK_BEGIN() \ do { \ - DetourTransactionBegin(); \ - DetourUpdateThread(GetCurrentThread()) + bool _ost_detour_transaction_ok_ = OSTPlatform::Detour::BeginTransaction() #define UNHOOK_END() \ - DetourTransactionCommit(); \ + _ost_detour_transaction_ok_ = OSTPlatform::Detour::CommitTransaction() && _ost_detour_transaction_ok_; \ } while (0) // ── hook function definition ──────────────────────────────────── -// HOOK_FUNC(LoadModuleWithPath, HMODULE, const char* path, bool f) { +// HOOK_FUNC(LoadModuleWithPath, void*, const char* path, bool f) { // return oLoadModuleWithPath(path, f); // } // // generates: -// using LoadModuleWithPath_t = HMODULE(__fastcall*)(const char*, bool); +// using LoadModuleWithPath_t = void*(__fastcall*)(const char*, bool); // inline LoadModuleWithPath_t oLoadModuleWithPath = nullptr; -// HMODULE __fastcall hkLoadModuleWithPath(const char* path, bool f) +// void* __fastcall hkLoadModuleWithPath(const char* path, bool f) #define HOOK_FUNC(name, ret, ...) \ using name##_t = ret(__fastcall*)(__VA_ARGS__); \ inline name##_t o##name = nullptr; \ @@ -49,11 +46,12 @@ // Call between HOOK_BEGIN / HOOK_END. #define INSTALL_HOOK(module, name) \ do { \ - void* _p_ = FIND_SIG(module, name); \ + void* _p_ = PatternLoader::FindPattern(module, #name); \ if (_p_) { \ o##name = (name##_t)_p_; \ - DetourAttach(reinterpret_cast(&o##name), \ - reinterpret_cast(hk##name)); \ + if (!OSTPlatform::Detour::Attach(reinterpret_cast(&o##name), reinterpret_cast(hk##name))) { \ + _ost_detour_transaction_ok_ = false; \ + } \ } \ } while (0) @@ -65,9 +63,11 @@ do { \ #define UNINSTALL_HOOK(name) \ do { \ if (o##name) { \ - DetourDetach(reinterpret_cast(&o##name), \ - reinterpret_cast(hk##name)); \ - o##name = nullptr; \ + if (OSTPlatform::Detour::Detach(reinterpret_cast(&o##name), reinterpret_cast(hk##name))) { \ + o##name = nullptr; \ + } else { \ + _ost_detour_transaction_ok_ = false; \ + } \ } \ } while (0) #define UNINSTALL_HOOK_C(name) UNINSTALL_HOOK(name) @@ -86,7 +86,7 @@ do { \ // Find signature → cast to name##_t → assign to o##name. #define RESOLVE(module, name) \ do { \ - void* _p_ = FIND_SIG(module, name); \ + void* _p_ = PatternLoader::FindPattern(module, #name); \ if (_p_) { \ o##name = reinterpret_cast(_p_); \ } \ @@ -94,5 +94,3 @@ do { \ #define RESOLVE_C(name) RESOLVE(client_hModule, name) #define RESOLVE_U(name) RESOLVE(ui_hModule, name) - - diff --git a/src/Hook/HookManager.cpp b/src/Hook/HookManager.cpp index 0d6bc63..1b26438 100644 --- a/src/Hook/HookManager.cpp +++ b/src/Hook/HookManager.cpp @@ -8,7 +8,7 @@ #include "Hooks_NetPacket.h" #include "Hooks_Package.h" #include "Hooks_SteamUI.h" -#include "Utils/VehCommon.h" +#include "Utils/HookSupport/VehCommon.h" namespace SteamUI { diff --git a/src/Hook/Hooks_IPC.cpp b/src/Hook/Hooks_IPC.cpp index 37aada4..6ccc8a2 100644 --- a/src/Hook/Hooks_IPC.cpp +++ b/src/Hook/Hooks_IPC.cpp @@ -4,8 +4,9 @@ #include "Hooks_IPC_ISteamUtils.h" #include "HookMacros.h" #include "Hooks_Misc.h" -#include "Utils/Hash.h" -#include "Utils/IPCLoader.h" +#include "Pipe/PipeManager.h" +#include "Utils/Support/FnvHash.h" +#include "Utils/SteamMetadata/IPCLoader.h" namespace { @@ -84,17 +85,35 @@ namespace { dispatch.handler = FindHandler(call.interfaceID(), call.funcHash()); if (!dispatch.handler) return dispatch; - LOG_IPC_DEBUG("Resolved IPC handler: {}", dispatch.DebugString()); + LOG_IPC_TRACE("Resolved IPC handler: {}", dispatch.DebugString()); return dispatch; } + static void HandleHandshake(void* pServer, HSteamPipe hSteamPipe,CUtlBuffer* pRead) + { + IPCMessages::IPCRequest request{pRead}; + if (!request.ok()|| request.command() != EIPCCommand::Handshake) return; + IPCMessages::IPCHandshakeReq handshake{request.body()}; + if(!handshake.ok()) return; + + CPipeClient* pipe = GetPipe(pServer, hSteamPipe); + if (!pipe) return; + // set client PID + pipe->m_clientPID = handshake.pid(); + + LOG_IPC_DEBUG("Received handshake from {},{}", pipe->DebugString(), handshake.DebugString()); + PipeManager::OnHandshake(pipe); + } + HOOK_FUNC(IPCProcessMessage, bool,void* pServer, HSteamPipe hSteamPipe, CUtlBuffer* pRead, CUtlBuffer* pWrite) { - const IPCDispatch dispatch = ResolveDispatch(pServer, hSteamPipe, pRead); + // handle handshake messages + HandleHandshake(pServer, hSteamPipe, pRead); + + IPCDispatch dispatch = ResolveDispatch(pServer, hSteamPipe, pRead); // If we didn't find a handler for this message, just pass through to the original function. - if(!dispatch.enabled()) - return oIPCProcessMessage(pServer, hSteamPipe, pRead, pWrite); + if(!dispatch.enabled()) return oIPCProcessMessage(pServer, hSteamPipe, pRead, pWrite); // If we did find a handler, run the pre-handler if (dispatch.handler->pre) diff --git a/src/Hook/Hooks_IPC_ISteamUser.cpp b/src/Hook/Hooks_IPC_ISteamUser.cpp index 3db04c4..f0ac67e 100644 --- a/src/Hook/Hooks_IPC_ISteamUser.cpp +++ b/src/Hook/Hooks_IPC_ISteamUser.cpp @@ -1,8 +1,10 @@ #include "Hooks_IPC.h" #include "Hooks_IPC_ISteamUser.h" #include "PendingAPICalls.h" -#include "Utils/AppTicket.h" -#include "Utils/Log.h" +#include "Utils/Tickets/AppTicket.h" +#include "Pipe/PipeManager.h" +#include "Pipe/Features/DenuvoAuth/DenuvoAuth.h" +#include "Utils/Logging/Log.h" #include "Hooks_Misc.h" namespace { @@ -12,14 +14,20 @@ namespace { void HandlerPost_IClientUser_GetSteamID(CPipeClient* pipe,CUtlBuffer* pRead, CUtlBuffer* pWrite) { AppId_t appId = Hooks_Misc::ResolveAppId(); + 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; + } + const uint64 spoofed = AppTicket::GetSpoofSteamID(appId); if (!spoofed) { LOG_IPC_WARN("IClientUser::GetSteamID: AppId={} no valid steamid - cannot spoof", appId); return; } - GetSteamIDResp resp{pWrite}; - if (!resp.ok()) return; LOG_IPC_DEBUG("IClientUser::GetSteamID: AppId={} Original: {} -> Spoofed: 0x{:X}({})", appId,resp.DebugString(),spoofed, spoofed); resp.set_returnValue(spoofed); @@ -37,7 +45,15 @@ namespace { AppTicket::AppOwnershipTicket ticket{}; AppId_t appId = req.unAppID() == kOnlineFixAppId ? Hooks_Misc::ResolveAppId() : req.unAppID(); - if (!AppTicket::GetAppOwnershipTicket(appId, ticket)) return; + AppTicket::AppTicketSource ticketSource; + 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; + } + if (!AppTicket::GetAppOwnershipTicket(appId, ticket, ticketSource)) return; + if (ticket.data.size() > static_cast(req.cbMaxTicket())) { LOG_IPC_WARN("IClientUser::GetAppOwnershipTicketExtendedData: AppId={} ticket too large ({} bytes) for buffer ({} bytes)", appId, ticket.data.size(), req.cbMaxTicket()); @@ -67,7 +83,7 @@ namespace { if (!resp.ok()) return; AppId_t appId = Hooks_Misc::ResolveAppId(); - std::vector ticket = AppTicket::GetEncryptedTicketFromRegistry(appId); + std::vector ticket = AppTicket::GetEncryptedTicketFromCredentialStore(appId); if (ticket.empty()) { LOG_IPC_DEBUG("RequestEncryptedAppTicket: AppId={} - no cached eticket, skip", appId); return; @@ -83,7 +99,7 @@ namespace { void HandlerPost_IClientUser_GetEncryptedAppTicket(CPipeClient* pipe, CUtlBuffer* pRead, CUtlBuffer* pWrite) { AppId_t appId = Hooks_Misc::ResolveAppId(); - std::vector ticket = AppTicket::GetEncryptedTicketFromRegistry(appId); + std::vector ticket = AppTicket::GetEncryptedTicketFromCredentialStore(appId); if (ticket.empty()) { LOG_IPC_DEBUG("GetEncryptedAppTicket: AppId={} - no cached eticket, skip", appId); return; diff --git a/src/Hook/Hooks_IPC_ISteamUtils.cpp b/src/Hook/Hooks_IPC_ISteamUtils.cpp index 5390049..de71295 100644 --- a/src/Hook/Hooks_IPC_ISteamUtils.cpp +++ b/src/Hook/Hooks_IPC_ISteamUtils.cpp @@ -3,7 +3,7 @@ #include "Hooks_Misc.h" #include "PendingAPICalls.h" #include "Steam/Callback.h" -#include "Utils/Log.h" +#include "Utils/Logging/Log.h" #include diff --git a/src/Hook/Hooks_KeyValues.cpp b/src/Hook/Hooks_KeyValues.cpp index b715a1f..a7b6a93 100644 --- a/src/Hook/Hooks_KeyValues.cpp +++ b/src/Hook/Hooks_KeyValues.cpp @@ -4,6 +4,7 @@ #include "Hooks_KeyValues.h" #include "HookMacros.h" #include "dllmain.h" +#include "OSTPlatform/include/DynamicLibrary.h" namespace { @@ -15,9 +16,10 @@ namespace { // ── KeyValuesSystem — symbol ↔ string (from vstdlib_s64.dll) ─── IKeyValuesSystem* GetKeyValuesSystem() { static IKeyValuesSystem* sys = []() -> IKeyValuesSystem* { - HMODULE vstdlib = GetModuleHandleW(L"vstdlib_s64.dll"); + auto vstdlib = OSTPlatform::DynamicLibrary::GetLoaded("vstdlib_s64.dll"); if (!vstdlib) return nullptr; - auto pfn = (KeyValuesSystemSteam_t)GetProcAddress(vstdlib, "KeyValuesSystemSteam"); + auto pfn = reinterpret_cast( + OSTPlatform::DynamicLibrary::GetSymbol(vstdlib, "KeyValuesSystemSteam")); return pfn ? pfn() : nullptr; }(); return sys; diff --git a/src/Hook/Hooks_Misc.cpp b/src/Hook/Hooks_Misc.cpp index 97ed029..367d851 100644 --- a/src/Hook/Hooks_Misc.cpp +++ b/src/Hook/Hooks_Misc.cpp @@ -1,6 +1,6 @@ #include "Hooks_Misc.h" #include "HookMacros.h" -#include "Utils/VehCommon.h" +#include "Utils/HookSupport/VehCommon.h" #include "dllmain.h" namespace { @@ -23,7 +23,7 @@ namespace { // pGameID, ...) // arg1=pCUser, arg2=pExePath, arg3=pCommandLine, arg4=pWorkingDir // arg5=pGameID (CGameID*; low 24 bits = AppId) - static void OnSpawnProcessHit(PCONTEXT ctx, const VehCommon::Int3Site& /*site*/) { + static void OnSpawnProcessHit(OSTPlatform::Trap::Context& ctx, const VehCommon::Int3Site& /*site*/) { CGameID* pGameID = VehCommon::GetArg(ctx, 5); AppId_t appId = static_cast(pGameID->AppID(true)); const char* cmdLine = VehCommon::GetArg(ctx, 3); @@ -130,7 +130,7 @@ namespace Hooks_Misc { if (!appid) { LOG_MISC_TRACE("GetAppIDForCurrentPipeWrap: AppId=0(Not GamePipe)"); } else { - LOG_MISC_DEBUG("GetAppIDForCurrentPipeWrap: AppId={}", appid); + LOG_MISC_TRACE("GetAppIDForCurrentPipeWrap: AppId={}", appid); } return appid; } diff --git a/src/Hook/Hooks_NetPacket.cpp b/src/Hook/Hooks_NetPacket.cpp index 7971539..d5d0a1f 100644 --- a/src/Hook/Hooks_NetPacket.cpp +++ b/src/Hook/Hooks_NetPacket.cpp @@ -1,10 +1,10 @@ #include "Hooks_NetPacket.h" -#include "Utils/ManifestClient.h" +#include "Utils/SteamMetadata/ManifestClient.h" #include "Hooks_Misc.h" #include "HookMacros.h" #include "dllmain.h" -#include "Utils/AppTicket.h" -#include "Utils/Hash.h" +#include "Utils/Tickets/AppTicket.h" +#include "Utils/Support/FnvHash.h" #include #include #include @@ -398,7 +398,7 @@ namespace Hooks_NetPacket_ETicket { if (resp.eresult() == k_EResultOK) return; if (!LuaConfig::HasDepot(resp.app_id())) return; - auto ticket = AppTicket::GetEncryptedTicketFromRegistry(resp.app_id()); + auto ticket = AppTicket::GetEncryptedTicketFromCredentialStore(resp.app_id()); if (ticket.empty()) return; if (!resp.mutable_encrypted_app_ticket()->ParseFromArray( diff --git a/src/Hook/Hooks_Package.cpp b/src/Hook/Hooks_Package.cpp index 3fc6177..a5e1539 100644 --- a/src/Hook/Hooks_Package.cpp +++ b/src/Hook/Hooks_Package.cpp @@ -1,204 +1,205 @@ -#include "Hooks_Package.h" -#include "HookMacros.h" -#include "Hooks_SteamUI.h" -#include "dllmain.h" -#include "Utils/VehCommon.h" - -namespace { - RESOLVE_FUNC(CUtlMemoryGrow, void*, CUtlVector*, int); - RESOLVE_FUNC(MarkLicenseAsChanged, int64, void*, uint32, bool); - RESOLVE_FUNC(ProcessPendingLicenseUpdates, bool, void*); - - CAPTURE_THIS_FUNC(GetPackageInfo, PackageInfo*,g_pCPackageInfo,void* pThis, uint32 packageId, uint64 accessToken); - - void* g_pCUser = nullptr; - PackageInfo* g_pInjectedPackageInfo = nullptr; - bool g_licenseInitialized = false; - bool g_licenseRefreshPending = false; - - constexpr PackageId_t kInjectedPackageId = 0; - constexpr uint64_t kInjectedPkgAccessToken = 10660652434190618804ull; - - bool MarkLicenseAsChangedAndProcessUpdates() { - if (!g_pCUser || !oMarkLicenseAsChanged || !oProcessPendingLicenseUpdates) { - LOG_PACKAGE_WARN("MarkLicenseAsChangedAndProcessUpdates: dependencies not ready, skipping"); - return false; - } - oMarkLicenseAsChanged(g_pCUser, kInjectedPackageId, true); - oProcessPendingLicenseUpdates(g_pCUser); - LOG_PACKAGE_DEBUG("MarkLicenseAsChangedAndProcessUpdates: marked package {} as changed and processed updates", kInjectedPackageId); - return true; - } - - void TryProcessPendingLicenseRefresh() { - if (!g_licenseRefreshPending) - return; - if (MarkLicenseAsChangedAndProcessUpdates()) - g_licenseRefreshPending = false; - } - - bool CUtlMemoryGrowWrap(CUtlVector* pVec, int grow_size) { - if (!oCUtlMemoryGrow) { - LOG_PACKAGE_WARN("CUtlMemoryGrow: oCUtlMemoryGrow not ready, cannot grow"); - return false; - } - return oCUtlMemoryGrow(pVec, grow_size); - } - - bool InitFakeLicenseOnce(PackageInfo* pPkg) { - // check package status before injecting - if (pPkg->Status != EPackageStatus::Available) { - LOG_PACKAGE_WARN("InitFakeLicenseOnce: package status is not Available ({}), skipping injection", static_cast(pPkg->Status)); - return false; - } - - // Inject all depots from config into the fake license. - std::vector appIds = LuaConfig::GetAllDepotIds(); - if (!appIds.empty()) { - uint32 oldSize = pPkg->AppIdVec.m_Size; - uint32 numToAdd = static_cast(appIds.size()); - LOG_PACKAGE_INFO("InitFakeLicense(PackageId={}): adding {} apps, oldSize={}", kInjectedPackageId, numToAdd, oldSize); - if (!CUtlMemoryGrowWrap(&pPkg->AppIdVec, numToAdd)) { - LOG_PACKAGE_WARN("InitFakeLicense(PackageId={}): failed to grow AppId vector", kInjectedPackageId); - return false; - } - for (uint32 i = 0; i < numToAdd; i++) - pPkg->AppIdVec.m_Memory.m_pMemory[oldSize + i] = appIds[i]; - } - - g_licenseInitialized = true; - g_licenseRefreshPending = true; - TryProcessPendingLicenseRefresh(); - return true; - } - - bool TryInitFakeLicenseOnce() { - if (g_licenseInitialized) return true; - if(CAPTURE_READY(GetPackageInfo)){ - PackageInfo* pPkg = oGetPackageInfo(g_pCPackageInfo, kInjectedPackageId, kInjectedPkgAccessToken); - if(!pPkg) { - LOG_PACKAGE_WARN("TryInitFakeLicenseOnce: GetPackageInfo returned null for injected package"); - return false; - } - if(!g_pInjectedPackageInfo) g_pInjectedPackageInfo = pPkg; - return InitFakeLicenseOnce(pPkg); - } - return false; - } - - - HOOK_FUNC(CheckAppOwnership, bool, void* pObj, AppId_t appId, AppOwnership* pOwn) { - if (!g_pCUser) { - g_pCUser = pObj; - LOG_PACKAGE_DEBUG("CheckAppOwnership: captured CUser {}", g_pCUser); - } - - bool result = oCheckAppOwnership(pObj, appId, pOwn); - TryInitFakeLicenseOnce(); - - // LOG_PACKAGE_TRACE("CheckAppOwnership: AppId={} result={} {}", appId, result, pOwn->DebugString()); - if (LuaConfig::HasDepot(appId,false)) { - if (result && pOwn->ExistInPackageNums > 1) { - // Actually owned — record so HasDepot excludes it going forward - LuaConfig::MarkOwned(appId); - pOwn->ReleaseState = EAppReleaseState::Released; - } else { - pOwn->PackageId = kInjectedPackageId; - pOwn->ReleaseState = EAppReleaseState::Released; - // Setting this free flag to false will hide it from the library UI. - pOwn->bFreeLicense = false; - return true; - } - } - return result; - } -} - -namespace Hooks_Package { - void Install() { - RESOLVE_C(CUtlMemoryGrow); - RESOLVE_C(MarkLicenseAsChanged); - RESOLVE_C(ProcessPendingLicenseUpdates); - - ARM_CAPTURE_C(GetPackageInfo); - - HOOK_BEGIN(); - INSTALL_HOOK_C(CheckAppOwnership); - HOOK_END(); - } - - void Uninstall() { - UNHOOK_BEGIN(); - UNINSTALL_HOOK_C(CheckAppOwnership); - UNHOOK_END(); - } - - void NotifyLicenseChanged() { - PackageInfo* pPkg = g_pInjectedPackageInfo; - if (!pPkg) { - LOG_PACKAGE_WARN("NotifyLicenseChanged: injected PackageInfo not ready, cannot notify"); - return; - } - - // ── Remove depots that were unloaded ── - std::vector removals = LuaConfig::TakePendingRemovals(); - LOG_PACKAGE_DEBUG("NotifyLicenseChanged: processing {} removals", removals.size()); - uint32_t removedCount = 0; - for (AppId_t id : removals) { - if (pPkg->AppIdVec.FindAndFastRemove(id)) { - ++removedCount; - LOG_PACKAGE_DEBUG("NotifyLicenseChanged: removed AppId {}", id); - }else { - LOG_PACKAGE_WARN("NotifyLicenseChanged: AppId {} not found in package AppIdVec during removal", id); - } - } - - // ── Add depots that are newly loaded ── - std::vector additions = LuaConfig::TakePendingAdditions(); - std::unordered_set addedIds; - LOG_PACKAGE_DEBUG("NotifyLicenseChanged: processing {} additions", additions.size()); - if (!additions.empty()) { - uint32_t oldSize = pPkg->AppIdVec.m_Size; - if (CUtlMemoryGrowWrap(&pPkg->AppIdVec, additions.size())) { - // An applied addition invalidates any UI removal that has not - // reached the UI thread yet. - for (AppId_t id : additions) - Hooks_SteamUI::CancelRemoval(id); - - for (size_t i = 0; i < additions.size(); ++i) { - pPkg->AppIdVec.m_Memory.m_pMemory[oldSize + i] = additions[i]; - addedIds.insert(additions[i]); - LOG_PACKAGE_DEBUG("NotifyLicenseChanged: inserted AppId {} at [{}]", additions[i], oldSize + i); - } - }else { - LOG_PACKAGE_WARN("NotifyLicenseChanged: failed to grow AppId vector for additions"); - } - } - - if (addedIds.empty() && removedCount == 0) { - LOG_PACKAGE_DEBUG("NotifyLicenseChanged: no changes"); - return; - } - - // Mark package 0 as changed and trigger library refresh. - if (!MarkLicenseAsChangedAndProcessUpdates()) { - LOG_PACKAGE_WARN("NotifyLicenseChanged: failed to mark license as changed"); - return; - } - LOG_PACKAGE_INFO("NotifyLicenseChanged: {} added, {} removed", addedIds.size(), removedCount); - - // Queue UI removals for the main-thread RunFrame hook to drain. - // Never touch MarkAppChange from this (FileWatcher) thread. - size_t queuedRemovalCount = 0; - for (AppId_t id : removals) { - // ParseFile unloads the old file before parsing the replacement. - // Do not queue that transient removal when the id was added again. - if (!addedIds.contains(id)) { - Hooks_SteamUI::QueueRemoval(id); - ++queuedRemovalCount; - } - } - LOG_PACKAGE_DEBUG("NotifyLicenseChanged: queued {} UI removals, skipped {} transient removals", - queuedRemovalCount, removals.size() - queuedRemovalCount); - } -} +#include "Hooks_Package.h" +#include "HookMacros.h" +#include "Hooks_SteamUI.h" +#include "dllmain.h" +#include "Utils/HookSupport/VehCommon.h" +#include + +namespace { + RESOLVE_FUNC(CUtlMemoryGrow, void*, CUtlVector*, int); + RESOLVE_FUNC(MarkLicenseAsChanged, int64, void*, uint32, bool); + RESOLVE_FUNC(ProcessPendingLicenseUpdates, bool, void*); + + CAPTURE_THIS_FUNC(GetPackageInfo, PackageInfo*,g_pCPackageInfo,void* pThis, uint32 packageId, uint64 accessToken); + + void* g_pCUser = nullptr; + PackageInfo* g_pInjectedPackageInfo = nullptr; + bool g_licenseInitialized = false; + bool g_licenseRefreshPending = false; + + constexpr PackageId_t kInjectedPackageId = 0; + constexpr uint64_t kInjectedPkgAccessToken = 10660652434190618804ull; + + bool MarkLicenseAsChangedAndProcessUpdates() { + if (!g_pCUser || !oMarkLicenseAsChanged || !oProcessPendingLicenseUpdates) { + LOG_PACKAGE_WARN("MarkLicenseAsChangedAndProcessUpdates: dependencies not ready, skipping"); + return false; + } + oMarkLicenseAsChanged(g_pCUser, kInjectedPackageId, true); + oProcessPendingLicenseUpdates(g_pCUser); + LOG_PACKAGE_DEBUG("MarkLicenseAsChangedAndProcessUpdates: marked package {} as changed and processed updates", kInjectedPackageId); + return true; + } + + void TryProcessPendingLicenseRefresh() { + if (!g_licenseRefreshPending) + return; + if (MarkLicenseAsChangedAndProcessUpdates()) + g_licenseRefreshPending = false; + } + + bool CUtlMemoryGrowWrap(CUtlVector* pVec, int grow_size) { + if (!oCUtlMemoryGrow) { + LOG_PACKAGE_WARN("CUtlMemoryGrow: oCUtlMemoryGrow not ready, cannot grow"); + return false; + } + return oCUtlMemoryGrow(pVec, grow_size); + } + + bool InitFakeLicenseOnce(PackageInfo* pPkg) { + // check package status before injecting + if (pPkg->Status != EPackageStatus::Available) { + LOG_PACKAGE_WARN("InitFakeLicenseOnce: package status is not Available ({}), skipping injection", static_cast(pPkg->Status)); + return false; + } + + // Inject all depots from config into the fake license. + std::vector appIds = LuaConfig::GetAllDepotIds(); + if (!appIds.empty()) { + uint32 oldSize = pPkg->AppIdVec.m_Size; + uint32 numToAdd = static_cast(appIds.size()); + LOG_PACKAGE_INFO("InitFakeLicense(PackageId={}): adding {} apps, oldSize={}", kInjectedPackageId, numToAdd, oldSize); + if (!CUtlMemoryGrowWrap(&pPkg->AppIdVec, numToAdd)) { + LOG_PACKAGE_WARN("InitFakeLicense(PackageId={}): failed to grow AppId vector", kInjectedPackageId); + return false; + } + for (uint32 i = 0; i < numToAdd; i++) + pPkg->AppIdVec.m_Memory.m_pMemory[oldSize + i] = appIds[i]; + } + + g_licenseInitialized = true; + g_licenseRefreshPending = true; + TryProcessPendingLicenseRefresh(); + return true; + } + + bool TryInitFakeLicenseOnce() { + if (g_licenseInitialized) return true; + if(CAPTURE_READY(GetPackageInfo)){ + PackageInfo* pPkg = oGetPackageInfo(g_pCPackageInfo, kInjectedPackageId, kInjectedPkgAccessToken); + if(!pPkg) { + LOG_PACKAGE_WARN("TryInitFakeLicenseOnce: GetPackageInfo returned null for injected package"); + return false; + } + if(!g_pInjectedPackageInfo) g_pInjectedPackageInfo = pPkg; + return InitFakeLicenseOnce(pPkg); + } + return false; + } + + + HOOK_FUNC(CheckAppOwnership, bool, void* pObj, AppId_t appId, AppOwnership* pOwn) { + if (!g_pCUser) { + g_pCUser = pObj; + LOG_PACKAGE_DEBUG("CheckAppOwnership: captured CUser {}", g_pCUser); + } + + bool result = oCheckAppOwnership(pObj, appId, pOwn); + TryInitFakeLicenseOnce(); + + // LOG_PACKAGE_TRACE("CheckAppOwnership: AppId={} result={} {}", appId, result, pOwn->DebugString()); + if (LuaConfig::HasDepot(appId,false)) { + if (result && pOwn->ExistInPackageNums > 1) { + // Actually owned — record so HasDepot excludes it going forward + LuaConfig::MarkOwned(appId); + pOwn->ReleaseState = EAppReleaseState::Released; + } else { + pOwn->PackageId = kInjectedPackageId; + pOwn->ReleaseState = EAppReleaseState::Released; + // Setting this free flag to false will hide it from the library UI. + pOwn->bFreeLicense = false; + return true; + } + } + return result; + } +} + +namespace Hooks_Package { + void Install() { + RESOLVE_C(CUtlMemoryGrow); + RESOLVE_C(MarkLicenseAsChanged); + RESOLVE_C(ProcessPendingLicenseUpdates); + + ARM_CAPTURE_C(GetPackageInfo); + + HOOK_BEGIN(); + INSTALL_HOOK_C(CheckAppOwnership); + HOOK_END(); + } + + void Uninstall() { + UNHOOK_BEGIN(); + UNINSTALL_HOOK_C(CheckAppOwnership); + UNHOOK_END(); + } + + void NotifyLicenseChanged() { + PackageInfo* pPkg = g_pInjectedPackageInfo; + if (!pPkg) { + LOG_PACKAGE_WARN("NotifyLicenseChanged: injected PackageInfo not ready, cannot notify"); + return; + } + + // ── Remove depots that were unloaded ── + std::vector removals = LuaConfig::TakePendingRemovals(); + LOG_PACKAGE_DEBUG("NotifyLicenseChanged: processing {} removals", removals.size()); + uint32_t removedCount = 0; + for (AppId_t id : removals) { + if (pPkg->AppIdVec.FindAndFastRemove(id)) { + ++removedCount; + LOG_PACKAGE_DEBUG("NotifyLicenseChanged: removed AppId {}", id); + }else { + LOG_PACKAGE_WARN("NotifyLicenseChanged: AppId {} not found in package AppIdVec during removal", id); + } + } + + // ── Add depots that are newly loaded ── + std::vector additions = LuaConfig::TakePendingAdditions(); + std::unordered_set addedIds; + LOG_PACKAGE_DEBUG("NotifyLicenseChanged: processing {} additions", additions.size()); + if (!additions.empty()) { + uint32_t oldSize = pPkg->AppIdVec.m_Size; + if (CUtlMemoryGrowWrap(&pPkg->AppIdVec, additions.size())) { + // An applied addition invalidates any UI removal that has not + // reached the UI thread yet. + for (AppId_t id : additions) + Hooks_SteamUI::CancelRemoval(id); + + for (size_t i = 0; i < additions.size(); ++i) { + pPkg->AppIdVec.m_Memory.m_pMemory[oldSize + i] = additions[i]; + addedIds.insert(additions[i]); + LOG_PACKAGE_DEBUG("NotifyLicenseChanged: inserted AppId {} at [{}]", additions[i], oldSize + i); + } + }else { + LOG_PACKAGE_WARN("NotifyLicenseChanged: failed to grow AppId vector for additions"); + } + } + + if (addedIds.empty() && removedCount == 0) { + LOG_PACKAGE_DEBUG("NotifyLicenseChanged: no changes"); + return; + } + + // Mark package 0 as changed and trigger library refresh. + if (!MarkLicenseAsChangedAndProcessUpdates()) { + LOG_PACKAGE_WARN("NotifyLicenseChanged: failed to mark license as changed"); + return; + } + LOG_PACKAGE_INFO("NotifyLicenseChanged: {} added, {} removed", addedIds.size(), removedCount); + + // Queue UI removals for the main-thread RunFrame hook to drain. + // Never touch MarkAppChange from this (FileWatcher) thread. + size_t queuedRemovalCount = 0; + for (AppId_t id : removals) { + // ParseFile unloads the old file before parsing the replacement. + // Do not queue that transient removal when the id was added again. + if (!addedIds.contains(id)) { + Hooks_SteamUI::QueueRemoval(id); + ++queuedRemovalCount; + } + } + LOG_PACKAGE_DEBUG("NotifyLicenseChanged: queued {} UI removals, skipped {} transient removals", + queuedRemovalCount, removals.size() - queuedRemovalCount); + } +} diff --git a/src/Hook/Hooks_SteamUI.cpp b/src/Hook/Hooks_SteamUI.cpp index 868ebb1..325980e 100644 --- a/src/Hook/Hooks_SteamUI.cpp +++ b/src/Hook/Hooks_SteamUI.cpp @@ -3,8 +3,10 @@ #include "HookMacros.h" #include "dllmain.h" #include "steam_messages.pb.h" -#include "Utils/VehCommon.h" +#include "Utils/HookSupport/VehCommon.h" #include +#include +#include namespace { diff --git a/src/OSTPlatform/CMakeLists.txt b/src/OSTPlatform/CMakeLists.txt new file mode 100644 index 0000000..911d57c --- /dev/null +++ b/src/OSTPlatform/CMakeLists.txt @@ -0,0 +1,53 @@ +# --------------------------------------------------------------------------- +# OSTPlatform — internal static library that owns the OS-specific backends. +# +# Windows-only: the public OSTPlatform include headers stay OS-agnostic, but +# every backend implemented here is Win32 (WinHttp / BCrypt / Detours). +# The top-level CMakeLists returns early on non-Windows, so this file only ever +# runs on WIN32 and needs no platform guard of its own. +# +# Public headers must NOT expose Win32, WinHttp, +# registry, BCrypt, or remote-process primitives — those stay behind this library. +# --------------------------------------------------------------------------- +include(Detours) + +add_library(OSTPlatform STATIC + # Platform-agnostic + Encoding.cpp + Numbers.cpp + Log.cpp + + # Windows backends + Windows/Dialog.cpp + Windows/Detour.cpp + Windows/DirectoryWatch.cpp + Windows/DynamicLibrary.cpp + Windows/Hash.cpp + Windows/Http.cpp + Windows/Memory.cpp + Windows/ByteSearch.cpp + Windows/PE.cpp + Windows/Process.cpp + Windows/RemoteProcess.cpp + Windows/SteamCredentialStore.cpp + Windows/Thread.cpp + Windows/Trap.cpp +) + +target_include_directories(OSTPlatform + PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR} + PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/.. # src/ — for cross-module private headers +) + +target_link_libraries(OSTPlatform PRIVATE + winhttp + Bcrypt + detours +) + +# Logging is compiled in only for Debug; Release reduces OSTP_LOG_* to no-ops. +target_compile_definitions(OSTPlatform PRIVATE + $<$:OPENSTEAMTOOL_LOGGING_ENABLED> +) diff --git a/src/OSTPlatform/Encoding.cpp b/src/OSTPlatform/Encoding.cpp new file mode 100644 index 0000000..b687b36 --- /dev/null +++ b/src/OSTPlatform/Encoding.cpp @@ -0,0 +1,62 @@ +#include "include/Encoding.h" + +#include "include/Log.h" + +#include + +#include + +namespace OSTPlatform::Encoding { +namespace { + +bool FitsWindowsInt(size_t size, const char* operation) { + if (size <= static_cast((std::numeric_limits::max)())) return true; + OSTP_LOG_DEBUG("{}: input is too large ({} bytes/chars)", operation, size); + return false; +} + +} // namespace + +std::string WideToUtf8(std::wstring_view value) { + if (value.empty()) return {}; + if (!FitsWindowsInt(value.size(), "WideToUtf8")) return {}; + + const int size = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast(value.size()), + nullptr, 0, nullptr, nullptr); + if (size <= 0) { + OSTP_LOG_DEBUG("WideCharToMultiByte(size query) failed (error={})", GetLastError()); + return {}; + } + + std::string result(static_cast(size), '\0'); + const int written = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast(value.size()), + result.data(), size, nullptr, nullptr); + if (written != size) { + OSTP_LOG_DEBUG("WideCharToMultiByte(convert) failed (written={}, expected={}, error={})", + written, size, GetLastError()); + return {}; + } + return result; +} + +std::wstring Utf8ToWide(std::string_view value) { + if (value.empty()) return {}; + if (!FitsWindowsInt(value.size(), "Utf8ToWide")) return {}; + + const int size = MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast(value.size()), nullptr, 0); + if (size <= 0) { + OSTP_LOG_DEBUG("MultiByteToWideChar(size query) failed (error={})", GetLastError()); + return {}; + } + + std::wstring result(static_cast(size), L'\0'); + const int written = MultiByteToWideChar(CP_UTF8, 0, value.data(), static_cast(value.size()), result.data(), size); + if (written != size) { + OSTP_LOG_DEBUG("MultiByteToWideChar(convert) failed (written={}, expected={}, error={})", + written, size, GetLastError()); + return {}; + } + return result; +} + +} // namespace OSTPlatform::Encoding diff --git a/src/OSTPlatform/Linux/.gitkeep b/src/OSTPlatform/Linux/.gitkeep new file mode 100644 index 0000000..8971aac --- /dev/null +++ b/src/OSTPlatform/Linux/.gitkeep @@ -0,0 +1,5 @@ +# Placeholder so git tracks this otherwise-empty directory. +# +# OSTPlatform is Windows-only today (see ../CMakeLists.txt and the top-level +# src/CMakeLists.txt, which returns early on non-WIN32). This folder reserves +# the spot for a future Linux backend; it intentionally holds no stub sources. diff --git a/src/OSTPlatform/Log.cpp b/src/OSTPlatform/Log.cpp new file mode 100644 index 0000000..1007aea --- /dev/null +++ b/src/OSTPlatform/Log.cpp @@ -0,0 +1,20 @@ +#include "include/Log.h" + +#include + +namespace OSTPlatform::Log { +namespace { +std::atomic g_sink{nullptr}; +} // namespace + +void SetSink(Sink sink) noexcept { + g_sink.store(sink, std::memory_order_release); +} + +void Dispatch(Level level, const Source& src, std::string_view msg) noexcept { + if (Sink sink = g_sink.load(std::memory_order_acquire)) { + sink(level, src, msg); + } +} + +} // namespace OSTPlatform::Log diff --git a/src/OSTPlatform/Numbers.cpp b/src/OSTPlatform/Numbers.cpp new file mode 100644 index 0000000..714bd4e --- /dev/null +++ b/src/OSTPlatform/Numbers.cpp @@ -0,0 +1,102 @@ +#include "include/Numbers.h" + +#include "include/Encoding.h" + +#include +#include + +namespace OSTPlatform::Numbers { +namespace { + +std::optional ApplySlice(std::string_view value, TextSlice slice) { + if (slice.offset > value.size()) return std::nullopt; + return value.substr(slice.offset, slice.count); +} + +template +std::optional ParseStrict(std::string_view value, int base) { + if (value.empty()) return std::nullopt; + + T result = 0; + const char* first = value.data(); + const char* last = first + value.size(); + const auto [ptr, ec] = std::from_chars(first, last, result, base); + if (ec != std::errc{} || ptr != last) return std::nullopt; + return result; +} + +template +std::optional ParseStrict(std::wstring_view value, int base) { + return ParseStrict(Encoding::WideToUtf8(value), base); +} + +template +std::optional ParseStrict(const char* value, int base) { + if (!value) return std::nullopt; + return ParseStrict(std::string_view(value), base); +} + +template +std::optional ParseStrict(std::string_view value, TextSlice slice, int base) { + const auto sliced = ApplySlice(value, slice); + if (!sliced) return std::nullopt; + return ParseStrict(*sliced, base); +} + +std::string_view StripHexPrefix(std::string_view value) { + if (value.size() >= 2 && value[0] == '0' && (value[1] == 'x' || value[1] == 'X')) { + value.remove_prefix(2); + } + return value; +} + +template +std::optional ParseHexInteger(std::string_view value) { + return ParseStrict(StripHexPrefix(value), 16); +} + +template +std::optional ParseHexInteger(std::wstring_view value) { + return ParseHexInteger(Encoding::WideToUtf8(value)); +} + +template +std::optional ParseHexInteger(const char* value) { + if (!value) return std::nullopt; + return ParseHexInteger(std::string_view(value)); +} + +template +std::optional ParseHexInteger(std::string_view value, TextSlice slice) { + const auto sliced = ApplySlice(value, slice); + if (!sliced) return std::nullopt; + return ParseHexInteger(*sliced); +} + +} // namespace + +#define DEFINE_DECIMAL_PARSER(name, type) \ + std::optional name(std::string_view value) { return ParseStrict(value, 10); } \ + std::optional name(std::wstring_view value) { return ParseStrict(value, 10); } \ + std::optional name(const char* value) { return ParseStrict(value, 10); } \ + std::optional name(std::string_view value, TextSlice slice) { return ParseStrict(value, slice, 10); } + +#define DEFINE_HEX_PARSER(name, type) \ + std::optional name(std::string_view value) { return ParseHexInteger(value); } \ + std::optional name(std::wstring_view value) { return ParseHexInteger(value); } \ + std::optional name(const char* value) { return ParseHexInteger(value); } \ + std::optional name(std::string_view value, TextSlice slice) { return ParseHexInteger(value, slice); } + +DEFINE_DECIMAL_PARSER(ParseUInt64, uint64_t) +DEFINE_DECIMAL_PARSER(ParseUInt32, uint32_t) +DEFINE_DECIMAL_PARSER(ParseInt64, int64_t) +DEFINE_DECIMAL_PARSER(ParseInt32, int32_t) + +DEFINE_HEX_PARSER(ParseHexUInt64, uint64_t) +DEFINE_HEX_PARSER(ParseHexUInt32, uint32_t) +DEFINE_HEX_PARSER(ParseHexUInt8, uint8_t) + +#undef DEFINE_DECIMAL_PARSER +#undef DEFINE_HEX_PARSER + +} // namespace OSTPlatform::Numbers diff --git a/src/OSTPlatform/Windows/ByteSearch.cpp b/src/OSTPlatform/Windows/ByteSearch.cpp new file mode 100644 index 0000000..15b27a9 --- /dev/null +++ b/src/OSTPlatform/Windows/ByteSearch.cpp @@ -0,0 +1,271 @@ +#include "include/ByteSearch.h" + +#include "Windows/Handles.h" +#include "include/Log.h" +#include "include/Stopwatch.h" + +#include + +#include +#include +#include +#include +#include + +namespace OSTPlatform::ByteSearch { +namespace { + +class FixedPatternScanner { +public: + explicit FixedPatternScanner(std::span pattern) + : pattern_(pattern.begin(), pattern.end()) { + const size_t patternSize = pattern_.size(); + skip_.fill(patternSize == 0 ? 1 : patternSize); + if (patternSize <= 1) return; + + for (size_t i = 0; i + 1 < patternSize; ++i) { + skip_[pattern_[i]] = patternSize - 1 - i; + } + } + + std::optional Find(std::span bytes) const { + const size_t patternSize = pattern_.size(); + if (patternSize == 0 || bytes.size() < patternSize) return std::nullopt; + + size_t offset = 0; + while (offset <= bytes.size() - patternSize) { + const uint8_t tail = bytes[offset + patternSize - 1]; + if (tail == pattern_[patternSize - 1]) { + size_t i = patternSize - 1; + while (i > 0 && bytes[offset + i - 1] == pattern_[i - 1]) { + --i; + } + if (i == 0) return offset; + } + + offset += skip_[tail]; + } + + return std::nullopt; + } + + size_t PatternSize() const { return pattern_.size(); } + +private: + std::vector pattern_; + std::array skip_{}; +}; + +// Open for overlapped (async) sequential reads. FILE_FLAG_SEQUENTIAL_SCAN biases +// the cache manager toward aggressive readahead and evict-behind; FILE_FLAG_OVERLAPPED +// lets us queue the next chunk's read before scanning the current one so disk and +// CPU work proceed concurrently. Sharing is permissive: the module is usually mapped. +Windows::UniqueFileHandle OpenForOverlappedSequentialRead(const std::filesystem::path& path) { + return Windows::UniqueFileHandle(::CreateFileW( + path.c_str(), + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, + OPEN_EXISTING, + FILE_FLAG_SEQUENTIAL_SCAN | FILE_FLAG_OVERLAPPED, + nullptr)); +} + +} // namespace + +std::optional Find(std::span bytes, std::span pattern) { + const Stopwatch scanTimer; + const auto result = FixedPatternScanner(pattern).Find(bytes); + OSTP_LOG_DEBUG("ByteSearch::Find bytes={} pattern={} matched={} elapsed_ms={:.3f}", + bytes.size(), pattern.size(), result.has_value(), scanTimer.ElapsedMs()); + return result; +} + +std::optional FindInFile( + const std::filesystem::path& path, + std::span pattern, + size_t chunkBytes) { + // A whole-file scan is just a range scan over [0, EOF). FindInFileRange clamps + // the requested size to the real file size, so a max sentinel means "to the end + // of the file" — this routes the legacy path through the same overlapped + // double-buffered engine (read of chunk i+1 overlaps the BMH scan of chunk i) + // and keeps a single I/O implementation. + return FindInFileRange( + path, 0, (std::numeric_limits::max)(), pattern, chunkBytes); +} + +std::optional FindInFileRange( + const std::filesystem::path& path, + uint64_t offset, + uint64_t size, + std::span pattern, + size_t chunkBytes) { + const Stopwatch totalTimer; + FixedPatternScanner scanner(pattern); + const size_t patternSize = scanner.PatternSize(); + if (patternSize == 0 || size == 0) return std::nullopt; + + Windows::UniqueFileHandle file = OpenForOverlappedSequentialRead(path); + if (!file) { + OSTP_LOG_DEBUG("ByteSearch::FindInFileRange open failed path={} offset=0x{:X} size={} err={}", + path.string(), offset, size, ::GetLastError()); + return std::nullopt; + } + + LARGE_INTEGER sizeLi{}; + if (!::GetFileSizeEx(file.get(), &sizeLi) || sizeLi.QuadPart <= 0) { + OSTP_LOG_DEBUG("ByteSearch::FindInFileRange empty/invalid file path={} offset=0x{:X} size={}", + path.string(), offset, size); + return std::nullopt; + } + + const uint64_t fileSize = static_cast(sizeLi.QuadPart); + if (offset >= fileSize) { + OSTP_LOG_DEBUG("ByteSearch::FindInFileRange offset past EOF path={} offset=0x{:X} file_size={}", + path.string(), offset, fileSize); + return std::nullopt; + } + + const uint64_t rangeEnd = offset + (std::min)(size, fileSize - offset); + + chunkBytes = (std::max)(chunkBytes, patternSize); + // Consecutive chunks overlap by (patternSize - 1) bytes: a match straddling a + // chunk boundary runs off chunk i's tail (BMH won't see it there) but is fully + // contained at the head of chunk i+1. Because the overlap is re-read from disk + // rather than carry-copied from the previous buffer, the read of chunk i+1 has + // no data dependency on the scan of chunk i — so the two can truly overlap. + const uint64_t stride = static_cast(chunkBytes) - (patternSize - 1); + + // Double buffering: while BMH scans chunk i in one buffer, the kernel streams + // chunk i+1 into the other. make_unique_for_overwrite skips the zero-fill the + // buffers are about to be overwritten anyway. + std::unique_ptr buffers[2] = { + std::make_unique_for_overwrite(chunkBytes), + std::make_unique_for_overwrite(chunkBytes), + }; + Windows::UniqueHandle events[2] = { + Windows::UniqueHandle(::CreateEventW(nullptr, TRUE, FALSE, nullptr)), + Windows::UniqueHandle(::CreateEventW(nullptr, TRUE, FALSE, nullptr)), + }; + if (!events[0] || !events[1]) { + OSTP_LOG_DEBUG("ByteSearch::FindInFileRange event create failed path={} err={}", + path.string(), ::GetLastError()); + return std::nullopt; + } + OVERLAPPED ov[2]{}; + + int pendingSlot = -1; // slot whose overlapped read is currently in flight + const auto issueRead = [&](int slot, uint64_t chunkStart, size_t len) -> bool { + ov[slot] = OVERLAPPED{}; + ov[slot].Offset = static_cast(chunkStart & 0xFFFFFFFFull); + ov[slot].OffsetHigh = static_cast(chunkStart >> 32); + ov[slot].hEvent = events[slot].get(); + ::ResetEvent(events[slot].get()); + DWORD got = 0; + const BOOL ok = ::ReadFile(file.get(), buffers[slot].get(), + static_cast(len), &got, &ov[slot]); + if (!ok && ::GetLastError() != ERROR_IO_PENDING) return false; + pendingSlot = slot; + return true; + }; + // Drain the in-flight read before tearing buffers down: the kernel may still be + // writing into a buffer, so cancel and wait for the op to settle first. + const auto drainPending = [&]() { + if (pendingSlot >= 0) { + ::CancelIoEx(file.get(), &ov[pendingSlot]); + DWORD discarded = 0; + ::GetOverlappedResult(file.get(), &ov[pendingSlot], &discarded, TRUE); + pendingSlot = -1; + } + }; + + uint64_t bytesReadTotal = 0; + size_t chunks = 0; + double readMs = 0.0; + double scanMs = 0.0; + + int slot = 0; + uint64_t curStart = offset; + { + const size_t len = static_cast( + (std::min)(static_cast(chunkBytes), rangeEnd - curStart)); + if (!issueRead(slot, curStart, len)) { + OSTP_LOG_DEBUG("ByteSearch::FindInFileRange read start failed path={} offset=0x{:X} err={}", + path.string(), curStart, ::GetLastError()); + return std::nullopt; + } + } + + while (true) { + const Stopwatch readTimer; + DWORD got = 0; + const BOOL ok = ::GetOverlappedResult(file.get(), &ov[slot], &got, TRUE); + readMs += readTimer.ElapsedMs(); + pendingSlot = -1; // this slot's read has completed (or errored) + if (!ok) { + const DWORD err = ::GetLastError(); + if (err != ERROR_HANDLE_EOF) { + OSTP_LOG_DEBUG("ByteSearch::FindInFileRange read failed path={} offset=0x{:X} err={}", + path.string(), curStart, err); + return std::nullopt; + } + got = 0; + } + if (got == 0) break; + bytesReadTotal += got; + ++chunks; + + const uint64_t curEnd = curStart + got; + const bool moreChunks = curEnd < rangeEnd; + + // Queue chunk i+1's read BEFORE scanning chunk i so disk and CPU overlap. + uint64_t nextStart = 0; + const int nextSlot = slot ^ 1; + if (moreChunks) { + nextStart = curStart + stride; + const size_t nextLen = static_cast( + (std::min)(static_cast(chunkBytes), rangeEnd - nextStart)); + if (!issueRead(nextSlot, nextStart, nextLen)) { + OSTP_LOG_DEBUG("ByteSearch::FindInFileRange read start failed path={} offset=0x{:X} err={}", + path.string(), nextStart, ::GetLastError()); + return std::nullopt; + } + } + + const Stopwatch scanTimer; + const auto match = scanner.Find(std::span(buffers[slot].get(), got)); + scanMs += scanTimer.ElapsedMs(); + if (match) { + drainPending(); + const uint64_t absoluteMatch = curStart + *match; + OSTP_LOG_DEBUG("ByteSearch::FindInFileRange matched path={} range=[0x{:X},0x{:X}) match=0x{:X} bytes_read={} chunks={} read_ms={:.3f} bmh_ms={:.3f} total_ms={:.3f}", + path.string(), + offset, + rangeEnd, + absoluteMatch, + bytesReadTotal, + chunks, + readMs, + scanMs, + totalTimer.ElapsedMs()); + return absoluteMatch; + } + + if (!moreChunks) break; + slot = nextSlot; + curStart = nextStart; + } + + OSTP_LOG_DEBUG("ByteSearch::FindInFileRange no match path={} range=[0x{:X},0x{:X}) bytes_read={} chunks={} read_ms={:.3f} bmh_ms={:.3f} total_ms={:.3f}", + path.string(), + offset, + rangeEnd, + bytesReadTotal, + chunks, + readMs, + scanMs, + totalTimer.ElapsedMs()); + return std::nullopt; +} + +} // namespace OSTPlatform::ByteSearch diff --git a/src/OSTPlatform/Windows/Detour.cpp b/src/OSTPlatform/Windows/Detour.cpp new file mode 100644 index 0000000..78dbd85 --- /dev/null +++ b/src/OSTPlatform/Windows/Detour.cpp @@ -0,0 +1,45 @@ +#include "include/Detour.h" + +#include "include/Log.h" +#include "include/Thread.h" + +#include +#include + +namespace OSTPlatform::Detour { + +namespace { + +bool DetourSucceeded(LONG result, const char* operation) { + if (result == NO_ERROR) return true; + OSTP_LOG_WARN("{} failed (detours_error={})", operation, result); + return false; +} + +} // namespace + +bool BeginTransaction() { + const LONG begin = DetourTransactionBegin(); + if (!DetourSucceeded(begin, "DetourTransactionBegin")) return false; + return DetourSucceeded( + DetourUpdateThread(Thread::CurrentNativeThreadHandle()), + "DetourUpdateThread"); +} + +bool CommitTransaction() { + return DetourSucceeded(DetourTransactionCommit(), "DetourTransactionCommit"); +} + +bool Attach(void** target, void* detour) { + return DetourSucceeded( + DetourAttach(reinterpret_cast(target), reinterpret_cast(detour)), + "DetourAttach"); +} + +bool Detach(void** target, void* detour) { + return DetourSucceeded( + DetourDetach(reinterpret_cast(target), reinterpret_cast(detour)), + "DetourDetach"); +} + +} // namespace OSTPlatform::Detour diff --git a/src/OSTPlatform/Windows/Dialog.cpp b/src/OSTPlatform/Windows/Dialog.cpp new file mode 100644 index 0000000..93e4f37 --- /dev/null +++ b/src/OSTPlatform/Windows/Dialog.cpp @@ -0,0 +1,11 @@ +#include "include/Dialog.h" + +#include + +namespace OSTPlatform::Dialog { + +void ShowWarning(std::string title, std::string message) { + MessageBoxA(nullptr, message.c_str(), title.c_str(), MB_OK | MB_ICONWARNING | MB_TOPMOST); +} + +} // namespace OSTPlatform::Dialog diff --git a/src/OSTPlatform/Windows/DirectoryWatch.cpp b/src/OSTPlatform/Windows/DirectoryWatch.cpp new file mode 100644 index 0000000..bea136b --- /dev/null +++ b/src/OSTPlatform/Windows/DirectoryWatch.cpp @@ -0,0 +1,192 @@ +#include "include/DirectoryWatch.h" + +#include "include/Encoding.h" +#include "include/Log.h" + +#include "Windows/Handles.h" + +#include + +#include +#include +#include + +namespace OSTPlatform::DirectoryWatch { +namespace { + +ChangeAction FromWindowsAction(DWORD action) { + switch (action) { + case FILE_ACTION_ADDED: return ChangeAction::Added; + case FILE_ACTION_REMOVED: return ChangeAction::Removed; + case FILE_ACTION_MODIFIED: return ChangeAction::Modified; + case FILE_ACTION_RENAMED_OLD_NAME: return ChangeAction::RenamedOldName; + case FILE_ACTION_RENAMED_NEW_NAME: return ChangeAction::RenamedNewName; + default: return ChangeAction::Modified; + } +} + +} // namespace + +struct Watch::Impl { + std::string directory; + Windows::UniqueFileHandle dir; + Windows::UniqueHandle event; + OVERLAPPED overlapped{}; + std::vector buffer; + bool readPending = false; + + void Close() { + if (dir) { + CancelIo(dir.get()); + } + dir.Reset(); + event.Reset(); + overlapped = {}; + buffer.clear(); + readPending = false; + directory.clear(); + } +}; + +Watch::Watch() : impl_(std::make_unique()) {} +Watch::~Watch() = default; +Watch::Watch(Watch&&) noexcept = default; +Watch& Watch::operator=(Watch&&) noexcept = default; + +bool Watch::Open(const std::string& directory, uint32_t bufferSize) { + impl_->Close(); + + impl_->event.Reset(CreateEventW(nullptr, FALSE, FALSE, nullptr)); + if (!impl_->event) { + OSTP_LOG_WARN("DirectoryWatch: CreateEvent failed for '{}' (error={})", directory, GetLastError()); + return false; + } + + const std::wstring nativeDirectory = Encoding::Utf8ToWide(directory); + impl_->dir.Reset(CreateFileW( + nativeDirectory.c_str(), + FILE_LIST_DIRECTORY, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, + nullptr)); + + if (!impl_->dir) { + OSTP_LOG_WARN("DirectoryWatch: CreateFile failed for '{}' (error={})", directory, GetLastError()); + impl_->Close(); + return false; + } + + impl_->directory = directory; + impl_->buffer.assign(bufferSize, 0); + impl_->overlapped = {}; + impl_->overlapped.hEvent = impl_->event.get(); + return true; +} + +bool Watch::IssueRead() { + if (!IsOpen()) return false; + if (impl_->readPending) return true; + + HANDLE event = impl_->event.get(); + impl_->overlapped = {}; + impl_->overlapped.hEvent = event; + + DWORD dummy = 0; + if (!ReadDirectoryChangesW( + impl_->dir.get(), + impl_->buffer.data(), + static_cast(impl_->buffer.size()), + FALSE, + FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE, + &dummy, + &impl_->overlapped, + nullptr)) { + if (GetLastError() != ERROR_IO_PENDING) { + OSTP_LOG_WARN("DirectoryWatch: ReadDirectoryChangesW failed for '{}' (error={})", + impl_->directory, GetLastError()); + impl_->readPending = false; + return false; + } + } + + impl_->readPending = true; + return true; +} + +std::vector Watch::Drain() { + std::vector changes; + if (!IsOpen() || !impl_->readPending) return changes; + + DWORD bytesReturned = 0; + if (!GetOverlappedResult(impl_->dir.get(), &impl_->overlapped, &bytesReturned, FALSE) || + bytesReturned == 0) { + impl_->readPending = false; + return changes; + } + + impl_->readPending = false; + const FILE_NOTIFY_INFORMATION* info = + reinterpret_cast(impl_->buffer.data()); + + while (info) { + std::wstring_view fileName(info->FileName, info->FileNameLength / sizeof(wchar_t)); + std::string relativePath = Encoding::WideToUtf8(fileName); + if (!relativePath.empty()) { + changes.push_back({std::move(relativePath), FromWindowsAction(info->Action)}); + } + + if (info->NextEntryOffset == 0) break; + info = reinterpret_cast( + reinterpret_cast(info) + info->NextEntryOffset); + } + + return changes; +} + +void Watch::Cancel() { + impl_->Close(); +} + +bool Watch::IsOpen() const { + return impl_ && impl_->dir && impl_->event; +} + +const std::string& Watch::Directory() const { + return impl_->directory; +} + +WaitResult WaitAny(std::span watches, uint32_t timeoutMs) { + std::vector events; + std::vector originalIndexes; + events.reserve(watches.size()); + originalIndexes.reserve(watches.size()); + + for (size_t i = 0; i < watches.size(); ++i) { + Watch* watch = watches[i]; + if (!watch || !watch->IsOpen()) continue; + events.push_back(watch->impl_->event.get()); + originalIndexes.push_back(i); + } + + if (events.empty()) { + return {WaitStatus::Failed, 0}; + } + + const DWORD result = WaitForMultipleObjects( + static_cast(events.size()), + events.data(), + FALSE, + timeoutMs); + + if (result == WAIT_TIMEOUT) return {WaitStatus::Timeout, 0}; + if (result >= WAIT_OBJECT_0 && result < WAIT_OBJECT_0 + events.size()) { + return {WaitStatus::Signaled, originalIndexes[static_cast(result - WAIT_OBJECT_0)]}; + } + + OSTP_LOG_WARN("DirectoryWatch: WaitForMultipleObjects failed/result={} error={}", result, GetLastError()); + return {WaitStatus::Failed, 0}; +} + +} // namespace OSTPlatform::DirectoryWatch diff --git a/src/OSTPlatform/Windows/DynamicLibrary.cpp b/src/OSTPlatform/Windows/DynamicLibrary.cpp new file mode 100644 index 0000000..b2ce6dc --- /dev/null +++ b/src/OSTPlatform/Windows/DynamicLibrary.cpp @@ -0,0 +1,127 @@ +#include "include/DynamicLibrary.h" + +#include "include/Encoding.h" +#include "include/Log.h" + +#include + +#include +#include + +namespace OSTPlatform::DynamicLibrary { +namespace { + +constexpr DWORD kMaxWin32PathBuffer = 32768; + +std::string WidePathToUtf8(std::wstring_view path, const char* operation) { + std::string result = Encoding::WideToUtf8(path); + if (result.empty() && !path.empty()) { + OSTP_LOG_DEBUG("{}: failed to convert path to UTF-8", operation); + } + return result; +} + +std::filesystem::path ModuleFilePath(HMODULE module, const char* operation) { + for (DWORD capacity = MAX_PATH; capacity <= kMaxWin32PathBuffer; capacity *= 2) { + std::vector buffer(capacity); + SetLastError(ERROR_SUCCESS); + const DWORD len = GetModuleFileNameW(module, buffer.data(), capacity); + if (len == 0) { + OSTP_LOG_WARN("{}: GetModuleFileNameW failed (error={})", operation, GetLastError()); + return {}; + } + if (len < capacity) { + return std::filesystem::path(std::wstring_view(buffer.data(), len)); + } + } + + OSTP_LOG_WARN("{}: GetModuleFileNameW path exceeds {} wchars", operation, kMaxWin32PathBuffer); + return {}; +} + +std::string DirectoryFromQuery(DWORD (*query)(DWORD, wchar_t*), const char* operation) { + for (DWORD capacity = MAX_PATH; capacity <= kMaxWin32PathBuffer; capacity *= 2) { + std::vector buffer(capacity); + const DWORD len = query(capacity, buffer.data()); + if (len == 0) { + OSTP_LOG_WARN("{} failed (error={})", operation, GetLastError()); + return {}; + } + if (len < capacity) { + return WidePathToUtf8(std::wstring_view(buffer.data(), len), operation); + } + if (len > capacity) { + capacity = len; + } + } + + OSTP_LOG_WARN("{} path exceeds {} wchars", operation, kMaxWin32PathBuffer); + return {}; +} + +DWORD GetCurrentDirectoryThunk(DWORD capacity, wchar_t* buffer) { + return GetCurrentDirectoryW(capacity, buffer); +} + +DWORD GetSystemDirectoryThunk(DWORD capacity, wchar_t* buffer) { + return GetSystemDirectoryW(buffer, capacity); +} + +} // namespace + +ModuleHandle Load(const std::filesystem::path& path) { + ModuleHandle module = reinterpret_cast(LoadLibraryW(path.wstring().c_str())); + if (!module) { + OSTP_LOG_WARN("LoadLibraryW('{}') failed (error={})", path.string(), GetLastError()); + } + return module; +} + +ModuleHandle GetLoaded(std::string_view moduleName) { + const std::wstring wideName = Encoding::Utf8ToWide(moduleName); + if (wideName.empty() && !moduleName.empty()) { + OSTP_LOG_DEBUG("GetLoaded('{}'): module name conversion failed", moduleName); + return nullptr; + } + return reinterpret_cast(GetModuleHandleW(wideName.c_str())); +} + +void* GetSymbol(ModuleHandle module, const char* symbolName) { + if (!module) return nullptr; + void* symbol = reinterpret_cast(GetProcAddress(reinterpret_cast(module), symbolName)); + if (!symbol) { + OSTP_LOG_DEBUG("GetProcAddress('{}') failed (error={})", symbolName ? symbolName : "", GetLastError()); + } + return symbol; +} + +void* GetSymbol(ModuleHandle module, uint16_t ordinal) { + if (!module) return nullptr; + void* symbol = reinterpret_cast(GetProcAddress(reinterpret_cast(module), MAKEINTRESOURCEA(ordinal))); + if (!symbol) { + OSTP_LOG_DEBUG("GetProcAddress(ordinal={}) failed (error={})", ordinal, GetLastError()); + } + return symbol; +} + +uint32_t GetLastErrorCode() { + return GetLastError(); +} + +std::string GetCurrentDirectoryPath() { + return DirectoryFromQuery(GetCurrentDirectoryThunk, "GetCurrentDirectoryW"); +} + +std::string GetSystemDirectoryPath() { + return DirectoryFromQuery(GetSystemDirectoryThunk, "GetSystemDirectoryW"); +} + +std::filesystem::path GetModuleDirectory(ModuleHandle module) { + return ModuleFilePath(reinterpret_cast(module), "GetModuleDirectory").parent_path(); +} + +std::filesystem::path GetMainExecutablePath() { + return ModuleFilePath(nullptr, "GetMainExecutablePath"); +} + +} // namespace OSTPlatform::DynamicLibrary diff --git a/src/OSTPlatform/Windows/Handles.h b/src/OSTPlatform/Windows/Handles.h new file mode 100644 index 0000000..6553905 --- /dev/null +++ b/src/OSTPlatform/Windows/Handles.h @@ -0,0 +1,150 @@ +#pragma once + +#include + +#include +#include + +// Private move-only RAII wrappers for Win32 resources owned by Windows/*.cpp. +namespace OSTPlatform::Windows { + +struct NullHandlePolicy { + static bool IsValid(HANDLE h) noexcept { return h != nullptr; } + static void Close(HANDLE h) noexcept { ::CloseHandle(h); } +}; + +struct InvalidHandlePolicy { + static bool IsValid(HANDLE h) noexcept { return h != nullptr && h != INVALID_HANDLE_VALUE; } + static void Close(HANDLE h) noexcept { ::CloseHandle(h); } +}; + +template +class UniqueHandleT { +public: + UniqueHandleT() noexcept = default; + explicit UniqueHandleT(HANDLE handle) noexcept : handle_(handle) {} + + UniqueHandleT(UniqueHandleT&& other) noexcept : handle_(other.handle_) { + other.handle_ = nullptr; + } + UniqueHandleT& operator=(UniqueHandleT&& other) noexcept { + if (this != &other) { + Reset(other.handle_); + other.handle_ = nullptr; + } + return *this; + } + + UniqueHandleT(const UniqueHandleT&) = delete; + UniqueHandleT& operator=(const UniqueHandleT&) = delete; + + ~UniqueHandleT() { Reset(); } + + HANDLE get() const noexcept { return handle_; } + explicit operator bool() const noexcept { return Policy::IsValid(handle_); } + + void Reset(HANDLE handle = nullptr) noexcept { + if (Policy::IsValid(handle_)) Policy::Close(handle_); + handle_ = handle; + } + +private: + HANDLE handle_ = nullptr; +}; + +using UniqueHandle = UniqueHandleT; +using UniqueFileHandle = UniqueHandleT; + +class UniqueRegKey { +public: + UniqueRegKey() noexcept = default; + + UniqueRegKey(UniqueRegKey&& other) noexcept : key_(other.key_) { other.key_ = nullptr; } + UniqueRegKey& operator=(UniqueRegKey&& other) noexcept { + if (this != &other) { + Reset(); + key_ = other.key_; + other.key_ = nullptr; + } + return *this; + } + + UniqueRegKey(const UniqueRegKey&) = delete; + UniqueRegKey& operator=(const UniqueRegKey&) = delete; + + ~UniqueRegKey() { Reset(); } + + HKEY get() const noexcept { return key_; } + explicit operator bool() const noexcept { return key_ != nullptr; } + + HKEY* put() noexcept { + Reset(); + return &key_; + } + + void Reset() noexcept { + if (key_) ::RegCloseKey(key_); + key_ = nullptr; + } + +private: + HKEY key_ = nullptr; +}; + +class UniqueMapView { +public: + UniqueMapView() noexcept = default; + explicit UniqueMapView(const uint8_t* base) noexcept : base_(base) {} + + UniqueMapView(UniqueMapView&& other) noexcept : base_(other.base_) { other.base_ = nullptr; } + UniqueMapView& operator=(UniqueMapView&& other) noexcept { + if (this != &other) { + Reset(); + base_ = other.base_; + other.base_ = nullptr; + } + return *this; + } + + UniqueMapView(const UniqueMapView&) = delete; + UniqueMapView& operator=(const UniqueMapView&) = delete; + + ~UniqueMapView() { Reset(); } + + const uint8_t* get() const noexcept { return base_; } + explicit operator bool() const noexcept { return base_ != nullptr; } + + void Reset() noexcept { + if (base_) ::UnmapViewOfFile(base_); + base_ = nullptr; + } + +private: + const uint8_t* base_ = nullptr; +}; + +template +class ScopeExit { +public: + explicit ScopeExit(Fn fn) : fn_(std::move(fn)) {} + + ScopeExit(const ScopeExit&) = delete; + ScopeExit& operator=(const ScopeExit&) = delete; + ScopeExit(ScopeExit&&) = delete; + ScopeExit& operator=(ScopeExit&&) = delete; + + ~ScopeExit() { + if (active_) fn_(); + } + + void Dismiss() noexcept { active_ = false; } + +private: + Fn fn_; + bool active_ = true; +}; + +template +ScopeExit(Fn) -> ScopeExit; + +} // namespace OSTPlatform::Windows diff --git a/src/OSTPlatform/Windows/Hash.cpp b/src/OSTPlatform/Windows/Hash.cpp new file mode 100644 index 0000000..7ceb9b6 --- /dev/null +++ b/src/OSTPlatform/Windows/Hash.cpp @@ -0,0 +1,115 @@ +#include "include/Hash.h" + +#include "include/Log.h" + +#include +#include + +#include +#include + +namespace OSTPlatform::Hash { + +namespace { + +uint32_t StatusCode(NTSTATUS status) { + return static_cast(status); +} + +} // namespace + +std::string Sha256OfFile(const std::filesystem::path& path) { + HANDLE hFile = CreateFileW(path.wstring().c_str(), GENERIC_READ, FILE_SHARE_READ, + nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (hFile == INVALID_HANDLE_VALUE) { + OSTP_LOG_WARN("Sha256OfFile: CreateFileW failed for '{}' (error={})", + path.string(), GetLastError()); + return {}; + } + + BCRYPT_ALG_HANDLE hAlg = nullptr; + NTSTATUS status = BCryptOpenAlgorithmProvider(&hAlg, BCRYPT_SHA256_ALGORITHM, nullptr, 0); + if (!BCRYPT_SUCCESS(status)) { + OSTP_LOG_WARN("Sha256OfFile: BCryptOpenAlgorithmProvider failed (status=0x{:08X})", + StatusCode(status)); + CloseHandle(hFile); + return {}; + } + + BCRYPT_HASH_HANDLE hHash = nullptr; + auto cleanup = [&] { + if (hHash) BCryptDestroyHash(hHash); + BCryptCloseAlgorithmProvider(hAlg, 0); + CloseHandle(hFile); + }; + + DWORD cbData = 0; + DWORD hashObjSize = 0; + status = BCryptGetProperty(hAlg, BCRYPT_OBJECT_LENGTH, reinterpret_cast(&hashObjSize), + sizeof(DWORD), &cbData, 0); + if (!BCRYPT_SUCCESS(status) || hashObjSize == 0) { + OSTP_LOG_WARN("Sha256OfFile: BCryptGetProperty(BCRYPT_OBJECT_LENGTH) failed (status=0x{:08X}, size={})", + StatusCode(status), hashObjSize); + cleanup(); + return {}; + } + std::vector hashObj(hashObjSize); + + DWORD hashSize = 0; + status = BCryptGetProperty(hAlg, BCRYPT_HASH_LENGTH, reinterpret_cast(&hashSize), + sizeof(DWORD), &cbData, 0); + if (!BCRYPT_SUCCESS(status) || hashSize == 0) { + OSTP_LOG_WARN("Sha256OfFile: BCryptGetProperty(BCRYPT_HASH_LENGTH) failed (status=0x{:08X}, size={})", + StatusCode(status), hashSize); + cleanup(); + return {}; + } + std::vector hashBuf(hashSize); + + status = BCryptCreateHash(hAlg, &hHash, hashObj.data(), hashObjSize, nullptr, 0, 0); + if (!BCRYPT_SUCCESS(status)) { + OSTP_LOG_WARN("Sha256OfFile: BCryptCreateHash failed (status=0x{:08X})", StatusCode(status)); + cleanup(); + return {}; + } + + constexpr DWORD kChunk = 65536; + std::vector buf(kChunk); + for (;;) { + DWORD bytesRead = 0; + // A read failure must not be mistaken for EOF: hashing partial content + // would yield a valid-looking but wrong digest. + if (!ReadFile(hFile, buf.data(), kChunk, &bytesRead, nullptr)) { + OSTP_LOG_WARN("Sha256OfFile: ReadFile failed for '{}' (error={})", + path.string(), GetLastError()); + cleanup(); + return {}; + } + if (bytesRead == 0) break; + status = BCryptHashData(hHash, buf.data(), bytesRead, 0); + if (!BCRYPT_SUCCESS(status)) { + OSTP_LOG_WARN("Sha256OfFile: BCryptHashData failed (status=0x{:08X})", StatusCode(status)); + cleanup(); + return {}; + } + } + + status = BCryptFinishHash(hHash, hashBuf.data(), hashSize, 0); + if (!BCRYPT_SUCCESS(status)) { + OSTP_LOG_WARN("Sha256OfFile: BCryptFinishHash failed (status=0x{:08X})", StatusCode(status)); + cleanup(); + return {}; + } + cleanup(); + + static constexpr char kHex[] = "0123456789abcdef"; + std::string result; + result.reserve(hashSize * 2); + for (uint8_t b : hashBuf) { + result += kHex[b >> 4]; + result += kHex[b & 0xF]; + } + return result; +} + +} // namespace OSTPlatform::Hash diff --git a/src/OSTPlatform/Windows/Http.cpp b/src/OSTPlatform/Windows/Http.cpp new file mode 100644 index 0000000..92817ab --- /dev/null +++ b/src/OSTPlatform/Windows/Http.cpp @@ -0,0 +1,192 @@ +#include "include/Http.h" + +#include "include/Log.h" +#include "include/Numbers.h" + +#include +#include + +#include +#include +#include + +namespace OSTPlatform::Http { +namespace { + +struct ParsedUrl { + std::wstring host; + std::wstring path; + INTERNET_PORT port = INTERNET_DEFAULT_HTTP_PORT; + bool tls = false; + bool valid = false; +}; + +ParsedUrl ParseUrl(const char* rawUrl) { + ParsedUrl out; + if (!rawUrl) return out; + + std::string url(rawUrl); + if (url.starts_with("https://")) { + out.tls = true; + out.port = INTERNET_DEFAULT_HTTPS_PORT; + url = url.substr(8); + } else if (url.starts_with("http://")) { + url = url.substr(7); + } else { + return out; + } + + const size_t slash = url.find('/'); + const std::string hostPart = url.substr(0, slash); + out.path = (slash != std::string::npos) + ? L"/" + std::wstring(url.begin() + slash + 1, url.end()) + : L"/"; + + const size_t colon = hostPart.find(':'); + if (colon != std::string::npos) { + out.host = std::wstring(hostPart.begin(), hostPart.begin() + colon); + const auto port = Numbers::ParseUInt32(hostPart, {colon + 1}); + if (!port || *port == 0 || *port > 65535) return out; + out.port = static_cast(*port); + } else { + out.host = std::wstring(hostPart.begin(), hostPart.end()); + } + + out.valid = !out.host.empty(); + return out; +} + +} // namespace + +Result Execute(const wchar_t* method, + const char* url, + const void* reqBody, + uint32_t reqBodyLen, + const wchar_t* headers, + uint32_t timeoutResolve, + uint32_t timeoutConnect, + uint32_t timeoutSend, + uint32_t timeoutRecv) { + Result r; + + ParsedUrl pu = ParseUrl(url); + if (!pu.valid) { + OSTP_LOG_WARN("Invalid URL: {}", url ? url : ""); + return r; + } + + auto t0 = std::chrono::steady_clock::now(); + + HINTERNET hSession = WinHttpOpen(L"OpenSteamTool/1.0", + WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, + WINHTTP_NO_PROXY_BYPASS, + 0); + if (!hSession) { + OSTP_LOG_WARN("{} - WinHttpOpen failed (error={})", url ? url : "", GetLastError()); + return r; + } + + WinHttpSetTimeouts(hSession, timeoutResolve, timeoutConnect, timeoutSend, timeoutRecv); + + HINTERNET hConnect = WinHttpConnect(hSession, pu.host.c_str(), pu.port, 0); + if (!hConnect) { + OSTP_LOG_WARN("{} - WinHttpConnect(host='{}', port={}) failed (error={})", + url ? url : "", + std::string(pu.host.begin(), pu.host.end()), + pu.port, + GetLastError()); + WinHttpCloseHandle(hSession); + return r; + } + + const DWORD flags = pu.tls ? WINHTTP_FLAG_SECURE : 0; + HINTERNET hRequest = WinHttpOpenRequest( + hConnect, + method, + pu.path.c_str(), + nullptr, + WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, + flags); + if (!hRequest) { + OSTP_LOG_WARN("{} - WinHttpOpenRequest failed (error={})", url ? url : "", GetLastError()); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + return r; + } + + if (headers && headers[0]) { + if (!WinHttpAddRequestHeaders( + hRequest, + headers, + static_cast(wcslen(headers)), + WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE)) { + OSTP_LOG_WARN("{} - WinHttpAddRequestHeaders failed (error={})", url ? url : "", GetLastError()); + } + } + + const DWORD totalLen = reqBodyLen; + if (!WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + const_cast(reqBody), reqBodyLen, totalLen, 0)) { + OSTP_LOG_WARN("{} - WinHttpSendRequest failed (error={})", url, GetLastError()); + } else if (!WinHttpReceiveResponse(hRequest, nullptr)) { + OSTP_LOG_WARN("{} - WinHttpReceiveResponse failed (error={})", url, GetLastError()); + } else { + DWORD sz = sizeof(r.status); + if (!WinHttpQueryHeaders( + hRequest, + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, + &r.status, + &sz, + WINHTTP_NO_HEADER_INDEX)) { + OSTP_LOG_WARN("{} - WinHttpQueryHeaders(status) failed (error={})", url ? url : "", GetLastError()); + } + + DWORD avail = 0; + while (true) { + if (!WinHttpQueryDataAvailable(hRequest, &avail)) { + OSTP_LOG_WARN("{} - WinHttpQueryDataAvailable failed (error={})", url ? url : "", GetLastError()); + break; + } + if (!avail) break; + + const size_t off = r.body.size(); + r.body.resize(off + avail); + DWORD read = 0; + if (!WinHttpReadData(hRequest, r.body.data() + off, avail, &read)) { + OSTP_LOG_WARN("{} - WinHttpReadData(size={}) failed (error={})", + url ? url : "", avail, GetLastError()); + r.body.resize(off); + break; + } + r.body.resize(off + read); + if (r.body.size() > 256 * 1024) break; + } + + if (r.status < 200 || r.status >= 300) { + OSTP_LOG_WARN("{} - unexpected HTTP {} body={}", + url, + r.status, + r.body.size() > 512 ? r.body.substr(0, 512) + "..." : r.body); + } else { + OSTP_LOG_TRACE("{} - response body={} ({}bytes)", + url ? url : "", r.body, r.body.size()); + } + r.ok = true; + } + + WinHttpCloseHandle(hRequest); + WinHttpCloseHandle(hConnect); + WinHttpCloseHandle(hSession); + + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + OSTP_LOG_DEBUG("{} - elapsed: {}ms status={} body_bytes={}", + url ? url : "", elapsed, r.status, r.body.size()); + + return r; +} + +} // namespace OSTPlatform::Http diff --git a/src/OSTPlatform/Windows/Memory.cpp b/src/OSTPlatform/Windows/Memory.cpp new file mode 100644 index 0000000..fa82ce3 --- /dev/null +++ b/src/OSTPlatform/Windows/Memory.cpp @@ -0,0 +1,51 @@ +#include "include/Memory.h" + +#include "include/Log.h" + +#include +#include + +namespace OSTPlatform::Memory { + +std::optional GetModuleImage(DynamicLibrary::ModuleHandle module) { + MODULEINFO info{}; + if (!module || !GetModuleInformation(GetCurrentProcess(), reinterpret_cast(module), &info, sizeof(info))) { + OSTP_LOG_DEBUG("GetModuleImage(module={}) failed (error={})", module, GetLastError()); + return std::nullopt; + } + + return ModuleImage{ + static_cast(info.lpBaseOfDll), + static_cast(info.SizeOfImage), + }; +} + +bool WriteExecutableByte(void* target, uint8_t value) { + if (!target) { + OSTP_LOG_WARN("WriteExecutableByte: target is null"); + return false; + } + + DWORD oldProtect = 0; + if (!VirtualProtect(target, 1, PAGE_EXECUTE_READWRITE, &oldProtect)) { + OSTP_LOG_WARN("WriteExecutableByte(target={}) VirtualProtect(RWX) failed (error={})", + target, GetLastError()); + return false; + } + *static_cast(target) = value; + + DWORD ignored = 0; + if (!VirtualProtect(target, 1, oldProtect, &ignored)) { + OSTP_LOG_WARN("WriteExecutableByte(target={}) VirtualProtect(restore=0x{:X}) failed (error={})", + target, oldProtect, GetLastError()); + return false; + } + if (!FlushInstructionCache(GetCurrentProcess(), target, 1)) { + OSTP_LOG_WARN("WriteExecutableByte(target={}) FlushInstructionCache failed (error={})", + target, GetLastError()); + return false; + } + return true; +} + +} // namespace OSTPlatform::Memory diff --git a/src/OSTPlatform/Windows/NtAbi.h b/src/OSTPlatform/Windows/NtAbi.h new file mode 100644 index 0000000..a833861 --- /dev/null +++ b/src/OSTPlatform/Windows/NtAbi.h @@ -0,0 +1,72 @@ +#pragma once + +#include + +#include +#include + +namespace OSTPlatform::Windows::NtAbi { + + enum class ProcessInfoClass : ULONG { + BasicInformation = 0, + Wow64Information = 26, + }; + + enum class MemoryInfoClass : ULONG { + BasicInformation = 0, + }; + + struct ProcessBasicInformation { + NTSTATUS exitStatus; + PVOID pebBaseAddress; + ULONG_PTR affinityMask; + LONG basePriority; + ULONG_PTR uniqueProcessId; + ULONG_PTR inheritedFromUniqueProcessId; + }; + + using NtQueryInformationProcessFn = NTSTATUS (NTAPI*)( + HANDLE processHandle, + ProcessInfoClass processInformationClass, + PVOID processInformation, + ULONG processInformationLength, + PULONG returnLength); + + using NtQueryVirtualMemoryFn = NTSTATUS (NTAPI*)( + HANDLE processHandle, + PVOID baseAddress, + MemoryInfoClass memoryInformationClass, + PVOID memoryInformation, + SIZE_T memoryInformationLength, + PSIZE_T returnLength); + + constexpr bool NtSuccess(NTSTATUS status) { + return status >= 0; + } + + struct Peb { + BYTE reserved0[0x20]; + PVOID processParameters; + }; + + struct RtlUserProcessParameters { + BYTE reserved0[0x80]; + PVOID environment; + }; + + struct Peb32 { + BYTE reserved0[0x10]; + uint32_t processParameters; + }; + + struct RtlUserProcessParameters32 { + BYTE reserved0[0x48]; + uint32_t environment; + }; + + static_assert(offsetof(Peb, processParameters) == 0x20); + static_assert(offsetof(RtlUserProcessParameters, environment) == 0x80); + static_assert(offsetof(Peb32, processParameters) == 0x10); + static_assert(offsetof(RtlUserProcessParameters32, environment) == 0x48); + +} // namespace OSTPlatform::Windows::NtAbi diff --git a/src/OSTPlatform/Windows/PE.cpp b/src/OSTPlatform/Windows/PE.cpp new file mode 100644 index 0000000..68166b2 --- /dev/null +++ b/src/OSTPlatform/Windows/PE.cpp @@ -0,0 +1,367 @@ +#include "include/PE.h" + +#include "Windows/Handles.h" +#include "include/Log.h" +#include "include/Stopwatch.h" + +#include + +#include +#include + +namespace OSTPlatform::PE { +namespace { + +constexpr size_t kHeaderReadBytes = 4 * 1024 * 1024; + +struct FileRead { + ByteBuffer bytes; + uint64_t fileSize = 0; +}; + +// Open a file for buffered sequential reading. FILE_FLAG_SEQUENTIAL_SCAN asks +// the cache manager to read ahead aggressively and drop pages behind the +// cursor — the right hint for a single forward pass over a large module image. +// Sharing is permissive because the target module is usually open/executing. +Windows::UniqueFileHandle OpenForSequentialRead(const std::filesystem::path& path) { + return Windows::UniqueFileHandle(::CreateFileW( + path.c_str(), + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + nullptr, + OPEN_EXISTING, + FILE_FLAG_SEQUENTIAL_SCAN, + nullptr)); +} + +// ReadFile caps each call at a DWORD and may return fewer bytes than asked; +// loop until the whole buffer is filled. Returns false on error or premature EOF. +bool ReadFull(HANDLE file, uint8_t* dst, size_t total) { + size_t done = 0; + while (done < total) { + const DWORD chunk = static_cast( + (std::min)(total - done, static_cast(0x40000000))); // 1 GiB cap + DWORD got = 0; + if (!::ReadFile(file, dst + done, chunk, &got, nullptr)) return false; + if (got == 0) break; // unexpected EOF before `total` + done += got; + } + return done == total; +} + +FileRead ReadFileRange(const std::filesystem::path& path, uint64_t offset, size_t requestedSize) { + const Stopwatch totalTimer; + const Windows::UniqueFileHandle file = OpenForSequentialRead(path); + if (!file) { + OSTP_LOG_DEBUG("PE::ReadFileRange open failed path={} offset=0x{:X} requested={} err={}", + path.string(), offset, requestedSize, ::GetLastError()); + return {}; + } + + LARGE_INTEGER sizeLi{}; + if (!::GetFileSizeEx(file.get(), &sizeLi) || sizeLi.QuadPart <= 0) { + OSTP_LOG_DEBUG("PE::ReadFileRange empty/invalid file path={} offset=0x{:X} requested={}", + path.string(), offset, requestedSize); + return {}; + } + + const uint64_t fileSize = static_cast(sizeLi.QuadPart); + if (offset >= fileSize) { + OSTP_LOG_DEBUG("PE::ReadFileRange offset past EOF path={} offset=0x{:X} file_size={} elapsed_ms={:.3f}", + path.string(), offset, fileSize, totalTimer.ElapsedMs()); + return {{}, fileSize}; + } + + const size_t readSize = static_cast((std::min)( + static_cast(requestedSize), + fileSize - offset)); + + ByteBuffer bytes(readSize); // default-init allocator: no redundant zero-fill before read + + LARGE_INTEGER seekTo{}; + seekTo.QuadPart = static_cast(offset); + if (!::SetFilePointerEx(file.get(), seekTo, nullptr, FILE_BEGIN)) { + OSTP_LOG_DEBUG("PE::ReadFileRange seek failed path={} offset=0x{:X} err={}", + path.string(), offset, ::GetLastError()); + return {}; + } + + const Stopwatch readTimer; + if (!ReadFull(file.get(), bytes.data(), readSize)) { + OSTP_LOG_DEBUG("PE::ReadFileRange read failed path={} offset=0x{:X} requested={} read_size={} err={} elapsed_ms={:.3f}", + path.string(), offset, requestedSize, readSize, ::GetLastError(), totalTimer.ElapsedMs()); + return {}; + } + const double readMs = readTimer.ElapsedMs(); + + OSTP_LOG_DEBUG("PE::ReadFileRange path={} offset=0x{:X} requested={} read={} file_size={} read_ms={:.3f} total_ms={:.3f}", + path.string(), offset, requestedSize, readSize, fileSize, readMs, totalTimer.ElapsedMs()); + + return {std::move(bytes), fileSize}; +} + +ByteBuffer ReadFileBytes(const std::filesystem::path& path) { + const Stopwatch totalTimer; + const Windows::UniqueFileHandle file = OpenForSequentialRead(path); + if (!file) { + OSTP_LOG_DEBUG("PE::ReadFileBytes open failed path={} err={}", path.string(), ::GetLastError()); + return {}; + } + + LARGE_INTEGER sizeLi{}; + if (!::GetFileSizeEx(file.get(), &sizeLi) || sizeLi.QuadPart <= 0) { + OSTP_LOG_DEBUG("PE::ReadFileBytes empty/invalid file path={}", path.string()); + return {}; + } + + const size_t size = static_cast(sizeLi.QuadPart); + ByteBuffer bytes(size); + + const Stopwatch readTimer; + if (!ReadFull(file.get(), bytes.data(), size)) { + OSTP_LOG_DEBUG("PE::ReadFileBytes read failed path={} size={} err={} elapsed_ms={:.3f}", + path.string(), static_cast(size), ::GetLastError(), totalTimer.ElapsedMs()); + return {}; + } + const double readMs = readTimer.ElapsedMs(); + + OSTP_LOG_DEBUG("PE::ReadFileBytes path={} size={} read_ms={:.3f} total_ms={:.3f}", + path.string(), static_cast(size), readMs, totalTimer.ElapsedMs()); + return bytes; +} + +template +const T* PtrAt(std::span bytes, size_t offset) { + if (offset > bytes.size() || sizeof(T) > bytes.size() - offset) return nullptr; + return reinterpret_cast(bytes.data() + offset); +} + +const char* StringAt(std::span bytes, size_t offset) { + if (offset >= bytes.size()) return nullptr; + const auto* str = reinterpret_cast(bytes.data() + offset); + const void* terminator = std::memchr(str, '\0', bytes.size() - offset); + return terminator ? str : nullptr; +} + +std::string SectionName(const IMAGE_SECTION_HEADER& section) { + char name[9] = {}; + std::memcpy(name, section.Name, 8); + return name; +} + +} // namespace + +bool Section::ContainsRva(uint32_t rva) const { + const uint32_t size = (std::max)(virtualSize, rawSize); + const uint32_t end = virtualAddress + size; + return end >= virtualAddress && rva >= virtualAddress && rva < end; +} + +Image::Image(const std::filesystem::path& path) : path_(path) { + const Stopwatch totalTimer; + FileRead header = ReadFileRange(path, 0, kHeaderReadBytes); + headerBytes_ = std::move(header.bytes); + fileSize_ = header.fileSize; + if (headerBytes_.empty()) { + OSTP_LOG_DEBUG("PE::Image invalid empty header path={} elapsed_ms={:.3f}", + path.string(), totalTimer.ElapsedMs()); + return; + } + + const auto* dos = PtrAt(headerBytes_, 0); + if (!dos || dos->e_magic != IMAGE_DOS_SIGNATURE || dos->e_lfanew < 0) { + OSTP_LOG_DEBUG("PE::Image invalid DOS header path={} elapsed_ms={:.3f}", + path.string(), totalTimer.ElapsedMs()); + return; + } + + const size_t ntOffset = static_cast(dos->e_lfanew); + const auto* signature = PtrAt(headerBytes_, ntOffset); + if (!signature || *signature != IMAGE_NT_SIGNATURE) { + OSTP_LOG_DEBUG("PE::Image invalid NT signature path={} nt_offset=0x{:X} elapsed_ms={:.3f}", + path.string(), ntOffset, totalTimer.ElapsedMs()); + return; + } + + const size_t fileHeaderOffset = ntOffset + sizeof(DWORD); + const auto* fileHeader = PtrAt(headerBytes_, fileHeaderOffset); + if (!fileHeader || fileHeader->NumberOfSections == 0) { + OSTP_LOG_DEBUG("PE::Image invalid file header path={} elapsed_ms={:.3f}", + path.string(), totalTimer.ElapsedMs()); + return; + } + + const size_t optionalOffset = fileHeaderOffset + sizeof(IMAGE_FILE_HEADER); + const auto* magic = PtrAt(headerBytes_, optionalOffset); + if (!magic) { + OSTP_LOG_DEBUG("PE::Image missing optional header path={} elapsed_ms={:.3f}", + path.string(), totalTimer.ElapsedMs()); + return; + } + + IMAGE_DATA_DIRECTORY exportDirectory{}; + if (*magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC) { + const auto* optional = PtrAt(headerBytes_, optionalOffset); + if (!optional) return; + entryPointRva_ = optional->AddressOfEntryPoint; + exportDirectory = optional->DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; + } else if (*magic == IMAGE_NT_OPTIONAL_HDR32_MAGIC) { + const auto* optional = PtrAt(headerBytes_, optionalOffset); + if (!optional) return; + entryPointRva_ = optional->AddressOfEntryPoint; + exportDirectory = optional->DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]; + } else { + OSTP_LOG_DEBUG("PE::Image unsupported optional header path={} magic=0x{:X} elapsed_ms={:.3f}", + path.string(), *magic, totalTimer.ElapsedMs()); + return; + } + + const size_t sectionsOffset = optionalOffset + fileHeader->SizeOfOptionalHeader; + const size_t sectionsEnd = sectionsOffset + + static_cast(fileHeader->NumberOfSections) * sizeof(IMAGE_SECTION_HEADER); + if (sectionsEnd > headerBytes_.size()) { + OSTP_LOG_DEBUG("PE::Image section headers outside header buffer path={} sections={} elapsed_ms={:.3f}", + path.string(), fileHeader->NumberOfSections, totalTimer.ElapsedMs()); + return; + } + + sections_.reserve(fileHeader->NumberOfSections); + for (WORD i = 0; i < fileHeader->NumberOfSections; ++i) { + const auto* section = PtrAt( + headerBytes_, + sectionsOffset + static_cast(i) * sizeof(IMAGE_SECTION_HEADER)); + if (!section) { + OSTP_LOG_DEBUG("PE::Image missing section header path={} index={} elapsed_ms={:.3f}", + path.string(), i, totalTimer.ElapsedMs()); + return; + } + + sections_.push_back(Section{ + SectionName(*section), + section->VirtualAddress, + section->Misc.VirtualSize, + section->PointerToRawData, + section->SizeOfRawData, + }); + } + + exportDirectoryRva_ = exportDirectory.VirtualAddress; + exportDirectorySize_ = exportDirectory.Size; + valid_ = true; + OSTP_LOG_DEBUG("PE::Image parsed path={} file_size={} sections={} entry=0x{:X} elapsed_ms={:.3f}", + path.string(), fileSize_, sections_.size(), entryPointRva_, totalTimer.ElapsedMs()); +} + +bool Image::HasSection(std::string_view name) const { + return std::ranges::any_of(sections_, [name](const Section& section) { + return section.name == name; + }); +} + +const Section* Image::SectionContainingRva(uint32_t rva) const { + for (const Section& section : sections_) { + if (section.ContainsRva(rva)) return §ion; + } + return nullptr; +} + +std::optional Image::RvaToOffset(uint32_t rva) const { + const Section* section = SectionContainingRva(rva); + if (!section) return std::nullopt; + + const uint32_t delta = rva - section->virtualAddress; + if (delta >= section->rawSize) return std::nullopt; + + const size_t offset = static_cast(section->rawOffset) + delta; + return offset < fileSize_ ? std::optional(offset) : std::nullopt; +} + +std::optional Image::RawOffsetToRva(size_t rawOffset) const { + for (const Section& section : sections_) { + const size_t sectionRaw = section.rawOffset; + const size_t sectionEnd = sectionRaw + section.rawSize; + if (sectionEnd < sectionRaw || rawOffset < sectionRaw || rawOffset >= sectionEnd) continue; + + const size_t delta = rawOffset - sectionRaw; + if (delta > UINT32_MAX) return std::nullopt; + return section.virtualAddress + static_cast(delta); + } + return std::nullopt; +} + +ByteBuffer Image::ReadRawBytes(size_t offset, size_t size) const { + return ReadFileRange(path_, offset, size).bytes; +} + +ByteBuffer Image::ReadAllBytes() const { + return ReadFileBytes(path_); +} + +std::optional Image::FindExport(std::string_view symbolName) const { + if (!valid_ || exportDirectoryRva_ == 0 || exportDirectorySize_ == 0) return std::nullopt; + + const auto exportOffset = RvaToOffset(exportDirectoryRva_); + if (!exportOffset) return std::nullopt; + + const Section* exportSection = SectionContainingRva(exportDirectoryRva_); + if (!exportSection) return std::nullopt; + + const ByteBuffer exportBytes = ReadRawBytes(exportSection->rawOffset, exportSection->rawSize); + if (exportBytes.empty()) return std::nullopt; + + auto viewAtRva = [&](uint32_t rva, size_t minSize = 0) -> std::optional> { + const auto raw = RvaToOffset(rva); + if (!raw || *raw < exportSection->rawOffset) return std::nullopt; + + const size_t local = *raw - exportSection->rawOffset; + if (local > exportBytes.size() || minSize > exportBytes.size() - local) return std::nullopt; + return std::span(exportBytes).subspan(local); + }; + + const auto exportView = viewAtRva(exportDirectoryRva_, sizeof(IMAGE_EXPORT_DIRECTORY)); + if (!exportView) return std::nullopt; + + const auto* exports = PtrAt(*exportView, 0); + if (!exports || exports->NumberOfNames == 0 || exports->NumberOfFunctions == 0) return std::nullopt; + + const auto namesView = viewAtRva(exports->AddressOfNames, static_cast(exports->NumberOfNames) * sizeof(DWORD)); + const auto ordinalsView = viewAtRva(exports->AddressOfNameOrdinals, static_cast(exports->NumberOfNames) * sizeof(WORD)); + const auto functionsView = viewAtRva(exports->AddressOfFunctions, static_cast(exports->NumberOfFunctions) * sizeof(DWORD)); + if (!namesView || !ordinalsView || !functionsView) return std::nullopt; + + for (DWORD i = 0; i < exports->NumberOfNames; ++i) { + const auto* nameRva = PtrAt(*namesView, static_cast(i) * sizeof(DWORD)); + if (!nameRva) return std::nullopt; + + const auto nameView = viewAtRva(*nameRva); + if (!nameView) continue; + + const char* exportedName = StringAt(*nameView, 0); + if (!exportedName || symbolName != exportedName) continue; + + const auto* ordinal = PtrAt(*ordinalsView, static_cast(i) * sizeof(WORD)); + if (!ordinal || *ordinal >= exports->NumberOfFunctions) return std::nullopt; + + const auto* functionRva = PtrAt(*functionsView, static_cast(*ordinal) * sizeof(DWORD)); + if (!functionRva || *functionRva == 0) return std::nullopt; + + Export result{}; + result.rva = *functionRva; + + const uint32_t exportEnd = exportDirectoryRva_ + exportDirectorySize_; + if (exportEnd >= exportDirectoryRva_ && + *functionRva >= exportDirectoryRva_ && + *functionRva < exportEnd) { + const auto forwarderView = viewAtRva(*functionRva); + const char* forwarder = forwarderView ? StringAt(*forwarderView, 0) : nullptr; + if (!forwarder) return std::nullopt; + result.forwarder = forwarder; + } + + return result; + } + + return std::nullopt; +} + +} // namespace OSTPlatform::PE diff --git a/src/OSTPlatform/Windows/Process.cpp b/src/OSTPlatform/Windows/Process.cpp new file mode 100644 index 0000000..b4fb663 --- /dev/null +++ b/src/OSTPlatform/Windows/Process.cpp @@ -0,0 +1,364 @@ +#include "include/Process.h" + +#include "include/Encoding.h" +#include "include/Log.h" + +#include "Windows/Handles.h" +#include "Windows/NtAbi.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OSTPlatform::Process { +namespace { + +namespace NtAbi = OSTPlatform::Windows::NtAbi; + +constexpr size_t kMaxEnvironmentBytes = 1024 * 1024; + +std::string NormalizePathForCompare(std::string path) { + for (char& ch : path) ch = static_cast(std::tolower(static_cast(ch))); + std::replace(path.begin(), path.end(), '/', '\\'); + return path; +} + +template +Fn GetNtDllProc(const char* name) { + HMODULE ntdll = GetModuleHandleW(L"ntdll.dll"); + if (!ntdll) return nullptr; + return reinterpret_cast(GetProcAddress(ntdll, name)); +} + +NtAbi::NtQueryInformationProcessFn NtQueryInformationProcessProc() { + return GetNtDllProc("NtQueryInformationProcess"); +} + +NtAbi::NtQueryVirtualMemoryFn NtQueryVirtualMemoryProc() { + return GetNtDllProc("NtQueryVirtualMemory"); +} + +Windows::UniqueHandle OpenProcessHandle(uint32_t pid, DWORD access) { + if (pid == 0) return {}; + Windows::UniqueHandle process(::OpenProcess(access, FALSE, pid)); + if (!process) { + OSTP_LOG_DEBUG("OpenProcess(pid={}, access=0x{:X}) failed (error={})", pid, access, GetLastError()); + } + return process; +} + +bool TryReadProcessMemory(HANDLE process, const void* address, void* buffer, size_t size, size_t* bytesRead = nullptr) { + SIZE_T read = 0; + const bool ok = ::ReadProcessMemory(process, address, buffer, size, &read) != FALSE; + if (!ok) { + OSTP_LOG_TRACE("ReadProcessMemory(addr={:#x}, size={}) failed (read={}, error={})", + reinterpret_cast(address), size, static_cast(read), GetLastError()); + } + if (bytesRead) *bytesRead = static_cast(read); + return ok; +} + +template +std::optional ReadRemoteValue(HANDLE process, const void* address) { + T value{}; + size_t bytesRead = 0; + if (!TryReadProcessMemory(process, address, &value, sizeof(value), &bytesRead) || + bytesRead != sizeof(value)) { + return std::nullopt; + } + return value; +} + +const void* AddOffset(const void* address, size_t offset) { + return reinterpret_cast(reinterpret_cast(address) + offset); +} + +std::optional QueryNativePebAddress(HANDLE process) { + const auto ntQueryInformationProcess = NtQueryInformationProcessProc(); + if (!ntQueryInformationProcess) return std::nullopt; + + NtAbi::ProcessBasicInformation info{}; + const NTSTATUS status = ntQueryInformationProcess( + process, + NtAbi::ProcessInfoClass::BasicInformation, + &info, + sizeof(info), + nullptr); + if (!NtAbi::NtSuccess(status) || !info.pebBaseAddress) return std::nullopt; + + return info.pebBaseAddress; +} + +std::optional QueryWow64PebAddress(HANDLE process) { + const auto ntQueryInformationProcess = NtQueryInformationProcessProc(); + if (!ntQueryInformationProcess) return std::nullopt; + + ULONG_PTR peb32 = 0; + const NTSTATUS status = ntQueryInformationProcess( + process, + NtAbi::ProcessInfoClass::Wow64Information, + &peb32, + sizeof(peb32), + nullptr); + if (!NtAbi::NtSuccess(status) || peb32 == 0) return std::nullopt; + return peb32; +} + +std::optional QueryNativeEnvironmentAddress(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 environment = ReadRemoteValue( + process, + AddOffset(*processParameters, offsetof(NtAbi::RtlUserProcessParameters, environment))); + if (!environment || !*environment) return std::nullopt; + + return *environment; +} + +std::optional QueryWow64EnvironmentAddress(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 environment32 = ReadRemoteValue( + process, + AddOffset(reinterpret_cast(static_cast(*processParameters32)), + offsetof(NtAbi::RtlUserProcessParameters32, environment))); + if (!environment32 || *environment32 == 0) return std::nullopt; + + return reinterpret_cast(static_cast(*environment32)); +} + +std::optional QueryReadableRegionBytes(HANDLE process, PVOID address) { + const auto ntQueryVirtualMemory = NtQueryVirtualMemoryProc(); + if (!ntQueryVirtualMemory) return std::nullopt; + + MEMORY_BASIC_INFORMATION info{}; + const NTSTATUS status = ntQueryVirtualMemory( + process, + address, + NtAbi::MemoryInfoClass::BasicInformation, + &info, + sizeof(info), + nullptr); + if (!NtAbi::NtSuccess(status) || info.RegionSize == 0) return std::nullopt; + + const uintptr_t base = reinterpret_cast(info.BaseAddress); + const uintptr_t env = reinterpret_cast(address); + if (env < base) return std::nullopt; + + const size_t offset = static_cast(env - base); + if (offset >= info.RegionSize) return std::nullopt; + + return (std::min)(info.RegionSize - offset, kMaxEnvironmentBytes); +} + +std::optional> ReadEnvironmentAt(HANDLE process, PVOID environmentAddress) { + const auto bytesToRead = QueryReadableRegionBytes(process, environmentAddress); + if (!bytesToRead || *bytesToRead < sizeof(wchar_t) * 2) return std::nullopt; + + std::vector environment(*bytesToRead / sizeof(wchar_t)); + size_t bytesRead = 0; + if (!TryReadProcessMemory( + process, + environmentAddress, + environment.data(), + environment.size() * sizeof(wchar_t), + &bytesRead)) { + return std::nullopt; + } + + environment.resize(bytesRead / sizeof(wchar_t)); + const auto end = std::adjacent_find( + environment.begin(), + environment.end(), + [](wchar_t lhs, wchar_t rhs) { return lhs == L'\0' && rhs == L'\0'; }); + if (end == environment.end()) return std::nullopt; + + environment.erase(std::next(end, 2), environment.end()); + return environment; +} + +std::optional> ReadEnvironmentBlock(HANDLE process) { + if (!process) return std::nullopt; + + auto environmentAddress = QueryWow64EnvironmentAddress(process); + if (!environmentAddress) { + environmentAddress = QueryNativeEnvironmentAddress(process); + } + if (!environmentAddress) return std::nullopt; + + return ReadEnvironmentAt(process, *environmentAddress); +} + +std::optional FindEnvironmentVariable(const std::vector& environment, std::wstring_view name) { + if (name.empty()) return std::nullopt; + + size_t offset = 0; + while (offset < environment.size() && environment[offset] != L'\0') { + const wchar_t* entry = environment.data() + offset; + const size_t entryLength = wcslen(entry); + if (entryLength > name.size() && + entry[name.size()] == L'=' && + _wcsnicmp(entry, name.data(), name.size()) == 0) { + return Encoding::WideToUtf8(std::wstring_view(entry + name.size() + 1, entryLength - name.size() - 1)); + } + offset += entryLength + 1; + } + + return std::nullopt; +} + +} // namespace + +std::optional GetCreationTime(uint32_t pid) { + Windows::UniqueHandle process = OpenProcessHandle(pid, PROCESS_QUERY_LIMITED_INFORMATION); + if (!process) return std::nullopt; + + FILETIME creation{}; + FILETIME exitTime{}; + FILETIME kernel{}; + FILETIME user{}; + if (!GetProcessTimes(process.get(), &creation, &exitTime, &kernel, &user)) { + OSTP_LOG_DEBUG("GetProcessTimes(pid={}) failed (error={})", pid, GetLastError()); + return std::nullopt; + } + + ULARGE_INTEGER value{}; + value.LowPart = creation.dwLowDateTime; + value.HighPart = creation.dwHighDateTime; + return value.QuadPart; +} + +std::string FormatCreationTime(uint64_t fileTime) { + if (fileTime == 0) return "-"; + + ULARGE_INTEGER value{}; + value.QuadPart = fileTime; + + FILETIME utc{}; + utc.dwLowDateTime = value.LowPart; + utc.dwHighDateTime = value.HighPart; + + FILETIME local{}; + if (!FileTimeToLocalFileTime(&utc, &local)) { + OSTP_LOG_DEBUG("FileTimeToLocalFileTime(filetime={}) failed (error={})", fileTime, GetLastError()); + return std::format("filetime={}", fileTime); + } + + SYSTEMTIME system{}; + if (!FileTimeToSystemTime(&local, &system)) { + OSTP_LOG_DEBUG("FileTimeToSystemTime(filetime={}) failed (error={})", fileTime, GetLastError()); + return std::format("filetime={}", fileTime); + } + + return std::format("{:04}-{:02}-{:02} {:02}:{:02}:{:02}.{:03}", + system.wYear, + system.wMonth, + system.wDay, + system.wHour, + system.wMinute, + system.wSecond, + system.wMilliseconds); +} + +std::optional GetImagePath(uint32_t pid) { + Windows::UniqueHandle process = OpenProcessHandle(pid, PROCESS_QUERY_LIMITED_INFORMATION); + if (!process) return std::nullopt; + + for (DWORD capacity : {static_cast(MAX_PATH), DWORD{32768}}) { + std::wstring path(capacity, L'\0'); + DWORD size = capacity; + if (QueryFullProcessImageNameW(process.get(), 0, path.data(), &size)) { + path.resize(size); + return Encoding::WideToUtf8(path); + } + if (GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + OSTP_LOG_DEBUG("QueryFullProcessImageNameW(pid={}) failed (error={})", pid, GetLastError()); + return std::nullopt; + } + } + OSTP_LOG_DEBUG("QueryFullProcessImageNameW(pid={}) failed: path exceeds 32768 wchars", pid); + return std::nullopt; +} + +std::optional GetEnvironmentVariableValue(uint32_t pid, std::wstring_view name) { + Windows::UniqueHandle process = + OpenProcessHandle(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ); + if (!process) return std::nullopt; + + const auto environment = ReadEnvironmentBlock(process.get()); + if (!environment) return std::nullopt; + return FindEnvironmentVariable(*environment, name); +} + +std::vector EnumerateModules(uint32_t pid) { + std::vector modules; + Windows::UniqueFileHandle snapshot( + CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, pid)); + if (!snapshot) { + OSTP_LOG_DEBUG("CreateToolhelp32Snapshot(pid={}) failed (error={})", pid, GetLastError()); + return modules; + } + + MODULEENTRY32W entry{}; + entry.dwSize = sizeof(entry); + if (Module32FirstW(snapshot.get(), &entry)) { + do { + std::filesystem::path nativePath(entry.szExePath); + std::string path = Encoding::WideToUtf8(entry.szExePath); + if (path.empty()) continue; + + modules.push_back(ModuleInfo{ + std::move(path), + std::move(nativePath), + entry.modBaseSize, + false, + }); + } while (Module32NextW(snapshot.get(), &entry)); + } + return modules; +} + +bool IsSystemModulePath(const std::string& path) { + static const std::string systemDir = [] { + const UINT needed = GetWindowsDirectoryW(nullptr, 0); + if (needed == 0) { + OSTP_LOG_WARN("GetWindowsDirectoryW(size query) failed (error={})", GetLastError()); + return std::string{}; + } + std::wstring dir(needed, L'\0'); + const UINT written = GetWindowsDirectoryW(dir.data(), needed); + if (written == 0 || written >= needed) { + OSTP_LOG_WARN("GetWindowsDirectoryW failed (written={}, error={})", written, GetLastError()); + return std::string{}; + } + dir.resize(written); + std::string normalized = NormalizePathForCompare(Encoding::WideToUtf8(dir)); + if (!normalized.empty() && normalized.back() != '\\') normalized += '\\'; + return normalized; + }(); + + if (systemDir.empty()) return false; + return NormalizePathForCompare(path).starts_with(systemDir); +} + +} // namespace OSTPlatform::Process diff --git a/src/OSTPlatform/Windows/RemoteProcess.cpp b/src/OSTPlatform/Windows/RemoteProcess.cpp new file mode 100644 index 0000000..e9e17df --- /dev/null +++ b/src/OSTPlatform/Windows/RemoteProcess.cpp @@ -0,0 +1,278 @@ +#include "include/RemoteProcess.h" + +#include "include/Log.h" +#include "include/PE.h" + +#include "Windows/Handles.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace OSTPlatform::RemoteProcess { +namespace { + +constexpr DWORD kRemoteLoadTimeoutMs = 10000; + +constexpr DWORD kInjectAccess = + PROCESS_CREATE_THREAD | + PROCESS_QUERY_INFORMATION | + PROCESS_VM_OPERATION | + PROCESS_VM_WRITE | + PROCESS_VM_READ; + +struct RemoteModule { + std::wstring name; + std::filesystem::path path; + uintptr_t base = 0; +}; + +Architecture DetectArchitecture(HANDLE process) { + BOOL wow64 = FALSE; + if (!IsWow64Process(process, &wow64)) { + OSTP_LOG_DEBUG("IsWow64Process failed (error={})", GetLastError()); + return Architecture::Unknown; + } + if (wow64) return Architecture::X86; + +#if defined(_WIN64) + return Architecture::X64; +#else + return Architecture::X86; +#endif +} + +std::wstring Lower(std::wstring value) { + for (wchar_t& ch : value) { + if (ch >= L'A' && ch <= L'Z') ch = static_cast(ch - L'A' + L'a'); + } + return value; +} + +bool EqualsInsensitive(std::wstring_view lhs, std::wstring_view rhs) { + return Lower(std::wstring(lhs)) == Lower(std::wstring(rhs)); +} + +std::wstring ModuleNameFromForwarder(std::string_view name) { + std::wstring wide; + wide.reserve(name.size() + 4); + for (char ch : name) wide.push_back(static_cast(ch)); + if (wide.find(L'.') == std::wstring::npos) wide += L".dll"; + return wide; +} + +std::vector EnumerateRemoteModules(uint32_t pid, Architecture architecture) { + const DWORD flags = architecture == Architecture::X86 + ? TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32 + : TH32CS_SNAPMODULE; + + Windows::UniqueFileHandle snapshot(CreateToolhelp32Snapshot(flags, pid)); + if (!snapshot && architecture == Architecture::X86) { + snapshot = Windows::UniqueFileHandle(CreateToolhelp32Snapshot(TH32CS_SNAPMODULE32, pid)); + } + if (!snapshot) { + OSTP_LOG_WARN("CreateToolhelp32Snapshot(pid={}, flags=0x{:X}) failed (error={})", + pid, flags, GetLastError()); + return {}; + } + + std::vector modules; + MODULEENTRY32W entry{}; + entry.dwSize = sizeof(entry); + if (Module32FirstW(snapshot.get(), &entry)) { + do { + modules.push_back(RemoteModule{ + entry.szModule, + std::filesystem::path(entry.szExePath), + reinterpret_cast(entry.modBaseAddr), + }); + } while (Module32NextW(snapshot.get(), &entry)); + } + + return modules; +} + +const RemoteModule* FindModule(std::span modules, std::wstring_view name) { + for (const RemoteModule& module : modules) { + if (EqualsInsensitive(module.name, name)) return &module; + } + return nullptr; +} + +bool StartsWithInsensitive(std::wstring_view value, std::wstring_view prefix) { + if (value.size() < prefix.size()) return false; + return EqualsInsensitive(value.substr(0, prefix.size()), prefix); +} + +std::optional ResolveRemoteExport( + std::span modules, + std::wstring_view moduleName, + std::string_view symbolName, + int depth = 0) { + if (depth > 8) return std::nullopt; + + const RemoteModule* module = FindModule(modules, moduleName); + if (!module && StartsWithInsensitive(moduleName, L"api-ms-win-")) { + module = FindModule(modules, L"kernelbase.dll"); + } + if (!module) { + OSTP_LOG_WARN("ResolveRemoteExport: module '{}' not found", std::string(moduleName.begin(), moduleName.end())); + return std::nullopt; + } + + const PE::Image image(module->path); + const auto exported = image.FindExport(symbolName); + if (!exported) { + OSTP_LOG_WARN("ResolveRemoteExport: export {}!{} not found", + module->path.string(), std::string(symbolName)); + return std::nullopt; + } + + if (!exported->IsForwarder()) { + return module->base + exported->rva; + } + + const size_t dot = exported->forwarder.find('.'); + if (dot == std::string::npos || dot == 0 || dot + 1 >= exported->forwarder.size()) { + OSTP_LOG_WARN("ResolveRemoteExport: unsupported forwarder '{}'", exported->forwarder); + return std::nullopt; + } + + const std::string_view forwardModule(exported->forwarder.data(), dot); + const std::string_view forwardSymbol(exported->forwarder.data() + dot + 1, exported->forwarder.size() - dot - 1); + if (!forwardSymbol.empty() && forwardSymbol.front() == '#') { + OSTP_LOG_WARN("ResolveRemoteExport: ordinal forwarder '{}' is not supported", exported->forwarder); + return std::nullopt; + } + + return ResolveRemoteExport(modules, ModuleNameFromForwarder(forwardModule), forwardSymbol, depth + 1); +} + +LPTHREAD_START_ROUTINE ResolveRemoteLoadLibraryW(uint32_t pid, Architecture architecture) { + const std::vector modules = EnumerateRemoteModules(pid, architecture); + const auto address = ResolveRemoteExport(modules, L"kernel32.dll", "LoadLibraryW"); + if (!address) { + OSTP_LOG_WARN("ResolveRemoteLoadLibraryW(pid={}, arch={}) failed", pid, ToString(architecture)); + return nullptr; + } + return reinterpret_cast(*address); +} + +} // namespace + +const char* ToString(Architecture architecture) { + switch (architecture) { + case Architecture::Unknown: return "Unknown"; + case Architecture::X86: return "X86"; + case Architecture::X64: return "X64"; + } + return "Unknown"; +} + +const char* ToString(InjectStatus status) { + switch (status) { + case InjectStatus::Ok: return "Ok"; + case InjectStatus::OpenProcessFailed: return "OpenProcessFailed"; + case InjectStatus::UnknownArchitecture: return "UnknownArchitecture"; + case InjectStatus::AllocFailed: return "AllocFailed"; + case InjectStatus::WriteFailed: return "WriteFailed"; + case InjectStatus::ResolveLoadLibraryFailed: return "ResolveLoadLibraryFailed"; + case InjectStatus::CreateThreadFailed: return "CreateThreadFailed"; + case InjectStatus::WaitFailed: return "WaitFailed"; + case InjectStatus::RemoteLoadFailed: return "RemoteLoadFailed"; + } + return "Unknown"; +} + +Architecture GetArchitecture(uint32_t pid) { + Windows::UniqueHandle process( + pid ? OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid) : nullptr); + if (!process) { + OSTP_LOG_DEBUG("OpenProcess(pid={}, access=0x{:X}) failed (error={})", + pid, PROCESS_QUERY_LIMITED_INFORMATION, GetLastError()); + return Architecture::Unknown; + } + + return DetectArchitecture(process.get()); +} + +InjectStatus InjectLibrary(uint32_t pid, const std::filesystem::path& libraryPath) { + Windows::UniqueHandle process(pid ? OpenProcess(kInjectAccess, FALSE, pid) : nullptr); + if (!process) { + OSTP_LOG_WARN("OpenProcess(pid={}, access=0x{:X}) failed (error={})", pid, kInjectAccess, GetLastError()); + return InjectStatus::OpenProcessFailed; + } + + const Architecture architecture = DetectArchitecture(process.get()); + if (architecture == Architecture::Unknown) { + OSTP_LOG_WARN("InjectLibrary(pid={}): target architecture is unknown", pid); + return InjectStatus::UnknownArchitecture; + } + + LPTHREAD_START_ROUTINE loadLibrary = ResolveRemoteLoadLibraryW(pid, architecture); + if (!loadLibrary) { + return InjectStatus::ResolveLoadLibraryFailed; + } + + const std::wstring nativePath = libraryPath.wstring(); + const size_t byteSize = (nativePath.size() + 1) * sizeof(wchar_t); + void* remotePath = VirtualAllocEx(process.get(), nullptr, byteSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if (!remotePath) { + OSTP_LOG_WARN("VirtualAllocEx(size={}) failed (error={})", byteSize, GetLastError()); + return InjectStatus::AllocFailed; + } + // Free the remote path on every exit path. It must outlive the remote + // thread, which reads it before LoadLibraryW returns; function scope (after + // the WaitForSingleObject below) satisfies that. + Windows::ScopeExit freeRemotePath([&] { + VirtualFreeEx(process.get(), remotePath, 0, MEM_RELEASE); + }); + + SIZE_T written = 0; + if (!WriteProcessMemory(process.get(), remotePath, nativePath.c_str(), byteSize, &written) || + written != byteSize) { + OSTP_LOG_WARN("WriteProcessMemory(size={}) failed (written={}, error={})", + byteSize, static_cast(written), GetLastError()); + return InjectStatus::WriteFailed; + } + + Windows::UniqueHandle thread( + CreateRemoteThread(process.get(), nullptr, 0, loadLibrary, remotePath, 0, nullptr)); + if (!thread) { + OSTP_LOG_WARN("CreateRemoteThread failed (error={})", GetLastError()); + return InjectStatus::CreateThreadFailed; + } + + const DWORD wait = WaitForSingleObject(thread.get(), kRemoteLoadTimeoutMs); + if (wait != WAIT_OBJECT_0) { + if (wait == WAIT_FAILED) { + OSTP_LOG_WARN("WaitForSingleObject(remote thread) failed (error={})", GetLastError()); + } else if (wait == WAIT_TIMEOUT) { + OSTP_LOG_WARN("WaitForSingleObject(remote thread) timed out after {} ms", kRemoteLoadTimeoutMs); + } else { + OSTP_LOG_WARN("WaitForSingleObject(remote thread) returned unexpected status {}", wait); + } + return InjectStatus::WaitFailed; + } + + DWORD exitCode = 0; + if (!GetExitCodeThread(thread.get(), &exitCode)) { + OSTP_LOG_WARN("GetExitCodeThread(remote thread) failed (error={})", GetLastError()); + return InjectStatus::RemoteLoadFailed; + } + if (exitCode == 0) { + OSTP_LOG_WARN("Remote LoadLibraryW returned 0 for '{}'", libraryPath.string()); + return InjectStatus::RemoteLoadFailed; + } + + return InjectStatus::Ok; +} + +} // namespace OSTPlatform::RemoteProcess diff --git a/src/OSTPlatform/Windows/SteamCredentialStore.cpp b/src/OSTPlatform/Windows/SteamCredentialStore.cpp new file mode 100644 index 0000000..b7f1306 --- /dev/null +++ b/src/OSTPlatform/Windows/SteamCredentialStore.cpp @@ -0,0 +1,257 @@ +#include "include/SteamCredentialStore.h" + +#include "include/Log.h" +#include "include/Numbers.h" +#include "include/Encoding.h" +#include "Windows/Handles.h" + +#include + +#include + +namespace OSTPlatform::SteamCredentialStore { +namespace { + +constexpr const wchar_t* kValueSteamId = L"SteamID"; +constexpr const wchar_t* kValueAppTicket = L"AppTicket"; +constexpr const wchar_t* kValueEncryptedTicket = L"ETicket"; +constexpr const wchar_t* kActiveProcessKeyPath = L"Software\\Valve\\Steam\\ActiveProcess"; +constexpr const wchar_t* kValueActiveUser = L"ActiveUser"; +constexpr const wchar_t* kValueUniverse = L"Universe"; + +std::wstring SteamAppKeyPath(uint32_t appId) { + return L"Software\\Valve\\Steam\\Apps\\" + std::to_wstring(appId); +} + +const char* ValueNameForLog(PCWSTR valueName) { + if (valueName == kValueSteamId) return "SteamID"; + if (valueName == kValueAppTicket) return "AppTicket"; + if (valueName == kValueEncryptedTicket) return "ETicket"; + if (valueName == kValueActiveUser) return "ActiveUser"; + if (valueName == kValueUniverse) return "Universe"; + return "?"; +} + +Status FromLStatus(LSTATUS status) { + switch (status) { + case ERROR_SUCCESS: + return Status::Ok; + case ERROR_FILE_NOT_FOUND: + case ERROR_PATH_NOT_FOUND: + case ERROR_NOT_FOUND: + return Status::NotFound; + default: + return Status::Failed; + } +} + +// RegGetValueW does the size-dance for us: first call (pvData == nullptr) +// reports the byte count, second fills the buffer. `out` is overwritten only on +// success; left untouched on any failure. +LSTATUS ReadBinaryValue(uint32_t appId, PCWSTR valueName, std::vector& out) { + const std::wstring keyPath = SteamAppKeyPath(appId); + + DWORD bytes = 0; + LSTATUS status = RegGetValueW(HKEY_CURRENT_USER, keyPath.c_str(), valueName, + RRF_RT_REG_BINARY, nullptr, nullptr, &bytes); + if (status != ERROR_SUCCESS) { + const auto mapped = FromLStatus(status); + if (mapped == Status::NotFound) { + OSTP_LOG_DEBUG("SteamCredentialStore: {} missing for appid={} status={}", + ValueNameForLog(valueName), appId, status); + } else { + OSTP_LOG_WARN("SteamCredentialStore: failed to query {} size for appid={} status={}", + ValueNameForLog(valueName), appId, status); + } + return status; + } + if (bytes == 0) { + OSTP_LOG_DEBUG("SteamCredentialStore: {} empty for appid={}", ValueNameForLog(valueName), appId); + out.clear(); + return ERROR_SUCCESS; + } + + std::vector buffer(bytes); + status = RegGetValueW(HKEY_CURRENT_USER, keyPath.c_str(), valueName, + RRF_RT_REG_BINARY, nullptr, buffer.data(), &bytes); + if (status != ERROR_SUCCESS) { + OSTP_LOG_WARN("SteamCredentialStore: failed to read {} for appid={} status={}", + ValueNameForLog(valueName), appId, status); + return status; + } + + buffer.resize(bytes); + out = std::move(buffer); + OSTP_LOG_DEBUG("SteamCredentialStore: read {} for appid={} bytes={}", + ValueNameForLog(valueName), appId, out.size()); + return ERROR_SUCCESS; +} + +// Create-or-open the per-app key and set a single value. REG_OPTION_NON_VOLATILE +// + KEY_SET_VALUE mirrors what Steam itself uses for these credential entries. +LSTATUS WriteValue(uint32_t appId, PCWSTR valueName, DWORD type, const BYTE* data, DWORD bytes) { + Windows::UniqueRegKey key; + LSTATUS status = RegCreateKeyExW(HKEY_CURRENT_USER, SteamAppKeyPath(appId).c_str(), 0, nullptr, + REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, nullptr, key.put(), nullptr); + if (status != ERROR_SUCCESS) { + OSTP_LOG_WARN("SteamCredentialStore: failed to create/open app key appid={} status={}", appId, status); + return status; + } + + status = RegSetValueExW(key.get(), valueName, 0, type, data, bytes); + if (status != ERROR_SUCCESS) { + OSTP_LOG_WARN("SteamCredentialStore: failed to write {} for appid={} bytes={} status={}", + ValueNameForLog(valueName), appId, bytes, status); + return status; + } + + OSTP_LOG_INFO("SteamCredentialStore: wrote {} for appid={} bytes={}", + ValueNameForLog(valueName), appId, bytes); + return ERROR_SUCCESS; +} + +} // namespace + +const char* ToString(Status status) { + switch (status) { + case Status::Ok: return "Ok"; + case Status::NotFound: return "NotFound"; + case Status::Unsupported: return "Unsupported"; + case Status::Failed: return "Failed"; + } + return "Unknown"; +} + +Status GetAppTicket(uint32_t appId, std::vector& ticket) { + return FromLStatus(ReadBinaryValue(appId, kValueAppTicket, ticket)); +} + +Status WriteAppTicket(uint32_t appId, const std::vector& data) { + return FromLStatus(WriteValue(appId, kValueAppTicket, REG_BINARY, + reinterpret_cast(data.data()), static_cast(data.size()))); +} + +Status GetETicket(uint32_t appId, std::vector& ticket) { + return FromLStatus(ReadBinaryValue(appId, kValueEncryptedTicket, ticket)); +} + +Status WriteETicket(uint32_t appId, const std::vector& data) { + return FromLStatus(WriteValue(appId, kValueEncryptedTicket, REG_BINARY, + reinterpret_cast(data.data()), static_cast(data.size()))); +} + +Status GetSteamId(uint32_t appId, uint64_t& steamId) { + const std::wstring keyPath = SteamAppKeyPath(appId); + + DWORD bytes = 0; + LSTATUS status = RegGetValueW(HKEY_CURRENT_USER, keyPath.c_str(), kValueSteamId, + RRF_RT_REG_SZ, nullptr, nullptr, &bytes); + if (status != ERROR_SUCCESS) { + const auto mapped = FromLStatus(status); + if (mapped == Status::NotFound) { + OSTP_LOG_DEBUG("SteamCredentialStore: SteamID missing for appid={} status={}", appId, status); + } else { + OSTP_LOG_WARN("SteamCredentialStore: failed to query SteamID size for appid={} status={}", appId, status); + } + return mapped; + } + if (bytes < sizeof(wchar_t)) { + OSTP_LOG_WARN("SteamCredentialStore: SteamID value too short for appid={} bytes={}", appId, bytes); + return Status::NotFound; + } + + std::wstring raw(bytes / sizeof(wchar_t), L'\0'); + status = RegGetValueW(HKEY_CURRENT_USER, keyPath.c_str(), kValueSteamId, + RRF_RT_REG_SZ, nullptr, raw.data(), &bytes); + if (status != ERROR_SUCCESS) { + OSTP_LOG_WARN("SteamCredentialStore: failed to read SteamID for appid={} status={}", appId, status); + return FromLStatus(status); + } + + // RegGetValueW guarantees a single null terminator and counts it in `bytes`; + // drop it so the parser sees only digits. + const size_t chars = bytes / sizeof(wchar_t); + raw.resize(chars > 0 ? chars - 1 : 0); + + // A present-but-empty/zero/unparseable value is treated as "no credential". + const auto parsed = Numbers::ParseUInt64(raw); + if (!parsed || *parsed == 0) { + OSTP_LOG_WARN("SteamCredentialStore: SteamID value invalid for appid={}", appId); + return Status::NotFound; + } + + steamId = *parsed; + OSTP_LOG_DEBUG("SteamCredentialStore: read SteamID for appid={} steamid={}", appId, steamId); + return Status::Ok; +} + +Status WriteSteamId(uint32_t appId, uint64_t steamId) { + const std::wstring value = std::to_wstring(steamId); + const DWORD bytes = static_cast((value.size() + 1) * sizeof(wchar_t)); + return FromLStatus(WriteValue(appId, kValueSteamId, REG_SZ, + reinterpret_cast(value.c_str()), bytes)); +} + +Status GetActiveUser(uint32_t& accountId, std::wstring& universe) { + DWORD activeUser = 0; + DWORD bytes = sizeof(activeUser); + LSTATUS status = RegGetValueW(HKEY_CURRENT_USER, kActiveProcessKeyPath, kValueActiveUser, + RRF_RT_REG_DWORD, nullptr, &activeUser, &bytes); + if (status != ERROR_SUCCESS) { + const auto mapped = FromLStatus(status); + if (mapped == Status::NotFound) { + OSTP_LOG_DEBUG("SteamCredentialStore: ActiveUser missing status={}", status); + } else { + OSTP_LOG_WARN("SteamCredentialStore: failed to read ActiveUser status={}", status); + } + return mapped; + } + if (bytes != sizeof(activeUser)) { + OSTP_LOG_WARN("SteamCredentialStore: ActiveUser has unexpected size bytes={}", bytes); + return Status::NotFound; + } + if (activeUser == 0) { + OSTP_LOG_DEBUG("SteamCredentialStore: ActiveUser is zero"); + return Status::NotFound; + } + + bytes = 0; + status = RegGetValueW(HKEY_CURRENT_USER, kActiveProcessKeyPath, kValueUniverse, + RRF_RT_REG_SZ, nullptr, nullptr, &bytes); + if (status != ERROR_SUCCESS) { + const auto mapped = FromLStatus(status); + if (mapped == Status::NotFound) { + OSTP_LOG_DEBUG("SteamCredentialStore: ActiveProcess Universe missing status={}", status); + } else { + OSTP_LOG_WARN("SteamCredentialStore: failed to query ActiveProcess Universe size status={}", status); + } + return mapped; + } + if (bytes < sizeof(wchar_t)) { + OSTP_LOG_WARN("SteamCredentialStore: ActiveProcess Universe value too short bytes={}", bytes); + return Status::NotFound; + } + + std::wstring rawUniverse(bytes / sizeof(wchar_t), L'\0'); + status = RegGetValueW(HKEY_CURRENT_USER, kActiveProcessKeyPath, kValueUniverse, + RRF_RT_REG_SZ, nullptr, rawUniverse.data(), &bytes); + if (status != ERROR_SUCCESS) { + OSTP_LOG_WARN("SteamCredentialStore: failed to read ActiveProcess Universe status={}", status); + return FromLStatus(status); + } + + const size_t chars = bytes / sizeof(wchar_t); + rawUniverse.resize(chars > 0 ? chars - 1 : 0); + if (rawUniverse.empty()) { + OSTP_LOG_WARN("SteamCredentialStore: ActiveProcess Universe is empty"); + return Status::NotFound; + } + + accountId = activeUser; + universe = std::move(rawUniverse); + OSTP_LOG_DEBUG("SteamCredentialStore: read ActiveUser accountid={} universe={}({} chars)", + accountId, Encoding::WideToUtf8(universe), universe.size()); + return Status::Ok; +} + +} // namespace OSTPlatform::SteamCredentialStore diff --git a/src/OSTPlatform/Windows/Thread.cpp b/src/OSTPlatform/Windows/Thread.cpp new file mode 100644 index 0000000..2ec24e2 --- /dev/null +++ b/src/OSTPlatform/Windows/Thread.cpp @@ -0,0 +1,41 @@ +#include "include/Thread.h" + +#include "include/Log.h" + +#include + +#include + +namespace OSTPlatform::Thread { + +NativeThreadHandle CurrentNativeThreadHandle() { + return GetCurrentThread(); +} + +bool StartDetached(std::function entry) { + if (!entry) { + OSTP_LOG_WARN("StartDetached: entry is empty"); + return false; + } + + auto* heapEntry = new std::function(std::move(entry)); + HANDLE thread = CreateThread( + nullptr, + 0, + [](void* param) -> DWORD { + std::unique_ptr> fn(static_cast*>(param)); + return static_cast((*fn)()); + }, + heapEntry, + 0, + nullptr); + if (!thread) { + OSTP_LOG_WARN("CreateThread failed (error={})", GetLastError()); + delete heapEntry; + return false; + } + CloseHandle(thread); + return true; +} + +} // namespace OSTPlatform::Thread diff --git a/src/OSTPlatform/Windows/Trap.cpp b/src/OSTPlatform/Windows/Trap.cpp new file mode 100644 index 0000000..4212650 --- /dev/null +++ b/src/OSTPlatform/Windows/Trap.cpp @@ -0,0 +1,155 @@ +#include "include/Trap.h" + +#include "include/Log.h" + +#include + +namespace OSTPlatform::Trap { +namespace { + +Handler g_handler = nullptr; +HandlerHandle g_handle = nullptr; + +const char* ToString(ExceptionKind kind) { + switch (kind) { + case ExceptionKind::Breakpoint: return "Breakpoint"; + case ExceptionKind::SingleStep: return "SingleStep"; + case ExceptionKind::Other: return "Other"; + } + return "Unknown"; +} + +ExceptionKind FromWindowsExceptionCode(DWORD code) { + switch (code) { + case EXCEPTION_BREAKPOINT: return ExceptionKind::Breakpoint; + case EXCEPTION_SINGLE_STEP: return ExceptionKind::SingleStep; + default: return ExceptionKind::Other; + } +} + +LONG CALLBACK VehThunk(PEXCEPTION_POINTERS exceptionInfo) { + if (!g_handler) { + OSTP_LOG_TRACE("VehThunk: no handler installed"); + return EXCEPTION_CONTINUE_SEARCH; + } + if (!exceptionInfo || !exceptionInfo->ExceptionRecord || !exceptionInfo->ContextRecord) { + OSTP_LOG_DEBUG("VehThunk: incomplete exception context (info={:#x}, record={:#x}, context={:#x})", + reinterpret_cast(exceptionInfo), + reinterpret_cast(exceptionInfo ? exceptionInfo->ExceptionRecord : nullptr), + reinterpret_cast(exceptionInfo ? exceptionInfo->ContextRecord : nullptr)); + return EXCEPTION_CONTINUE_SEARCH; + } + + Context context(exceptionInfo->ContextRecord); + const DWORD code = exceptionInfo->ExceptionRecord->ExceptionCode; + const ExceptionKind kind = FromWindowsExceptionCode(code); + OSTP_LOG_TRACE("VehThunk: exception code=0x{:08X} kind={} ip={:#x}", + code, ToString(kind), context.InstructionPointer()); + + const bool handled = g_handler( + kind, + context); + OSTP_LOG_TRACE("VehThunk: {} at ip={:#x}", handled ? "handled" : "continued", context.InstructionPointer()); + return handled ? EXCEPTION_CONTINUE_EXECUTION : EXCEPTION_CONTINUE_SEARCH; +} + +PCONTEXT AsWindowsContext(void* nativeContext) { + return static_cast(nativeContext); +} + +bool TryReadStackSlot(uintptr_t address, uint64_t& out) { + __try { + out = *reinterpret_cast(address); + return true; + } __except (EXCEPTION_EXECUTE_HANDLER) { + OSTP_LOG_TRACE("TryReadStackSlot({:#x}) failed", address); + return false; + } +} + +} // namespace + +uint64_t Context::InstructionPointer() const { + PCONTEXT context = AsWindowsContext(nativeContext_); + if (!context) { + OSTP_LOG_TRACE("Context::InstructionPointer: native context is null"); + return 0; + } + return context->Rip; +} + +uint64_t Context::Argument(int index) const { + PCONTEXT context = AsWindowsContext(nativeContext_); + if (!context) { + OSTP_LOG_TRACE("Context::Argument({}): native context is null", index); + return 0; + } + + switch (index) { + case 1: return context->Rcx; + case 2: return context->Rdx; + case 3: return context->R8; + case 4: return context->R9; + default: { + if (index <= 0) { + OSTP_LOG_DEBUG("Context::Argument: invalid 1-based index {}", index); + return 0; + } + const uintptr_t slot = context->Rsp + static_cast(index) * 8; + uint64_t value = 0; + if (!TryReadStackSlot(slot, value)) { + OSTP_LOG_TRACE("Context::Argument({}): stack slot {:#x} is unreadable", index, slot); + return 0; + } + return value; + } + } +} + +void Context::EnableSingleStep() { + PCONTEXT context = AsWindowsContext(nativeContext_); + if (!context) { + OSTP_LOG_TRACE("Context::EnableSingleStep: native context is null"); + return; + } + context->EFlags |= 0x100; + OSTP_LOG_TRACE("Context::EnableSingleStep: TF set at ip={:#x}", context->Rip); +} + +HandlerHandle AddVectoredHandler(Handler handler) { + if (!handler) { + OSTP_LOG_DEBUG("AddVectoredHandler: handler is null"); + return nullptr; + } + if (g_handle) { + OSTP_LOG_DEBUG("AddVectoredHandler: handler already installed"); + return nullptr; + } + + g_handler = handler; + g_handle = AddVectoredExceptionHandler(1, VehThunk); + if (!g_handle) { + OSTP_LOG_WARN("AddVectoredExceptionHandler failed (error={})", GetLastError()); + g_handler = nullptr; + } else { + OSTP_LOG_DEBUG("AddVectoredExceptionHandler installed handle={}", g_handle); + } + return g_handle; +} + +void RemoveVectoredHandler(HandlerHandle handle) { + if (!handle || handle != g_handle) { + OSTP_LOG_TRACE("RemoveVectoredHandler: ignoring stale handle {}", handle); + return; + } + + if (RemoveVectoredExceptionHandler(handle) == 0) { + OSTP_LOG_WARN("RemoveVectoredExceptionHandler failed (error={})", GetLastError()); + return; + } + g_handle = nullptr; + g_handler = nullptr; + OSTP_LOG_DEBUG("RemoveVectoredExceptionHandler removed handle={}", handle); +} + +} // namespace OSTPlatform::Trap diff --git a/src/OSTPlatform/include/ByteSearch.h b/src/OSTPlatform/include/ByteSearch.h new file mode 100644 index 0000000..3264809 --- /dev/null +++ b/src/OSTPlatform/include/ByteSearch.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace OSTPlatform::ByteSearch { + +std::optional Find(std::span bytes, std::span pattern); +std::optional FindInFile(const std::filesystem::path& path,std::span pattern,size_t chunkBytes = 8 * 1024 * 1024); +std::optional FindInFileRange( + const std::filesystem::path& path, + uint64_t offset, + uint64_t size, + std::span pattern, + size_t chunkBytes = 8 * 1024 * 1024); + +} // namespace OSTPlatform::ByteSearch diff --git a/src/OSTPlatform/include/Detour.h b/src/OSTPlatform/include/Detour.h new file mode 100644 index 0000000..5208f98 --- /dev/null +++ b/src/OSTPlatform/include/Detour.h @@ -0,0 +1,10 @@ +#pragma once + +namespace OSTPlatform::Detour { + + bool BeginTransaction(); + bool CommitTransaction(); + bool Attach(void** target, void* detour); + bool Detach(void** target, void* detour); + +} // namespace OSTPlatform::Detour diff --git a/src/OSTPlatform/include/Dialog.h b/src/OSTPlatform/include/Dialog.h new file mode 100644 index 0000000..b28e403 --- /dev/null +++ b/src/OSTPlatform/include/Dialog.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace OSTPlatform::Dialog { + + void ShowWarning(std::string title, std::string message); + +} // namespace OSTPlatform::Dialog diff --git a/src/OSTPlatform/include/DirectoryWatch.h b/src/OSTPlatform/include/DirectoryWatch.h new file mode 100644 index 0000000..5c03846 --- /dev/null +++ b/src/OSTPlatform/include/DirectoryWatch.h @@ -0,0 +1,64 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace OSTPlatform::DirectoryWatch { + + enum class ChangeAction { + Added, + Removed, + Modified, + RenamedOldName, + RenamedNewName, + }; + + struct Change { + std::string relativePath; + ChangeAction action = ChangeAction::Modified; + }; + + enum class WaitStatus { + Signaled, + Timeout, + Failed, + }; + + struct WaitResult { + WaitStatus status = WaitStatus::Failed; + size_t index = 0; + }; + + class Watch { + public: + Watch(); + ~Watch(); + + Watch(Watch&&) noexcept; + Watch& operator=(Watch&&) noexcept; + + Watch(const Watch&) = delete; + Watch& operator=(const Watch&) = delete; + + bool Open(const std::string& directory, uint32_t bufferSize); + bool IssueRead(); + std::vector Drain(); + void Cancel(); + + bool IsOpen() const; + const std::string& Directory() const; + + private: + struct Impl; + std::unique_ptr impl_; + + friend WaitResult WaitAny(std::span watches, uint32_t timeoutMs); + }; + + WaitResult WaitAny(std::span watches, uint32_t timeoutMs); + +} // namespace OSTPlatform::DirectoryWatch diff --git a/src/OSTPlatform/include/DynamicLibrary.h b/src/OSTPlatform/include/DynamicLibrary.h new file mode 100644 index 0000000..4f0049c --- /dev/null +++ b/src/OSTPlatform/include/DynamicLibrary.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include +#include + +namespace OSTPlatform::DynamicLibrary { + + using ModuleHandle = void*; + + ModuleHandle Load(const std::filesystem::path& path); + ModuleHandle GetLoaded(std::string_view moduleName); + void* GetSymbol(ModuleHandle module, const char* symbolName); + void* GetSymbol(ModuleHandle module, uint16_t ordinal); + uint32_t GetLastErrorCode(); + + std::string GetCurrentDirectoryPath(); + std::string GetSystemDirectoryPath(); + std::filesystem::path GetModuleDirectory(ModuleHandle module); + std::filesystem::path GetMainExecutablePath(); + +} // namespace OSTPlatform::DynamicLibrary diff --git a/src/OSTPlatform/include/Encoding.h b/src/OSTPlatform/include/Encoding.h new file mode 100644 index 0000000..ea6f1b0 --- /dev/null +++ b/src/OSTPlatform/include/Encoding.h @@ -0,0 +1,11 @@ +#pragma once + +#include +#include + +namespace OSTPlatform::Encoding { + + std::string WideToUtf8(std::wstring_view value); + std::wstring Utf8ToWide(std::string_view value); + +} // namespace OSTPlatform::Encoding diff --git a/src/OSTPlatform/include/Hash.h b/src/OSTPlatform/include/Hash.h new file mode 100644 index 0000000..cb6a27c --- /dev/null +++ b/src/OSTPlatform/include/Hash.h @@ -0,0 +1,10 @@ +#pragma once + +#include +#include + +namespace OSTPlatform::Hash { + + std::string Sha256OfFile(const std::filesystem::path& path); + +} // namespace OSTPlatform::Hash diff --git a/src/OSTPlatform/include/Http.h b/src/OSTPlatform/include/Http.h new file mode 100644 index 0000000..628883d --- /dev/null +++ b/src/OSTPlatform/include/Http.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +namespace OSTPlatform::Http { + + struct Result { + std::string body; + uint32_t status = 0; + // True once WinHTTP produced a response; callers must still check status for 2xx. + bool ok = false; + }; + + Result Execute(const wchar_t* method, + const char* url, + const void* reqBody = nullptr, + uint32_t reqBodyLen = 0, + const wchar_t* headers = nullptr, + uint32_t timeoutResolve = 5000, + uint32_t timeoutConnect = 5000, + uint32_t timeoutSend = 10000, + uint32_t timeoutRecv = 10000); + +} // namespace OSTPlatform::Http diff --git a/src/OSTPlatform/include/Log.h b/src/OSTPlatform/include/Log.h new file mode 100644 index 0000000..17dad88 --- /dev/null +++ b/src/OSTPlatform/include/Log.h @@ -0,0 +1,45 @@ +#pragma once + +// Small logging facade for OSTPlatform. The host installs a sink after its +// own logger is ready; Release builds compile OSTP_LOG_* to no-ops. + +#include +#include + +#ifdef OPENSTEAMTOOL_LOGGING_ENABLED +#include +#endif + +namespace OSTPlatform::Log { + +enum class Level : uint8_t { Trace, Debug, Info, Warn, Error }; + +struct Source { + const char* file = nullptr; + int line = 0; + const char* function = nullptr; +}; + +// Host-injected receiver. `msg` is already formatted. +using Sink = void (*)(Level level, const Source& src, std::string_view msg); + +void SetSink(Sink sink) noexcept; +void Dispatch(Level level, const Source& src, std::string_view msg) noexcept; + +} // namespace OSTPlatform::Log + +#ifdef OPENSTEAMTOOL_LOGGING_ENABLED +#define OSTP_LOG(lvl, ...) \ + ::OSTPlatform::Log::Dispatch( \ + (lvl), \ + ::OSTPlatform::Log::Source{__FILE__, __LINE__, __func__}, \ + ::std::format(__VA_ARGS__)) +#else +#define OSTP_LOG(lvl, ...) ((void)0) +#endif + +#define OSTP_LOG_TRACE(...) OSTP_LOG(::OSTPlatform::Log::Level::Trace, __VA_ARGS__) +#define OSTP_LOG_DEBUG(...) OSTP_LOG(::OSTPlatform::Log::Level::Debug, __VA_ARGS__) +#define OSTP_LOG_INFO(...) OSTP_LOG(::OSTPlatform::Log::Level::Info, __VA_ARGS__) +#define OSTP_LOG_WARN(...) OSTP_LOG(::OSTPlatform::Log::Level::Warn, __VA_ARGS__) +#define OSTP_LOG_ERROR(...) OSTP_LOG(::OSTPlatform::Log::Level::Error, __VA_ARGS__) diff --git a/src/OSTPlatform/include/Memory.h b/src/OSTPlatform/include/Memory.h new file mode 100644 index 0000000..0213c23 --- /dev/null +++ b/src/OSTPlatform/include/Memory.h @@ -0,0 +1,19 @@ +#pragma once + +#include "include/DynamicLibrary.h" + +#include +#include +#include + +namespace OSTPlatform::Memory { + + struct ModuleImage { + uint8_t* base = nullptr; + size_t size = 0; + }; + + std::optional GetModuleImage(DynamicLibrary::ModuleHandle module); + bool WriteExecutableByte(void* target, uint8_t value); + +} // namespace OSTPlatform::Memory diff --git a/src/OSTPlatform/include/Numbers.h b/src/OSTPlatform/include/Numbers.h new file mode 100644 index 0000000..d53820d --- /dev/null +++ b/src/OSTPlatform/include/Numbers.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include + +namespace OSTPlatform::Numbers { + + struct TextSlice { + size_t offset = 0; + size_t count = std::string_view::npos; + }; + + // Strict decimal parsing: full input must be consumed and fit the target type. + std::optional ParseUInt64(std::string_view value); + std::optional ParseUInt64(std::wstring_view value); + std::optional ParseUInt64(const char* value); + std::optional ParseUInt64(std::string_view value, TextSlice slice); + + std::optional ParseUInt32(std::string_view value); + std::optional ParseUInt32(std::wstring_view value); + std::optional ParseUInt32(const char* value); + std::optional ParseUInt32(std::string_view value, TextSlice slice); + + std::optional ParseInt64(std::string_view value); + std::optional ParseInt64(std::wstring_view value); + std::optional ParseInt64(const char* value); + std::optional ParseInt64(std::string_view value, TextSlice slice); + + std::optional ParseInt32(std::string_view value); + std::optional ParseInt32(std::wstring_view value); + std::optional ParseInt32(const char* value); + std::optional ParseInt32(std::string_view value, TextSlice slice); + + // Strict hexadecimal parsing; optional 0x/0X prefix is accepted. + std::optional ParseHexUInt64(std::string_view value); + std::optional ParseHexUInt64(std::wstring_view value); + std::optional ParseHexUInt64(const char* value); + std::optional ParseHexUInt64(std::string_view value, TextSlice slice); + + std::optional ParseHexUInt32(std::string_view value); + std::optional ParseHexUInt32(std::wstring_view value); + std::optional ParseHexUInt32(const char* value); + std::optional ParseHexUInt32(std::string_view value, TextSlice slice); + + std::optional ParseHexUInt8(std::string_view value); + std::optional ParseHexUInt8(std::wstring_view value); + std::optional ParseHexUInt8(const char* value); + std::optional ParseHexUInt8(std::string_view value, TextSlice slice); + +} // namespace OSTPlatform::Numbers diff --git a/src/OSTPlatform/include/PE.h b/src/OSTPlatform/include/PE.h new file mode 100644 index 0000000..a7d5055 --- /dev/null +++ b/src/OSTPlatform/include/PE.h @@ -0,0 +1,86 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace OSTPlatform::PE { + +// Owning byte buffer for bulk file reads. Unlike std::vector, sizing +// it does NOT initialize the storage — it uses std::make_unique_for_overwrite, +// which is a single uninitialized array allocation (one operator new[]). So a +// buffer about to be fully overwritten by a file read pays neither a zero-fill +// nor a per-element construct loop. Move-only; converts to a const span for +// scanning/parsing. +class ByteBuffer { +public: + ByteBuffer() = default; + explicit ByteBuffer(size_t size) + : data_(size ? std::make_unique_for_overwrite(size) : nullptr), + size_(size) {} + + uint8_t* data() { return data_.get(); } + const uint8_t* data() const { return data_.get(); } + size_t size() const { return size_; } + bool empty() const { return size_ == 0; } + + operator std::span() const { return {data_.get(), size_}; } + +private: + std::unique_ptr data_; + size_t size_ = 0; +}; + +struct Section { + std::string name; + uint32_t virtualAddress = 0; + uint32_t virtualSize = 0; + uint32_t rawOffset = 0; + uint32_t rawSize = 0; + + bool ContainsRva(uint32_t rva) const; +}; + +struct Export { + uint32_t rva = 0; + std::string forwarder; + + bool IsForwarder() const { return !forwarder.empty(); } +}; + +class Image { +public: + explicit Image(const std::filesystem::path& path); + + explicit operator bool() const { return valid_; } + const std::filesystem::path& Path() const { return path_; } + uint64_t FileSize() const { return fileSize_; } + uint32_t EntryPointRva() const { return entryPointRva_; } + std::span Sections() const { return sections_; } + + bool HasSection(std::string_view name) const; + const Section* SectionContainingRva(uint32_t rva) const; + std::optional RvaToOffset(uint32_t rva) const; + std::optional RawOffsetToRva(size_t rawOffset) const; + ByteBuffer ReadRawBytes(size_t offset, size_t size) const; + ByteBuffer ReadAllBytes() const; + std::optional FindExport(std::string_view symbolName) const; + +private: + std::filesystem::path path_; + ByteBuffer headerBytes_; + std::vector
sections_; + uint64_t fileSize_ = 0; + uint32_t entryPointRva_ = 0; + uint32_t exportDirectoryRva_ = 0; + uint32_t exportDirectorySize_ = 0; + bool valid_ = false; +}; + +} // namespace OSTPlatform::PE diff --git a/src/OSTPlatform/include/Process.h b/src/OSTPlatform/include/Process.h new file mode 100644 index 0000000..42a41ca --- /dev/null +++ b/src/OSTPlatform/include/Process.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace OSTPlatform::Process { + + struct ModuleInfo { + std::string path; + std::filesystem::path nativePath; + uint32_t size = 0; + bool executable = false; + }; + + std::optional GetCreationTime(uint32_t pid); + std::string FormatCreationTime(uint64_t fileTime); + std::optional GetImagePath(uint32_t pid); + std::optional GetEnvironmentVariableValue(uint32_t pid, std::wstring_view name); + std::vector EnumerateModules(uint32_t pid); + + // True when `path` lives under the OS system directory tree (on Windows, + // %WINDIR%). Used to filter out OS-supplied modules during scans. Case- and + // separator-insensitive; returns false on platforms without the concept. + bool IsSystemModulePath(const std::string& path); + +} // namespace OSTPlatform::Process diff --git a/src/OSTPlatform/include/RemoteProcess.h b/src/OSTPlatform/include/RemoteProcess.h new file mode 100644 index 0000000..5fbf19a --- /dev/null +++ b/src/OSTPlatform/include/RemoteProcess.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include + +namespace OSTPlatform::RemoteProcess { + + enum class Architecture { + Unknown, + X86, + X64, + }; + + enum class InjectStatus { + Ok, + OpenProcessFailed, + UnknownArchitecture, + AllocFailed, + WriteFailed, + ResolveLoadLibraryFailed, + CreateThreadFailed, + WaitFailed, + RemoteLoadFailed, + }; + + const char* ToString(Architecture architecture); + const char* ToString(InjectStatus status); + Architecture GetArchitecture(uint32_t pid); + InjectStatus InjectLibrary(uint32_t pid, const std::filesystem::path& libraryPath); + +} // namespace OSTPlatform::RemoteProcess diff --git a/src/OSTPlatform/include/SteamCredentialStore.h b/src/OSTPlatform/include/SteamCredentialStore.h new file mode 100644 index 0000000..6726837 --- /dev/null +++ b/src/OSTPlatform/include/SteamCredentialStore.h @@ -0,0 +1,33 @@ +#pragma once + +#include +#include +#include + +namespace OSTPlatform::SteamCredentialStore { + + enum class Status { + Ok, + NotFound, + Unsupported, + Failed, + }; + + const char* ToString(Status status); + + // Read/write Steam per-app credentials. Each Get* reader writes its + // out-parameter only on Status::Ok; on any other status the out-parameter is + // left untouched. The signatures mirror their Write* counterparts so a value + // travels through the same shape going in and coming out. + Status GetAppTicket(uint32_t appId, std::vector& ticket); + Status WriteAppTicket(uint32_t appId, const std::vector& data); + + Status GetETicket(uint32_t appId, std::vector& ticket); + Status WriteETicket(uint32_t appId, const std::vector& data); + + Status GetSteamId(uint32_t appId, uint64_t& steamId); + Status WriteSteamId(uint32_t appId, uint64_t steamId); + + Status GetActiveUser(uint32_t& accountId, std::wstring& universe); + +} // namespace OSTPlatform::SteamCredentialStore diff --git a/src/OSTPlatform/include/Stopwatch.h b/src/OSTPlatform/include/Stopwatch.h new file mode 100644 index 0000000..86ec131 --- /dev/null +++ b/src/OSTPlatform/include/Stopwatch.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace OSTPlatform { + +class Stopwatch { +public: + double ElapsedMs() const { + const auto elapsed = std::chrono::steady_clock::now() - start_; + return std::chrono::duration(elapsed).count(); + } + +private: + std::chrono::steady_clock::time_point start_ = std::chrono::steady_clock::now(); +}; + +} // namespace OSTPlatform diff --git a/src/OSTPlatform/include/Thread.h b/src/OSTPlatform/include/Thread.h new file mode 100644 index 0000000..71055c3 --- /dev/null +++ b/src/OSTPlatform/include/Thread.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +namespace OSTPlatform::Thread { + + using NativeThreadHandle = void*; + + NativeThreadHandle CurrentNativeThreadHandle(); + bool StartDetached(std::function entry); + +} // namespace OSTPlatform::Thread diff --git a/src/OSTPlatform/include/Trap.h b/src/OSTPlatform/include/Trap.h new file mode 100644 index 0000000..5e948bc --- /dev/null +++ b/src/OSTPlatform/include/Trap.h @@ -0,0 +1,38 @@ +#pragma once + +#include + +namespace OSTPlatform::Trap { + + enum class ExceptionKind { + Breakpoint, + SingleStep, + Other, + }; + + class Context { + public: + explicit Context(void* nativeContext) : nativeContext_(nativeContext) {} + + uint64_t InstructionPointer() const; + + // Nth integer/pointer argument (1-based) under the x64 calling + // convention. Args 1-4 come from RCX/RDX/R8/R9; args 5+ are read from + // the stack and are ONLY valid when the trap fired at the function's + // first instruction (RSP still at the return address, before the + // prologue moves it). Returns 0 on a bad index or unreadable stack slot. + uint64_t Argument(int index) const; + + void EnableSingleStep(); + + private: + void* nativeContext_ = nullptr; + }; + + using Handler = bool (*)(ExceptionKind kind, Context& context); + using HandlerHandle = void*; + + HandlerHandle AddVectoredHandler(Handler handler); + void RemoveVectoredHandler(HandlerHandle handle); + +} // namespace OSTPlatform::Trap diff --git a/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp b/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp new file mode 100644 index 0000000..cabeab9 --- /dev/null +++ b/src/Pipe/Features/DenuvoAuth/DenuvoAuth.cpp @@ -0,0 +1,202 @@ +#include "Pipe/Features/DenuvoAuth/DenuvoAuth.h" + +#include "Pipe/Features/DenuvoAuth/ProtectionScan.h" +#include "Utils/Logging/Log.h" +#include "Utils/Tickets/AppTicket.h" +#include "Utils/Config/LuaConfig.h" +#include "OSTPlatform/include/SteamCredentialStore.h" + +#include +#include +#include +#include + +namespace PipeManager::DenuvoAuth { +namespace { + + constexpr uint32 kEndDenuvoVerificationHandshake = 2; + + enum class Stage { + None, + Authorizing, + EndAuthorization, + }; + + const char* ToString(Stage stage) { + switch (stage) { + case Stage::None: return "None"; + case Stage::Authorizing: return "Authorizing"; + case Stage::EndAuthorization: return "EndAuthorization"; + } + return "?"; + } + + bool EqualsUniverseName(std::wstring_view lhs, std::wstring_view rhs) { + if (lhs.size() != rhs.size()) return false; + + for (size_t i = 0; i < lhs.size(); ++i) { + if (std::towlower(lhs[i]) != std::towlower(rhs[i])) return false; + } + + return true; + } + + EUniverse ParseUniverse(std::wstring_view universe) { + if (EqualsUniverseName(universe, L"Public")) return k_EUniversePublic; + if (EqualsUniverseName(universe, L"Beta")) return k_EUniverseBeta; + if (EqualsUniverseName(universe, L"Internal")) return k_EUniverseInternal; + if (EqualsUniverseName(universe, L"Dev")) return k_EUniverseDev; + return k_EUniverseInvalid; + } + + std::optional GetCurrentSteamIdForDenuvoAuth() { + uint32 accountId = 0; + std::wstring universeName; + const auto status = OSTPlatform::SteamCredentialStore::GetActiveUser(accountId, universeName); + if (status != OSTPlatform::SteamCredentialStore::Status::Ok) { + LOG_PIPE_WARN("DenuvoAuth: active Steam user unavailable ({})", + OSTPlatform::SteamCredentialStore::ToString(status)); + return std::nullopt; + } + + const EUniverse universe = ParseUniverse(universeName); + if (universe == k_EUniverseInvalid) { + LOG_PIPE_WARN("DenuvoAuth: active Steam user has unsupported universe"); + return std::nullopt; + } + + CSteamID steamId; + steamId.Set(accountId, universe, k_EAccountTypeIndividual); + return steamId.ConvertToUint64(); + } + + struct ProcessAuth { + bool scanned = false; + bool denuvo = false; + Stage stage = Stage::None; + uint32 pid = 0; + uint32 handshakeCount = 0; + + std::optional authorizationPipe; + AppId_t authorizedAppId = k_uAppIdInvalid; + + std::string DebugString() const { + return std::format("denuvo={} stage={} handshakeCount={} auth_appid={} pid={}", + denuvo, ToString(stage), handshakeCount, authorizedAppId, pid); + } + + void OnHandshake(const PipeContext& ctx, const PipeKey& pipeKey) { + ++handshakeCount; + + if (!denuvo) { + stage = Stage::None; + return; + } + + if (!authorizationPipe.has_value()) { + authorizationPipe = pipeKey; + authorizedAppId = ctx.appId; + pid = ctx.process.pid; + stage = Stage::Authorizing; + LOG_PIPE_INFO("DenuvoAuth: authorization pipe selected {}", this->DebugString()); + } + + if (stage == Stage::Authorizing && + handshakeCount >= kEndDenuvoVerificationHandshake) { + stage = Stage::EndAuthorization; + LOG_PIPE_INFO("DenuvoAuth: authorization window ended {}", this->DebugString()); + if(LuaConfig::IsOwned(authorizedAppId)){ + WriteSteamIdOnEndAuthorization(); + } + } + } + + bool CanUseAuthorizedIdentity(const PipeKey& key) const { + return denuvo && stage == Stage::Authorizing && authorizationPipe == key; + } + + void WriteSteamIdOnEndAuthorization() const { + if (authorizedAppId == k_uAppIdInvalid) { + LOG_PIPE_WARN("DenuvoAuth: end authorization skipped SteamID persist without auth app"); + return; + } + + const std::optional steamId = GetCurrentSteamIdForDenuvoAuth(); + if (!steamId || *steamId == 0) { + LOG_PIPE_WARN("DenuvoAuth: end authorization no current SteamID source auth_appid={}", + authorizedAppId); + return; + } + + if (AppTicket::WriteSteamID(authorizedAppId, *steamId)) { + LOG_PIPE_INFO("DenuvoAuth: persisted end-authorization SteamID auth_appid={} steamid={}", + authorizedAppId, *steamId); + return; + } + + LOG_PIPE_WARN("DenuvoAuth: failed to persist end-authorization SteamID auth_appid={} steamid={}", + authorizedAppId, *steamId); + } + }; + + // All access runs on the single Steam IPC thread, so these need no lock. + std::unordered_map g_processAuth; + std::unordered_map g_pipeProcess; + + ProcessAuth* FindAuthForPipe(const PipeKey& pipeKey) { + const auto pipeIt = g_pipeProcess.find(pipeKey); + if (pipeIt == g_pipeProcess.end()) return nullptr; + + const auto authIt = g_processAuth.find(pipeIt->second); + return authIt == g_processAuth.end() ? nullptr : &authIt->second; + } + + void EnsureScanned(ProcessAuth& auth, const ProcessKey& process) { + if (auth.scanned) { + LOG_PIPE_TRACE("DenuvoAuth: reusing cached protection result {} denuvo={}", + process.DebugString(), auth.denuvo); + return; + } + + auth.scanned = true; + auth.denuvo = ScanProtection(process.pid).denuvoDetected; + if (!auth.denuvo) auth.stage = Stage::None; + } + +} // namespace + +void Apply(const PipeContext& ctx) { + if (!ctx.gameProcess || !ctx.trackedApp) return; + + const PipeKey pipeKey = MakePipeKey(ctx.pipe); + if (!pipeKey.IsValid()) return; + + ProcessAuth& auth = g_processAuth[ctx.process]; + g_pipeProcess[pipeKey] = ctx.process; + + EnsureScanned(auth, ctx.process); + auth.OnHandshake(ctx, pipeKey); +} + +bool IsAuthorizedPipe(const CPipeClient* pipe) { + if (!pipe) { + LOG_PIPE_TRACE("DenuvoAuth: pipe not in authorization window: null pipe"); + return false; + } + + const PipeKey pipeKey = MakePipeKey(pipe); + const ProcessAuth* auth = FindAuthForPipe(pipeKey); + if (!auth) { + LOG_PIPE_TRACE("DenuvoAuth: pipe not tracked by DenuvoAuth {}", pipeKey.DebugString()); + return false; + } + + if (!auth->CanUseAuthorizedIdentity(pipeKey)) { + LOG_PIPE_TRACE("DenuvoAuth: pipe not in authorization window {} {}", pipeKey.DebugString(), auth->DebugString()); + return false; + } + LOG_PIPE_INFO("DenuvoAuth: pipe in authorization window {} {}", pipeKey.DebugString(), auth->DebugString()); + return true; +} + +} // namespace PipeManager::DenuvoAuth diff --git a/src/Pipe/Features/DenuvoAuth/DenuvoAuth.h b/src/Pipe/Features/DenuvoAuth/DenuvoAuth.h new file mode 100644 index 0000000..dbb97c0 --- /dev/null +++ b/src/Pipe/Features/DenuvoAuth/DenuvoAuth.h @@ -0,0 +1,15 @@ +#pragma once + +#include "Pipe/PipeTypes.h" + +namespace PipeManager::DenuvoAuth { + + // Per-handshake entry point: runs the one-time Denuvo detection (cached per + // process) and advances the authorization state machine. + void Apply(const PipeContext& ctx); + + // True only while the pipe is the selected authorization pipe and Denuvo has + // not reached the end-authorization handshake. + bool IsAuthorizedPipe(const CPipeClient* pipe); + +} // namespace PipeManager::DenuvoAuth diff --git a/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp b/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp new file mode 100644 index 0000000..bb46a6e --- /dev/null +++ b/src/Pipe/Features/DenuvoAuth/ProtectionScan.cpp @@ -0,0 +1,364 @@ +#include "Pipe/Features/DenuvoAuth/ProtectionScan.h" + +#include "OSTPlatform/include/ByteSearch.h" +#include "OSTPlatform/include/PE.h" +#include "OSTPlatform/include/Process.h" +#include "Utils/Logging/Log.h" +#include "Utils/Support/Stopwatch.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace PipeManager::DenuvoAuth { +namespace { + + struct ModuleCandidate { + std::string path; + std::filesystem::path nativePath; + uint32 size = 0; + bool executable = false; + bool inGameTree = false; + size_t order = 0; + + std::string DebugString() const { + return std::format("path={} size={} executable={} inGameTree={} order={}", + path, size, executable, inGameTree, order); + } + }; + + struct DetectionMatch { + DetectionMethod method = DetectionMethod::None; + std::string sectionName; + uint32 entryPointRva = 0; + uint32 matchRva = 0; + size_t matchRawOffset = 0; + }; + + constexpr std::array kLegacyDenuvoSections = { + ".arch", + ".srdata", + ".xpdata", + ".xdata", + ".xtls", + }; + + constexpr std::array kLegacyDenuvoString = { + 'D', 'E', 'N', 'U', 'V', 'O', + }; + + // High-version quick path: only scan the section containing the entry point. + constexpr std::array kDenuvoOepPattern = { + 0x48, 0xB9, 0x44, 0x4F, 0x44, 0x45, 0x4E, 0x55, 0x56, 0x4F, + }; + + // Packed Denuvo game modules are expected to be large; this avoids most + // small runtime DLLs before any PE parsing or disk reads. + constexpr uint32 kMinPackedModuleBytes = 80u * 1024u * 1024u; + constexpr size_t kLegacyScanChunkBytes = 8ull * 1024ull * 1024ull; + constexpr size_t kOepScanChunkBytes = 8ull * 1024ull * 1024ull; + + double BytesToMiB(uint64 bytes) { + return static_cast(bytes) / (1024.0 * 1024.0); + } + + constexpr std::array kSteamRuntimeModuleNames = { + "steamclient.dll", + "steamclient64.dll", + "steam_api.dll", + "steam_api64.dll", + "tier0_s.dll", + "tier0_s64.dll", + "vstdlib_s.dll", + "vstdlib_s64.dll", + "gameoverlayrenderer.dll", + "gameoverlayrenderer64.dll", + }; + + std::string Lower(std::string value) { + for (char& ch : value) { + ch = static_cast(std::tolower(static_cast(ch))); + } + return value; + } + + bool EndsWithInsensitive(std::string_view value, std::string_view suffix) { + if (value.size() < suffix.size()) return false; + + const size_t offset = value.size() - suffix.size(); + for (size_t i = 0; i < suffix.size(); ++i) { + const char a = static_cast(std::tolower(static_cast(value[offset + i]))); + const char b = static_cast(std::tolower(static_cast(suffix[i]))); + if (a != b) return false; + } + return true; + } + + std::string BaseNameFromPath(const std::string& path) { + const size_t slash = path.find_last_of("\\/"); + if (slash == std::string::npos) return path; + return path.substr(slash + 1); + } + + std::string DirectoryFromPath(const std::string& path) { + const size_t slash = path.find_last_of("\\/"); + if (slash == std::string::npos) return {}; + return path.substr(0, slash + 1); + } + + std::string NormalizeDirectoryPrefix(std::string path) { + path = Lower(std::move(path)); + std::replace(path.begin(), path.end(), '/', '\\'); + if (!path.empty() && path.back() != '\\') path += '\\'; + return path; + } + + bool PathStartsWithInsensitive(const std::string& path, const std::string& prefix) { + if (prefix.empty()) return false; + std::string normalizedPath = Lower(path); + std::replace(normalizedPath.begin(), normalizedPath.end(), '/', '\\'); + return normalizedPath.starts_with(prefix); + } + + bool IsSteamRuntimeModuleName(std::string name) { + name = Lower(std::move(name)); + return std::ranges::find(kSteamRuntimeModuleNames, name) != kSteamRuntimeModuleNames.end(); + } + + const OSTPlatform::PE::Section* FindLegacyDenuvoSection(const OSTPlatform::PE::Image& image) { + for (std::string_view name : kLegacyDenuvoSections) { + for (const auto& section : image.Sections()) { + if (section.name == name) return §ion; + } + } + return nullptr; + } + + std::optional TryLegacySectionString( + const ModuleCandidate& module, + const OSTPlatform::PE::Image& image, + const OSTPlatform::PE::Section& section) { + const auto matchRaw = OSTPlatform::ByteSearch::FindInFile( + module.nativePath, + kLegacyDenuvoString, + kLegacyScanChunkBytes); + if (!matchRaw) { + LOG_PIPE_DEBUG("DenuvoAuth: legacy section present but DENUVO string not found path={} section={}", + module.path, section.name); + return std::nullopt; + } + + DetectionMatch match{}; + match.method = DetectionMethod::LegacySectionString; + match.sectionName = section.name; + match.entryPointRva = image.EntryPointRva(); + match.matchRawOffset = static_cast(*matchRaw); + match.matchRva = image.RawOffsetToRva(match.matchRawOffset).value_or(0); + return match; + } + + std::optional TryOepPattern( + const ModuleCandidate& module, + const OSTPlatform::PE::Image& image) { + const uint32 oep = image.EntryPointRva(); + if (oep == 0) { + LOG_PIPE_TRACE("DenuvoAuth: module skipped zero OEP path={}", module.path); + return std::nullopt; + } + + const OSTPlatform::PE::Section* section = image.SectionContainingRva(oep); + if (!section) { + LOG_PIPE_TRACE("DenuvoAuth: module skipped OEP not in section path={} oep=0x{:X}", + module.path, oep); + return std::nullopt; + } + + if (section->rawSize == 0) { + LOG_PIPE_TRACE("DenuvoAuth: module skipped empty OEP section path={} section={} oep=0x{:X}", + module.path, section->name, oep); + return std::nullopt; + } + + // Streaming scan: read the section straight from disk in overlapped chunks + // so the BMH pass runs concurrently with the next chunk's read, instead of + // materializing the whole (~100s of MB) section in memory first. Returns the + // absolute file offset of the match. + const Utils::Stopwatch scanTimer; + const auto matchRaw = OSTPlatform::ByteSearch::FindInFileRange( + module.nativePath, + section->rawOffset, + section->rawSize, + kDenuvoOepPattern, + kOepScanChunkBytes); + const double scanMs = scanTimer.ElapsedMs(); + LOG_PIPE_DEBUG("DenuvoAuth: OEP section timing path={} section={} raw_start=0x{:X} scan_size={} ({:.2f} MB) scan_ms={:.3f} matched={}", + module.path, + section->name, + section->rawOffset, + section->rawSize, + BytesToMiB(static_cast(section->rawSize)), + scanMs, + matchRaw.has_value()); + if (!matchRaw) { + LOG_PIPE_DEBUG("DenuvoAuth: OEP section scanned path={} section={} oep=0x{:X} raw_start=0x{:X} scan_size={} ({:.2f} MB) matched=false", + module.path, + section->name, + oep, + section->rawOffset, + section->rawSize, + BytesToMiB(static_cast(section->rawSize))); + return std::nullopt; + } + + const size_t localMatch = static_cast(*matchRaw) - section->rawOffset; + DetectionMatch match{}; + match.method = DetectionMethod::OepPattern; + match.sectionName = section->name; + match.entryPointRva = oep; + match.matchRawOffset = static_cast(*matchRaw); + match.matchRva = section->virtualAddress + static_cast(localMatch); + return match; + } + + std::optional DetectModule( + const ModuleCandidate& module, + const OSTPlatform::PE::Image& image) { + // Prefer the OEP signature path; legacy section/string scan is broader. + if (const auto match = TryOepPattern(module, image)) { + return match; + } + + if (const auto* legacySection = FindLegacyDenuvoSection(image)) { + return TryLegacySectionString(module, image, *legacySection); + } + return std::nullopt; + } + + std::vector EnumerateModules(PID_t pid) { + const Utils::Stopwatch timer; + std::vector modules; + + size_t order = 0; + for (auto module : OSTPlatform::Process::EnumerateModules(pid)) { + if (module.path.empty()) continue; + + const bool executable = EndsWithInsensitive(module.path, ".exe"); + const bool dll = EndsWithInsensitive(module.path, ".dll"); + if (!executable && !dll) continue; + if (module.size < kMinPackedModuleBytes) { + LOG_PIPE_TRACE("DenuvoAuth: module skipped below packed size floor path={} size={} ({:.2f} MB) min={} ({:.2f} MB)", + module.path, + module.size, + BytesToMiB(module.size), + kMinPackedModuleBytes, + BytesToMiB(kMinPackedModuleBytes)); + continue; + } + if (!executable && OSTPlatform::Process::IsSystemModulePath(module.path)) continue; + if (!executable && IsSteamRuntimeModuleName(BaseNameFromPath(module.path))) { + LOG_PIPE_TRACE("DenuvoAuth: module skipped Steam runtime path={}", module.path); + continue; + } + + modules.push_back(ModuleCandidate{ + std::move(module.path), + std::move(module.nativePath), + module.size, + executable, + false, + order++, + }); + } + + std::string gameDirectory; + for (const auto& module : modules) { + if (!module.executable) continue; + gameDirectory = NormalizeDirectoryPrefix(DirectoryFromPath(module.path)); + break; + } + + for (auto& module : modules) { + module.inGameTree = !module.executable && + PathStartsWithInsensitive(module.path, gameDirectory); + } + + const size_t unsortedCount = modules.size(); + std::stable_sort(modules.begin(), modules.end(), + [](const ModuleCandidate& lhs, const ModuleCandidate& rhs) { + if (lhs.executable != rhs.executable) return lhs.executable; + if (!lhs.executable && lhs.inGameTree != rhs.inGameTree) return lhs.inGameTree; + if (!lhs.executable && lhs.size != rhs.size) return lhs.size > rhs.size; + return lhs.order < rhs.order; + }); + + LOG_PIPE_DEBUG("DenuvoAuth: pid={} enumerated {} candidate module(s) elapsed_ms={:.3f}", + pid, unsortedCount, timer.ElapsedMs()); + return modules; + } + + ProtectionScanReport RunProtectionScan(PID_t pid) { + ProtectionScanReport report{}; + if (pid == 0) return report; + + const Utils::Stopwatch totalTimer; + std::vector modules = EnumerateModules(pid); + LOG_PIPE_DEBUG("DenuvoAuth: pid={} scanning {} candidate module(s)", pid, modules.size()); + + for (const auto& module : modules) { + ++report.scannedModules; + LOG_PIPE_DEBUG("DenuvoAuth: module candidate {}: {}", report.scannedModules, module.DebugString()); + + const OSTPlatform::PE::Image image(module.nativePath); + if (!image) { + LOG_PIPE_DEBUG("DenuvoAuth: module skipped invalid or unreadable PE path={} size={}", + module.path, module.size); + continue; + } + + const auto match = DetectModule(module, image); + if (!match) continue; + + report.pid = pid; + report.denuvoDetected = true; + report.method = match->method; + report.modulePath = module.path; + report.sectionName = match->sectionName; + report.moduleSize = module.size; + report.entryPointRva = match->entryPointRva; + report.matchRva = match->matchRva; + report.matchRawOffset = match->matchRawOffset; + report.elapsedMs = totalTimer.ElapsedMs(); + + LOG_PIPE_INFO("DenuvoAuth: success to detect Denuvo in module {}", report.DebugString()); + return report; + } + + report.elapsedMs = totalTimer.ElapsedMs(); + LOG_PIPE_DEBUG("DenuvoAuth: pid={} no Denuvo match scanned={} elapsed_ms={:.3f}", + pid, report.scannedModules, report.elapsedMs); + return report; + } + +} // namespace + +const char* ToString(DetectionMethod method) { + switch (method) { + case DetectionMethod::None: return "None"; + case DetectionMethod::LegacySectionString: return "LegacySectionString"; + case DetectionMethod::OepPattern: return "OepPattern"; + } + return "Unknown"; +} + +ProtectionScanReport ScanProtection(PID_t pid) { + return RunProtectionScan(pid); +} + +} // namespace PipeManager::DenuvoAuth diff --git a/src/Pipe/Features/DenuvoAuth/ProtectionScan.h b/src/Pipe/Features/DenuvoAuth/ProtectionScan.h new file mode 100644 index 0000000..4d95160 --- /dev/null +++ b/src/Pipe/Features/DenuvoAuth/ProtectionScan.h @@ -0,0 +1,54 @@ +#pragma once + +// Denuvo-specific module scanner. ProcessInspector stays generic; this file +// owns only the current "is this process protected" policy. + +#include "Steam/Types.h" + +#include +#include +#include + +namespace PipeManager::DenuvoAuth { + + enum class DetectionMethod { + None, + LegacySectionString, + OepPattern, + }; + + const char* ToString(DetectionMethod method); + + struct ProtectionScanReport { + bool denuvoDetected = false; + DetectionMethod method = DetectionMethod::None; + std::string modulePath; + std::string sectionName; + uint32 pid = 0; + uint32 moduleSize = 0; + uint32 entryPointRva = 0; + uint32 matchRva = 0; + size_t matchRawOffset = 0; + size_t scannedModules = 0; + double elapsedMs = 0.0; + + std::string DebugString() const { + return std::format( + "[pid={}] denuvoDetected={} method={} module={} section={} moduleSize={} entryPointRva=0x{:X} matchRva=0x{:X} matchRaw=0x{:X} scannedModules={} elapsedMs={:.3f}", + pid, + denuvoDetected, + ToString(method), + modulePath.empty() ? "-" : modulePath, + sectionName.empty() ? "-" : sectionName, + moduleSize, + entryPointRva, + matchRva, + matchRawOffset, + scannedModules, + elapsedMs); + } + }; + + ProtectionScanReport ScanProtection(PID_t pid); + +} // namespace PipeManager::DenuvoAuth diff --git a/src/Pipe/Features/Injection/Injection.cpp b/src/Pipe/Features/Injection/Injection.cpp new file mode 100644 index 0000000..81948c4 --- /dev/null +++ b/src/Pipe/Features/Injection/Injection.cpp @@ -0,0 +1,74 @@ +#include "Pipe/Features/Injection/Injection.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 + +namespace PipeManager::Injection { +namespace { + + std::mutex g_mutex; + std::unordered_set g_injected; + + bool ClaimInjection(const ProcessKey& key) { + std::scoped_lock lock(g_mutex); + 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; + } + return nullptr; + } + +} // namespace + +void Apply(const PipeContext& ctx) { + const Config::InjectionSettings settings = Config::GetInjectionSettings(); + if (!settings.enabled) 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 (!ClaimInjection(ctx.process)) return; + + const std::filesystem::path libraryPath = ResolveLibraryPath(*configuredLibrary); + const auto status = OSTPlatform::RemoteProcess::InjectLibrary(ctx.process.pid, libraryPath); + if (status == OSTPlatform::RemoteProcess::InjectStatus::Ok) { + 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()); + } +} + +} // namespace PipeManager::Injection diff --git a/src/Pipe/Features/Injection/Injection.h b/src/Pipe/Features/Injection/Injection.h new file mode 100644 index 0000000..8555ef0 --- /dev/null +++ b/src/Pipe/Features/Injection/Injection.h @@ -0,0 +1,10 @@ +#pragma once + +#include "Pipe/PipeTypes.h" + +namespace PipeManager::Injection { + + // Injects the architecture-matched helper library at most once per game process. + void Apply(const PipeContext& ctx); + +} // namespace PipeManager::Injection diff --git a/src/Pipe/PipeManager.cpp b/src/Pipe/PipeManager.cpp new file mode 100644 index 0000000..7c0a52f --- /dev/null +++ b/src/Pipe/PipeManager.cpp @@ -0,0 +1,90 @@ +#include "Pipe/PipeManager.h" + +#include "Pipe/PipeTypes.h" +#include "Pipe/ProcessInspector.h" +#include "Pipe/Features/DenuvoAuth/DenuvoAuth.h" +#include "Pipe/Features/Injection/Injection.h" +#include "Utils/Logging/Log.h" +#include "Utils/Config/LuaConfig.h" + +#include +#include + +namespace PipeManager { +namespace { + + // OnHandshake runs single-threaded, so this cache needs no lock. + std::unordered_map g_processes; + + ProcessKey MakeProcessKey(const ProcessInspector::ProcessSnapshot& snapshot) { + return ProcessKey{snapshot.pid, snapshot.creationTime}; + } + + std::optional TryReuseCachedProcess(PID_t pid) { + const auto currentCreationTime = ProcessInspector::GetProcessCreationTime(pid); + if (!currentCreationTime) return std::nullopt; + + const ProcessKey currentKey{pid, *currentCreationTime}; + const auto it = g_processes.find(currentKey); + if (it == g_processes.end()) return std::nullopt; + + LOG_PIPE_DEBUG("PipeManager: reusing cached process snapshot pid={} process={}", + pid, it->second.DebugString()); + return it->second; + } + + ProcessInspector::ProcessSnapshot ResolveProcess(PID_t pid) { + if (auto cached = TryReuseCachedProcess(pid)) return *cached; + + // First sighting: inspect once and cache so sibling pipes reuse it. A dead + // process yields creationTime=0, so skip caching that junk key. + const ProcessInspector::ProcessSnapshot snapshot = ProcessInspector::InspectProcess(pid); + const ProcessKey processKey = MakeProcessKey(snapshot); + if (processKey.IsValid()) g_processes[processKey] = snapshot; + return snapshot; + } + +} // namespace + +void OnHandshake(CPipeClient* pipe) { + if (!pipe) { + LOG_PIPE_INFO("PipeManager: ignore null pipe handshake"); + return; + } + + const PipeKey pipeKey = MakePipeKey(pipe); + if (pipeKey.pid == 0) { + LOG_PIPE_DEBUG("PipeManager: ignore handshake with invalid pipe key {}", pipeKey.DebugString()); + return; + } + + const ProcessInspector::ProcessSnapshot snapshot = ResolveProcess(pipeKey.pid); + const ProcessKey processKey = MakeProcessKey(snapshot); + + if (!processKey.IsValid()) { + LOG_PIPE_DEBUG("PipeManager: ignore handshake with invalid process key {} snapshot={}", + processKey.DebugString(), snapshot.DebugString()); + return; + } + + const AppId_t appId = snapshot.ResolveAppId(); + const bool trackedApp = appId != k_uAppIdInvalid && LuaConfig::HasDepot(appId, false); + + PipeContext ctx{}; + ctx.pipe = pipe; + ctx.process = processKey; + ctx.appId = appId; + ctx.gameProcess = snapshot.likelyGameProcess; + ctx.trackedApp = trackedApp; + ctx.owned = trackedApp && LuaConfig::IsOwned(appId); + + LOG_PIPE_INFO("PipeManager: handshake {} process={} appid={} trackedApp={} snapshot={}", + pipeKey.DebugString(), processKey.DebugString(), appId, + trackedApp, snapshot.DebugString()); + + // Feature side effects run without holding the registry lock. + DenuvoAuth::Apply(ctx); + Injection::Apply(ctx); +} + +} // namespace PipeManager diff --git a/src/Pipe/PipeManager.h b/src/Pipe/PipeManager.h new file mode 100644 index 0000000..ea9df8f --- /dev/null +++ b/src/Pipe/PipeManager.h @@ -0,0 +1,12 @@ +#pragma once + +#include "Steam/Structs.h" +#include "Steam/Types.h" + +namespace PipeManager { + + // Called after steamclient processes a pipe handshake. Resolves the caller + // once, caches the process snapshot, then lets each Pipe feature react. + void OnHandshake(CPipeClient* pipe); + +} // namespace PipeManager diff --git a/src/Pipe/PipeTypes.h b/src/Pipe/PipeTypes.h new file mode 100644 index 0000000..b832c86 --- /dev/null +++ b/src/Pipe/PipeTypes.h @@ -0,0 +1,79 @@ +#pragma once + +// Shared identities passed between Pipe features. Keep feature-owned state in +// each feature module so this layer stays only about "who is calling". + +#include "Steam/Structs.h" +#include "Steam/Types.h" +#include "OSTPlatform/include/Process.h" + +#include +#include +#include +#include + +namespace PipeManager { + + // PID plus creation time lets caches survive PID reuse safely. + struct ProcessKey { + PID_t pid = 0; + uint64 creationTime = 0; + + bool operator==(const ProcessKey&) const = default; + + bool IsValid() const { + return pid != 0 && creationTime != 0; + } + + std::string DebugString() const { + return std::format("pid={} creation={} filetime={}",pid,OSTPlatform::Process::FormatCreationTime(creationTime),creationTime); + } + }; + + // Container companion for unordered caches; ProcessKey owns the meaning. + struct ProcessKeyHash { + std::size_t operator()(const ProcessKey& key) const noexcept { + return (static_cast(key.pid) << 1) ^ + static_cast(key.creationTime ^ (key.creationTime >> 32)); + } + }; + + // A process may open several Steam IPC pipes. + struct PipeKey { + PID_t pid = 0; + HSteamPipe pipe = 0; + + bool operator==(const PipeKey&) const = default; + + bool IsValid() const { + return pid != 0 && pipe != 0; + } + + std::string DebugString() const { + return std::format("pid={} pipe=0x{:08X}", pid, pipe); + } + }; + + // Container companion for unordered pipe maps; PipeKey owns the meaning. + struct PipeKeyHash { + std::size_t operator()(const PipeKey& key) const noexcept { + return (static_cast(key.pid) << 1) ^ static_cast(key.pipe); + } + }; + + inline PipeKey MakePipeKey(const CPipeClient* pipe) { + if (!pipe) return {}; + return PipeKey{pipe->m_clientPID, pipe->m_hSteamPipe}; + } + + struct PipeContext { + CPipeClient* pipe = nullptr; + ProcessKey process; + AppId_t appId = k_uAppIdInvalid; + // These flags are resolved once in PipeManager and consumed by features. + bool gameProcess = false; + bool trackedApp = false; + bool owned = false; + }; + +} // namespace PipeManager diff --git a/src/Pipe/ProcessInspector.cpp b/src/Pipe/ProcessInspector.cpp new file mode 100644 index 0000000..b2904e8 --- /dev/null +++ b/src/Pipe/ProcessInspector.cpp @@ -0,0 +1,94 @@ +#include "Pipe/ProcessInspector.h" + +#include "OSTPlatform/include/Process.h" +#include "OSTPlatform/include/Numbers.h" +#include "Utils/Logging/Log.h" +#include "Utils/Support/Stopwatch.h" + +#include +#include +#include +#include +#include + +namespace ProcessInspector { +namespace { + + std::string Lower(std::string value) { + for (char& ch : value) { + ch = static_cast(std::tolower(static_cast(ch))); + } + return value; + } + + std::string BaseNameFromPath(const std::string& path) { + const size_t slash = path.find_last_of("\\/"); + if (slash == std::string::npos) return path; + return path.substr(slash + 1); + } + + std::optional AppIdFromGameIdString(std::string_view value) { + const auto parsed = OSTPlatform::Numbers::ParseUInt64(value); + if (!parsed) return std::nullopt; + + const AppId_t appId = static_cast(*parsed & 0xFFFFFFu); + return appId == k_uAppIdInvalid ? std::nullopt : std::optional(appId); + } + + std::optional AppIdFromAppIdString(std::string_view value) { + const auto parsed = OSTPlatform::Numbers::ParseUInt32(value); + if (!parsed || *parsed == k_uAppIdInvalid) return std::nullopt; + return *parsed; + } + + std::optional QueryProcessImagePath(PID_t pid) { + return OSTPlatform::Process::GetImagePath(pid); + } + +} // namespace + +std::optional GetProcessCreationTime(PID_t pid) { + return OSTPlatform::Process::GetCreationTime(pid); +} + +bool IsSteamProcessName(std::string_view name) { + std::string normalized = Lower(std::string(name)); + return std::ranges::find(kSteamProcessNames, normalized) != kSteamProcessNames.end(); +} + +ProcessEnvironment ReadSteamEnvironment(PID_t pid) { + ProcessEnvironment env{}; + if (auto value = OSTPlatform::Process::GetEnvironmentVariableValue(pid, L"SteamAppId")) { + env.steamAppId = AppIdFromAppIdString(*value); + } + if (auto value = OSTPlatform::Process::GetEnvironmentVariableValue(pid, L"SteamGameId")) { + env.steamGameIdAppId = AppIdFromGameIdString(*value); + } + if (auto value = OSTPlatform::Process::GetEnvironmentVariableValue(pid, L"SteamOverlayGameId")) { + env.steamOverlayGameIdAppId = AppIdFromGameIdString(*value); + } + + LOG_PIPE_DEBUG("ProcessInspector: pid={} steam env {}", pid, env.DebugString()); + return env; +} + +ProcessSnapshot InspectProcess(PID_t pid) { + const Utils::Stopwatch timer; + ProcessSnapshot snapshot{}; + snapshot.pid = pid; + snapshot.creationTime = GetProcessCreationTime(pid).value_or(0); + + if (auto imagePath = QueryProcessImagePath(pid)) { + snapshot.imagePath = *imagePath; + snapshot.imageName = BaseNameFromPath(snapshot.imagePath); + } + snapshot.steamClientProcess = IsSteamProcessName(snapshot.imageName); + snapshot.environment = ReadSteamEnvironment(pid); + snapshot.likelyGameProcess = !snapshot.steamClientProcess && snapshot.environment.HasSteamAppEnvironment(); + + LOG_PIPE_INFO("ProcessInspector: inspected {} elapsed_ms={:.3f}", + snapshot.DebugString(), timer.ElapsedMs()); + return snapshot; +} + +} // namespace ProcessInspector diff --git a/src/Pipe/ProcessInspector.h b/src/Pipe/ProcessInspector.h new file mode 100644 index 0000000..4baf727 --- /dev/null +++ b/src/Pipe/ProcessInspector.h @@ -0,0 +1,78 @@ +#pragma once + +#include "Steam/Types.h" +#include "OSTPlatform/include/Process.h" + +#include +#include +#include +#include +#include + +namespace ProcessInspector { + + inline constexpr std::array kSteamProcessNames = { + "steam.exe", + "steamwebhelper.exe", + "steamservice.exe", + "steamerrorreporter.exe", + "gameoverlayui.exe", + "gameoverlayui64.exe", + }; + + struct ProcessEnvironment { + std::optional steamOverlayGameIdAppId; + std::optional steamGameIdAppId; + std::optional steamAppId; + + AppId_t ResolveAppId() const { + if (steamOverlayGameIdAppId) return *steamOverlayGameIdAppId; + if (steamGameIdAppId) return *steamGameIdAppId; + return steamAppId.value_or(k_uAppIdInvalid); + } + + bool HasSteamAppEnvironment() const { + return steamAppId || steamGameIdAppId || steamOverlayGameIdAppId; + } + + std::string DebugString() const { + return std::format( + "SteamAppId={} SteamGameId={} SteamOverlayGameId={} resolvedAppId={}", + steamAppId.value_or(k_uAppIdInvalid), + steamGameIdAppId.value_or(k_uAppIdInvalid), + steamOverlayGameIdAppId.value_or(k_uAppIdInvalid), + ResolveAppId()); + } + }; + + struct ProcessSnapshot { + PID_t pid = 0; + uint64 creationTime = 0; + std::string imagePath; + std::string imageName; + bool steamClientProcess = false; + bool likelyGameProcess = false; + ProcessEnvironment environment; + + AppId_t ResolveAppId() const { + return environment.ResolveAppId(); + } + + std::string DebugString() const { + return std::format( + "pid={} creation={} image={} steamClientProcess={} likelyGameProcess={} env=[{}]", + pid, + std::format("{} filetime={}",OSTPlatform::Process::FormatCreationTime(creationTime),creationTime), + imageName.empty() ? "-" : imageName, + steamClientProcess, + likelyGameProcess, + environment.DebugString()); + } + }; + + std::optional GetProcessCreationTime(PID_t pid); + bool IsSteamProcessName(std::string_view name); + ProcessEnvironment ReadSteamEnvironment(PID_t pid); + ProcessSnapshot InspectProcess(PID_t pid); + +} // namespace ProcessInspector diff --git a/src/Steam/IPCMessages.steamd b/src/Steam/IPCMessages.steamd index e6d7c82..7d5282e 100644 --- a/src/Steam/IPCMessages.steamd +++ b/src/Steam/IPCMessages.steamd @@ -86,6 +86,16 @@ struct IPCResponseHeader { protocol IPC { request IPCRequestHeader; + command IPCHandshake = EIPCCommand::Handshake { + request { + uint32 version; + uint32 pid; + uint32 tid; + } + + response none; + } + command IPCInterfaceCall = EIPCCommand::InterfaceCall { request { IPCInterfaceCallHeader header; @@ -119,6 +129,7 @@ interface IClientUser { ); bool GetEncryptedAppTicket( + in uint32 cbMaxTicket, out uint32 pcbTicket, out bytes pTicket[pcbTicket] ); diff --git a/src/Steam/Structs.h b/src/Steam/Structs.h index 28fcd28..344ca6e 100644 --- a/src/Steam/Structs.h +++ b/src/Steam/Structs.h @@ -243,9 +243,9 @@ struct ExtendedMsgHdr struct CPipeClient { void* m_pServer; // +0 void* m_pClient; // +8 - uint32 m_hSteamPipe; // +16 + HSteamPipe m_hSteamPipe; // +16 uint8 _pad0[12]; // +20 - uint32 m_clientPID; // +32 + PID_t m_clientPID; // +32 uint8 _pad1[4]; // +36 char* m_szProcessName; // +40 uint8 _pad2[80]; // +48 @@ -301,8 +301,13 @@ struct CGameID{ }; }; +constexpr unsigned int k_unSteamAccountIDMask = 0xFFFFFFFF; +constexpr unsigned int k_unSteamAccountInstanceMask = 0x000FFFFF; +constexpr unsigned int k_unSteamUserDefaultInstance = 1; // fixed instance for all individual users + struct CSteamID { + CSteamID() { m_steamid.m_comp.m_unAccountID = 0; @@ -316,6 +321,28 @@ struct CSteamID SetFromUint64( ulSteamID ); } + //----------------------------------------------------------------------------- + // Purpose: Sets parameters for steam ID + // Input : unAccountID - 32-bit account ID + // eUniverse - Universe this account belongs to + // eAccountType - Type of account + //----------------------------------------------------------------------------- + void Set( uint32 unAccountID, EUniverse eUniverse, EAccountType eAccountType ) + { + m_steamid.m_comp.m_unAccountID = unAccountID; + m_steamid.m_comp.m_EUniverse = eUniverse; + m_steamid.m_comp.m_EAccountType = eAccountType; + + if ( eAccountType == k_EAccountTypeClan || eAccountType == k_EAccountTypeGameServer ) + { + m_steamid.m_comp.m_unAccountInstance = 0; + } + else + { + m_steamid.m_comp.m_unAccountInstance = k_unSteamUserDefaultInstance; + } + } + void SetFromUint64( uint64 ulSteamID ) { m_steamid.m_unAll64Bits = ulSteamID; diff --git a/src/Utils/AppTicket.cpp b/src/Utils/AppTicket.cpp deleted file mode 100644 index e7616b6..0000000 --- a/src/Utils/AppTicket.cpp +++ /dev/null @@ -1,220 +0,0 @@ -#include "AppTicket.h" -#include "Hook/Hooks_Decryption.h" - -#include - -namespace AppTicket { - constexpr AppId_t kLocalAppTicketSourceAppId = 7; - - static uint64_t GetSteamIDFromRegistryString(AppId_t appId) { - HKEY hKey; - const std::string regPath = "Software\\Valve\\Steam\\Apps\\" + std::to_string(appId); - if (RegOpenKeyExA(HKEY_CURRENT_USER, regPath.c_str(), 0, KEY_READ, &hKey) != ERROR_SUCCESS) { - return 0; - } - - DWORD valueType = 0; - DWORD valueSize = 0; - if (RegQueryValueExA(hKey, "SteamID", nullptr, &valueType, nullptr, &valueSize) != ERROR_SUCCESS - || valueType != REG_SZ || valueSize == 0) { - RegCloseKey(hKey); - return 0; - } - - std::vector value(valueSize); - if (RegQueryValueExA(hKey, "SteamID", nullptr, nullptr, - reinterpret_cast(value.data()), &valueSize) != ERROR_SUCCESS) { - RegCloseKey(hKey); - return 0; - } - RegCloseKey(hKey); - - if (value.back() != '\0') { - value.push_back('\0'); - } - - const std::string steamIdStr(value.data()); - if (steamIdStr.empty()) { - return 0; - } - for (char c : steamIdStr) { - if (c < '0' || c > '9') { - return 0; - } - } - - const uint64_t steamID = std::strtoull(steamIdStr.c_str(), nullptr, 10); - if (steamID != 0) { - LOG_DEBUG("GetSpoofSteamID for AppId {}: SteamID REG_SZ -> 0x{:X}({})", appId, steamID, steamID); - } - return steamID; - } - - std::vector GetAppOwnershipTicketFromRegistry(AppId_t appId) { - LOG_TRACE("AppId={}", appId); - // exclude those appids that are not in addappid - if (!LuaConfig::HasDepot(appId)) { - LOG_DEBUG("GetAppOwnershipTicketFromRegistry for AppId {}: not in addappid, skip", appId); - return {}; - } - std::vector empty{}; - HKEY hKey; - const std::string regPath = "Software\\Valve\\Steam\\Apps\\" + std::to_string(appId); - if (RegOpenKeyExA(HKEY_CURRENT_USER, regPath.c_str(), 0, KEY_READ, &hKey) != ERROR_SUCCESS) { - return empty; - } - - std::vector value(1024); - DWORD valueSize = static_cast(value.size()); - DWORD valueType = 0; - if (RegQueryValueExA(hKey, "AppTicket", nullptr, &valueType,value.data(), &valueSize) != ERROR_SUCCESS - || valueType != REG_BINARY) { - RegCloseKey(hKey); - return empty; - } - RegCloseKey(hKey); - - value.resize(valueSize); - LOG_INFO("Successfully retrieved App Ownership Ticket from Registry, AppId: {}, Ticket Size: {}", appId, valueSize); - return value; - } - - // Exploit steamdrmp's off-by-four ticket parsing vulnerability: - static std::vector ForgeLocalAppOwnershipTicket(AppId_t appId) { - std::vector source = Hooks_Decryption::GetCacheAppOwnershipTicket(kLocalAppTicketSourceAppId); - if (source.size() <= kAppTicketSignatureSize) { - LOG_DEBUG("ForgeLocalAppOwnershipTicket for AppId {}: no source appticket", appId); - return {}; - } - - const size_t signedSize = source.size() - kAppTicketSignatureSize; - std::vector ticket; - ticket.reserve(source.size() + sizeof(AppId_t)); - ticket.insert(ticket.end(), source.begin(), source.begin() + signedSize); - - const uint8_t* appIdBytes = reinterpret_cast(&appId); - ticket.insert(ticket.end(), appIdBytes, appIdBytes + sizeof(AppId_t)); - ticket.insert(ticket.end(), source.begin() + signedSize, source.end()); - - LOG_INFO("Forged App Ownership Ticket, AppId: {}, SourceAppId: {}, Physical Size: {}, Total Size: {}", - appId, kLocalAppTicketSourceAppId, ticket.size(), source.size()); - return ticket; - } - - bool GetAppOwnershipTicket(AppId_t appId, AppOwnershipTicket& ticket) { - ticket = {}; - - ticket.data = GetAppOwnershipTicketFromRegistry(appId); - if (!ticket.data.empty() && ticket.data.size() >= sizeof(uint32)) { - ticket.totalSize = static_cast(ticket.data.size()); - ticket.appIdOffset = kAppTicketAppIdOffset; - ticket.steamIdOffset = kAppTicketSteamIdOffset; - ticket.signatureOffset = *reinterpret_cast(ticket.data.data()); - ticket.signatureSize = kAppTicketSignatureSize; - return true; - } - - ticket.data = ForgeLocalAppOwnershipTicket(appId); - if (ticket.data.empty()) { - return false; - } - - ticket.totalSize = static_cast(ticket.data.size() - sizeof(AppId_t)); - ticket.appIdOffset = ticket.totalSize - kAppTicketSignatureSize; - ticket.steamIdOffset = kAppTicketSteamIdOffset; - ticket.signatureOffset = ticket.appIdOffset + sizeof(AppId_t); - ticket.signatureSize = kAppTicketSignatureSize; - return true; - } - - std::vector GetEncryptedTicketFromRegistry(AppId_t appId) { - LOG_DEBUG("appid={}", appId); - // exclude those appids that are not in addappid - if (!LuaConfig::HasDepot(appId)) { - LOG_DEBUG("GetAppOwnershipTicketFromRegistry for AppId {}: not in addappid, skip", appId); - return {}; - } - std::vector empty{}; - HKEY hKey; - const std::string regPath = "Software\\Valve\\Steam\\Apps\\" + std::to_string(appId); - if (RegOpenKeyExA(HKEY_CURRENT_USER, regPath.c_str(), 0, KEY_READ, &hKey) != ERROR_SUCCESS) { - return empty; - } - - std::vector value(1024); - DWORD valueSize = static_cast(value.size()); - DWORD valueType = 0; - if (RegQueryValueExA(hKey, "ETicket", nullptr, &valueType,value.data(), &valueSize) != ERROR_SUCCESS - || valueType != REG_BINARY) { - RegCloseKey(hKey); - return empty; - } - RegCloseKey(hKey); - - value.resize(valueSize); - LOG_INFO("Successfully retrieved Encrypted App Ticket from Registry, AppId: {}, Ticket Size: {}", appId, valueSize); - return value; - } - - bool WriteAppOwnershipTicket(AppId_t appId, const std::vector& data) { - // we can't execlude appids here - HKEY hKey; - const std::string regPath = "Software\\Valve\\Steam\\Apps\\" + std::to_string(appId); - DWORD disposition; - if (RegCreateKeyExA(HKEY_CURRENT_USER, regPath.c_str(), 0, nullptr, 0, KEY_WRITE, nullptr, &hKey, &disposition) != ERROR_SUCCESS) { - LOG_ERROR("Failed to create/open registry key: {}", regPath); - return false; - } - LSTATUS result = RegSetValueExA(hKey, "AppTicket", 0, REG_BINARY, data.data(), static_cast(data.size())); - RegCloseKey(hKey); - if (result != ERROR_SUCCESS) { - LOG_ERROR("Failed to write AppTicket for AppId {}: {}", appId, result); - return false; - } - LOG_INFO("Wrote AppTicket for AppId {} ({} bytes)", appId, data.size()); - return true; - } - - bool WriteEncryptedTicket(AppId_t appId, const std::vector& data) { - // we can't execlude appids here - HKEY hKey; - const std::string regPath = "Software\\Valve\\Steam\\Apps\\" + std::to_string(appId); - DWORD disposition; - if (RegCreateKeyExA(HKEY_CURRENT_USER, regPath.c_str(), 0, nullptr, 0, KEY_WRITE, nullptr, &hKey, &disposition) != ERROR_SUCCESS) { - LOG_ERROR("Failed to create/open registry key: {}", regPath); - return false; - } - LSTATUS result = RegSetValueExA(hKey, "ETicket", 0, REG_BINARY, data.data(), static_cast(data.size())); - RegCloseKey(hKey); - if (result != ERROR_SUCCESS) { - LOG_ERROR("Failed to write ETicket for AppId {}: {}", appId, result); - return false; - } - LOG_INFO("Wrote ETicket for AppId {} ({} bytes)", appId, data.size()); - return true; - } - - uint64_t GetSpoofSteamID(AppId_t appId) { - // exclude those appids that are not in addappid - if (!LuaConfig::HasDepot(appId)) { - LOG_DEBUG("GetSpoofSteamID for AppId {}: not in addappid, skip spoofing", appId); - return 0; - } - const uint64_t registrySteamID = GetSteamIDFromRegistryString(appId); - if (registrySteamID != 0) { - return registrySteamID; - } - - // 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 = GetAppOwnershipTicketFromRegistry(appId); - if (ticket.size() >= 16) { - const uint64_t steamID = reinterpret_cast(ticket.data())[1]; - LOG_DEBUG("GetSpoofSteamID for AppId {}: -> 0x{:X}({})", appId, steamID, steamID); - return steamID; - } - return 0; - } -} diff --git a/src/Utils/ByteSearch.cpp b/src/Utils/ByteSearch.cpp deleted file mode 100644 index d341b68..0000000 --- a/src/Utils/ByteSearch.cpp +++ /dev/null @@ -1 +0,0 @@ -#include "ByteSearch.h" diff --git a/src/Utils/ByteSearch.h b/src/Utils/ByteSearch.h deleted file mode 100644 index dbaed14..0000000 --- a/src/Utils/ByteSearch.h +++ /dev/null @@ -1,13 +0,0 @@ -#pragma once - -// Memory patching helper and the FIND_SIG convenience macro. -// -// Signature scanning has been replaced by PatternLoader, which downloads -// per-DLL TOML files keyed on the SHA-256 of the DLL on disk. -// FIND_SIG now delegates directly to PatternLoader::FindPattern. -#include "PatternLoader.h" - -// FIND_SIG(module, FuncName) -// → PatternLoader::FindPattern(module, "FuncName") -// Drop-in replacement for the old ByteSearch / Patterns.h approach. -#define FIND_SIG(module, name) PatternLoader::FindPattern(module, #name) diff --git a/src/Utils/Config.cpp b/src/Utils/Config.cpp deleted file mode 100644 index 7401862..0000000 --- a/src/Utils/Config.cpp +++ /dev/null @@ -1,88 +0,0 @@ -#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()) { - remoteUrlTemplate = *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(), - remoteUrlTemplate.empty() ? "" : remoteUrlTemplate); - - } 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 deleted file mode 100644 index 02a3753..0000000 --- a/src/Utils/Config.h +++ /dev/null @@ -1,31 +0,0 @@ -#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; - -} diff --git a/src/Utils/Config/Config.cpp b/src/Utils/Config/Config.cpp new file mode 100644 index 0000000..eaedee3 --- /dev/null +++ b/src/Utils/Config/Config.cpp @@ -0,0 +1,217 @@ +#include "Config.h" +#include "Utils/Logging/Log.h" +#include "Utils/SteamMetadata/ManifestClient.h" + +#include + +#include +#include + +namespace Config { +namespace { + + struct Snapshot { + std::string manifestProvider = "opensteamtool"; + ManifestTimeouts manifestTimeouts; + LogLevel logLevel = LogLevel::Debug; + std::string logDir; + std::vector luaPaths; + std::string remoteUrlTemplate; + InjectionSettings injection; + }; + + std::mutex g_mutex; + bool g_loadedOnce = false; + + const char* ToString(LogLevel level) { + switch (level) { + 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"; + } + return "???"; + } + + Snapshot MakeDefaultSnapshot(const std::string& configPath) { + Snapshot snapshot; + std::filesystem::path p(configPath); + snapshot.logDir = (p.parent_path() / "opensteamtool").string(); + return snapshot; + } + + void ApplySnapshot(const Snapshot& snapshot) { + manifestTimeoutResolve = snapshot.manifestTimeouts.resolve; + manifestTimeoutConnect = snapshot.manifestTimeouts.connect; + manifestTimeoutSend = snapshot.manifestTimeouts.send; + manifestTimeoutRecv = snapshot.manifestTimeouts.recv; + logLevel = snapshot.logLevel; + logDir = snapshot.logDir; + luaPaths = snapshot.luaPaths; + remoteUrlTemplate = snapshot.remoteUrlTemplate; + injectEnabled = snapshot.injection.enabled; + injectLibraryX86 = snapshot.injection.libraryX86; + injectLibraryX64 = snapshot.injection.libraryX64; + } + + void ApplyManifestProvider(const std::string& provider) { + if (!ManifestClient::SetProvider(provider)) { + LOG_WARN("Unknown manifest.url \"{}\", keeping default", provider); + ManifestClient::SetProvider("opensteamtool"); + } + } + + LoadResult ApplySnapshotLocked(const Snapshot& snapshot) { + std::lock_guard lock(g_mutex); + LoadResult result; + result.luaPathsChanged = luaPaths != snapshot.luaPaths; + ApplySnapshot(snapshot); + g_loadedOnce = true; + result.applied = true; + return result; + } + +} // namespace + + LoadResult Load(const std::string& configPath) { + Snapshot snapshot = MakeDefaultSnapshot(configPath); + if (!std::filesystem::exists(configPath)) { + LOG_INFO("Config file not found, using defaults"); + ApplyManifestProvider(snapshot.manifestProvider); + LoadResult result = ApplySnapshotLocked(snapshot); + LOG_INFO("Config loaded: manifest.url={} log.level={} lua.paths={} remote.url_template={}", + ManifestClient::ActiveProviderName(), + ToString(GetLogLevel()), + (uint32_t)GetLuaPaths().size(), + GetRemoteUrlTemplate().empty() ? "" : GetRemoteUrlTemplate()); + return result; + } + + try { + auto tbl = toml::parse_file(configPath); + + // [manifest] + if (auto manifest = tbl["manifest"].as_table()) { + if (auto val = (*manifest)["url"].value()) { + snapshot.manifestProvider = *val; + } + if (auto val = (*manifest)["timeout_resolve_ms"].value()) + snapshot.manifestTimeouts.resolve = static_cast(*val); + if (auto val = (*manifest)["timeout_connect_ms"].value()) + snapshot.manifestTimeouts.connect = static_cast(*val); + if (auto val = (*manifest)["timeout_send_ms"].value()) + snapshot.manifestTimeouts.send = static_cast(*val); + if (auto val = (*manifest)["timeout_recv_ms"].value()) + snapshot.manifestTimeouts.recv = static_cast(*val); + } + + // [log] + if (auto log = tbl["log"].as_table()) { + if (auto val = (*log)["level"].value()) { + if (*val == "trace") snapshot.logLevel = LogLevel::Trace; + else if (*val == "debug") snapshot.logLevel = LogLevel::Debug; + else if (*val == "info") snapshot.logLevel = LogLevel::Info; + else if (*val == "warn") snapshot.logLevel = LogLevel::Warn; + else if (*val == "error") snapshot.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()) { + snapshot.luaPaths.push_back(*str); + } + } + } + } + + // [remote] + if (auto remote = tbl["remote"].as_table()) { + if (auto val = (*remote)["url_template"].value()) { + snapshot.remoteUrlTemplate = *val; + } + } + + // [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; + } + + ApplyManifestProvider(snapshot.manifestProvider); + LoadResult result = ApplySnapshotLocked(snapshot); + LOG_INFO("Config loaded: manifest.url={} log.level={} lua.paths={} remote.url_template={}", + ManifestClient::ActiveProviderName(), + ToString(snapshot.logLevel), + (uint32_t)snapshot.luaPaths.size(), + snapshot.remoteUrlTemplate.empty() ? "" : snapshot.remoteUrlTemplate); + return result; + + } catch (const toml::parse_error& e) { + LOG_WARN("Config parse error: {}", e.what()); + } catch (...) { + LOG_WARN("Config load failed"); + } + bool shouldApplyDefault = false; + { + std::lock_guard lock(g_mutex); + shouldApplyDefault = !g_loadedOnce; + } + if (shouldApplyDefault) { + ApplyManifestProvider(snapshot.manifestProvider); + std::lock_guard lock(g_mutex); + const bool luaChanged = luaPaths != snapshot.luaPaths; + ApplySnapshot(snapshot); + g_loadedOnce = true; + return {true, luaChanged}; + } + return {}; + } + + ManifestTimeouts GetManifestTimeouts() { + std::lock_guard lock(g_mutex); + return { + manifestTimeoutResolve, + manifestTimeoutConnect, + manifestTimeoutSend, + manifestTimeoutRecv, + }; + } + + LogLevel GetLogLevel() { + std::lock_guard lock(g_mutex); + return logLevel; + } + + std::string GetLogDir() { + std::lock_guard lock(g_mutex); + return logDir; + } + + std::vector GetLuaPaths() { + std::lock_guard lock(g_mutex); + return luaPaths; + } + + std::string GetRemoteUrlTemplate() { + std::lock_guard lock(g_mutex); + return remoteUrlTemplate; + } + + InjectionSettings GetInjectionSettings() { + std::lock_guard lock(g_mutex); + return { + injectEnabled, + injectLibraryX86, + injectLibraryX64, + }; + } + +} diff --git a/src/Utils/Config/Config.h b/src/Utils/Config/Config.h new file mode 100644 index 0000000..c681170 --- /dev/null +++ b/src/Utils/Config/Config.h @@ -0,0 +1,61 @@ +#pragma once + +#include +#include +#include + +namespace Config { + + enum class LogLevel { Trace, Debug, Info, Warn, Error }; + + struct ManifestTimeouts { + uint32_t resolve = 5000; + uint32_t connect = 5000; + uint32_t send = 10000; + uint32_t recv = 10000; + }; + + struct InjectionSettings { + bool enabled = false; + std::string libraryX86; + std::string libraryX64; + }; + + struct LoadResult { + bool applied = false; + bool luaPathsChanged = false; + }; + + LoadResult Load(const std::string& configPath); + + ManifestTimeouts GetManifestTimeouts(); + LogLevel GetLogLevel(); + std::string GetLogDir(); + std::vector GetLuaPaths(); + std::string GetRemoteUrlTemplate(); + InjectionSettings GetInjectionSettings(); + + // [manifest] — provider selection lives in ManifestClient (table-driven). + inline uint32_t manifestTimeoutResolve = 5000; + inline uint32_t manifestTimeoutConnect = 5000; + inline uint32_t manifestTimeoutSend = 10000; + inline uint32_t manifestTimeoutRecv = 10000; + + // [log] + inline LogLevel logLevel = LogLevel::Debug; + + // derived from configPath: /opensteamtool/ + inline std::string logDir; + + // [lua] + inline std::vector luaPaths; + + // [remote] + inline std::string remoteUrlTemplate; + + // [inject] - optional library injection into game processes. + inline bool injectEnabled = false; + inline std::string injectLibraryX86; + inline std::string injectLibraryX64; + +} diff --git a/src/Utils/Config/ConfigFileWatcher.cpp b/src/Utils/Config/ConfigFileWatcher.cpp new file mode 100644 index 0000000..94b8ea1 --- /dev/null +++ b/src/Utils/Config/ConfigFileWatcher.cpp @@ -0,0 +1,152 @@ +#include "Hook/Hooks_Package.h" +#include "Utils/Config/Config.h" +#include "Utils/Config/LuaConfig.h" +#include "Utils/Config/LuaFileWatcher.h" +#include "Utils/Config/ConfigFileWatcher.h" +#include "Utils/Logging/Log.h" +#include "OSTPlatform/include/DirectoryWatch.h" + +#include +#include +#include +#include +#include +#include + +namespace ConfigFileWatcher { +namespace { + +std::atomic g_running{false}; +std::thread g_watcherThread; +std::string g_configPath; +std::string g_defaultLuaDir; + +constexpr uint32_t kDebounceMs = 500; + +bool SameFileName(std::string_view lhs, std::string_view rhs) { + if (lhs.size() != rhs.size()) return false; + for (size_t i = 0; i < lhs.size(); ++i) { + if (std::tolower(static_cast(lhs[i])) != + std::tolower(static_cast(rhs[i]))) { + return false; + } + } + return true; +} + +bool ContainsConfigChange( + const std::vector& changes, + std::string_view targetFileName) { + for (const auto& change : changes) { + if (SameFileName(change.relativePath, targetFileName)) { + return true; + } + } + return false; +} + +std::vector BuildLuaWatchDirs() { + std::vector watchDirs = Config::GetLuaPaths(); + watchDirs.push_back(g_defaultLuaDir); + return watchDirs; +} + +void RestartLuaWatcher() { + std::vector watchDirs = BuildLuaWatchDirs(); + + LuaFileWatcher::Stop(); + LuaConfig::ReloadDirectories(watchDirs); + LuaFileWatcher::Start(watchDirs); + + Hooks_Package::NotifyLicenseChanged(); + LOG_INFO("Lua directories refreshed after config reload: {}", static_cast(watchDirs.size())); +} + +void ReloadConfig() { + LOG_INFO("Reloading config: {}", g_configPath); + + const Config::LoadResult result = Config::Load(g_configPath); + if (!result.applied) { + LOG_WARN("Config reload skipped; keeping previous valid config"); + return; + } + + Log::ApplyConfigLevel(); + + if (result.luaPathsChanged) { + RestartLuaWatcher(); + } +} + +void WatcherThread() { + const std::filesystem::path configPath(g_configPath); + const std::filesystem::path dirPath = configPath.parent_path(); + const std::string targetFileName = configPath.filename().string(); + + OSTPlatform::DirectoryWatch::Watch watch; + if (!watch.Open(dirPath.string(), 4096)) { + LOG_WARN("Failed to open config watch directory: {}", dirPath.string()); + return; + } + if (!watch.IssueRead()) { + return; + } + + LOG_INFO("Watching config file: {}", g_configPath); + + OSTPlatform::DirectoryWatch::Watch* watchPtr = &watch; + std::vector watches{watchPtr}; + + auto drainEvent = [&]() { + bool changed = ContainsConfigChange(watch.Drain(), targetFileName); + watch.IssueRead(); + return changed; + }; + + while (g_running) { + auto waitResult = OSTPlatform::DirectoryWatch::WaitAny(watches, 1000); + + if (!g_running) break; + if (waitResult.status == OSTPlatform::DirectoryWatch::WaitStatus::Timeout) continue; + if (waitResult.status != OSTPlatform::DirectoryWatch::WaitStatus::Signaled) continue; + + bool changed = drainEvent(); + while (g_running) { + auto debounceResult = OSTPlatform::DirectoryWatch::WaitAny(watches, kDebounceMs); + if (!g_running) break; + if (debounceResult.status == OSTPlatform::DirectoryWatch::WaitStatus::Timeout) break; + if (debounceResult.status != OSTPlatform::DirectoryWatch::WaitStatus::Signaled) break; + changed = drainEvent() || changed; + } + + if (changed) { + ReloadConfig(); + } + } + + watch.Cancel(); + LOG_INFO("Config watcher stopped"); +} + +} // namespace + +void Start(const std::string& configPath, const std::string& defaultLuaDir) { + if (g_running.exchange(true)) { + LOG_WARN("Config watcher already running"); + return; + } + + g_configPath = configPath; + g_defaultLuaDir = defaultLuaDir; + g_watcherThread = std::thread(WatcherThread); +} + +void Stop() { + if (!g_running) return; + g_running = false; + if (g_watcherThread.joinable()) { + g_watcherThread.join(); + } +} + +} // namespace ConfigFileWatcher diff --git a/src/Utils/Config/ConfigFileWatcher.h b/src/Utils/Config/ConfigFileWatcher.h new file mode 100644 index 0000000..fe698ec --- /dev/null +++ b/src/Utils/Config/ConfigFileWatcher.h @@ -0,0 +1,8 @@ +#pragma once + +#include + +namespace ConfigFileWatcher { + void Start(const std::string& configPath, const std::string& defaultLuaDir); + void Stop(); +} diff --git a/src/Utils/LuaConfig.cpp b/src/Utils/Config/LuaConfig.cpp similarity index 80% rename from src/Utils/LuaConfig.cpp rename to src/Utils/Config/LuaConfig.cpp index 404e34f..31d2421 100644 --- a/src/Utils/LuaConfig.cpp +++ b/src/Utils/Config/LuaConfig.cpp @@ -1,717 +1,835 @@ -#include "dllmain.h" -#include "LuaConfig.h" -#include "WinHttp.h" -#include "Utils/AppTicket.h" -#include -#include -#include -#include - -extern "C" { - struct lua_State; -} - -namespace LuaConfig{ - static lua_State* g_lua_state = nullptr; - static bool g_hasManifestCodeFunc = false; - static bool g_hasManifestCodeFuncEx = false; - std::unordered_mapDepotKeySet{}; - std::unordered_mapAccessTokenSet{}; - std::unordered_set PinnedApps{}; - std::unordered_map ManifestOverrides{}; - std::unordered_map StatSteamIdSet{}; - std::unordered_set OwnedAppIdSet{}; - - // Per-file tracking: which depots each .lua file contributed. - static std::string g_currentFile; - static std::unordered_map> g_fileDepots; - // Reference count: how many files provide each depot. - static std::unordered_map g_depotRefCount; - // mtime (unix epoch seconds) of each parsed .lua file, captured at ParseFile entry. - static std::unordered_map g_fileMtime; - // Per-appId purchase time: max(mtime) across every file that currently contributes it. - // Simple variant: never lowered on UnloadFile unless the refcount drops to zero. - static std::unordered_map g_purchaseTime; - // Depot IDs removed by UnloadFile / added by ParseFile, consumed by NotifyLicenseChanged. - static std::vector g_pendingRemovals; - static std::vector g_pendingAdditions; - constexpr uint64_t kDefaultStatSteamId = 76561198028121353ULL; - - // Case-insensitive function registry: lowercase name → C function - static std::unordered_map g_func_registry; - - static bool ParseUInt64Decimal(const char* text, uint64_t* out) { - if (!text || !*text || !out) return false; - if (!std::all_of(text, text + strlen(text), - [](unsigned char c) { return std::isdigit(c) != 0; })) { - return false; - } - try { - *out = std::stoull(text); - return true; - } catch (...) { - return false; - } - } - - // ── Lua HTTP helpers ────────────────────────────────────────── - // http_get(url [, headers]) → body, status_code - // headers: optional table, e.g. {["Accept"]="application/json"} - // - // http_post(url, post_body [, headers]) → body, status_code - // post_body: string payload - // ──────────────────────────────────────────────────────────────── - - // Serialise a Lua {key=val, ...} table at stack index idx into a - // wstring of "Key: Val\r\n" lines. Returns L"" if no table. - static std::wstring LuaHeadersToWstr(lua_State* L, int idx) { - std::wstring headers; - if (idx < 1 || !lua_istable(L, idx)) return headers; - lua_pushnil(L); - while (lua_next(L, idx)) { - std::string key(lua_tostring(L, -2)); - std::string val(lua_tostring(L, -1)); - headers += std::wstring(key.begin(), key.end()) - + L": " + std::wstring(val.begin(), val.end()) + L"\r\n"; - lua_pop(L, 1); - } - return headers; - } - - static int lua_http_get(lua_State* L) { - // http_get(url [, headers]) → body, status_code - auto hdrs = LuaHeadersToWstr(L, lua_gettop(L) >= 2 ? 2 : -1); - auto r = WinHttp::Execute(L"GET", luaL_checkstring(L, 1), - nullptr, 0, hdrs.empty() ? nullptr : hdrs.c_str()); - if (r.ok) { - lua_pushstring(L, r.body.c_str()); - lua_pushinteger(L, r.status); - } else { - lua_pushnil(L); - lua_pushstring(L, "HTTP request failed"); - } - return 2; - } - - static int lua_http_post(lua_State* L) { - // http_post(url, post_body [, headers]) → body, status_code - size_t bodyLen = 0; - const char* body = luaL_checklstring(L, 2, &bodyLen); - auto hdrs = LuaHeadersToWstr(L, lua_gettop(L) >= 3 ? 3 : -1); - auto r = WinHttp::Execute(L"POST", luaL_checkstring(L, 1), - body, static_cast(bodyLen), - hdrs.empty() ? nullptr : hdrs.c_str()); - if (r.ok) { - lua_pushstring(L, r.body.c_str()); - lua_pushinteger(L, r.status); - } else { - lua_pushnil(L); - lua_pushstring(L, "HTTP request failed"); - } - return 2; - } - - // ── Case-insensitive global function lookup ───────────────── - // __index metamethod on _G: when a global name isn't found, - // lower-case the name and look it up in g_func_registry. - static int case_insensitive_global_index(lua_State* L) { - const char* name = lua_tostring(L, 2); - if (!name) { - lua_pushnil(L); - return 1; - } - std::string lower; - for (const char* p = name; *p; ++p) { - lower += static_cast(std::tolower(static_cast(*p))); - } - auto it = g_func_registry.find(lower); - if (it != g_func_registry.end()) { - lua_pushcfunction(L, it->second); - return 1; - } - lua_pushnil(L); - return 1; - } - - // Register a C function both in _G (lowercase name) and in the - // case-insensitive lookup table so any case variant resolves. - static void register_func(lua_State* L, const char* lowercase_name, lua_CFunction fn) { - g_func_registry[lowercase_name] = fn; - lua_pushcfunction(L, fn); - lua_setglobal(L, lowercase_name); - } - - // ── Lua: addappid / addtoken / pinApp / setManifestid ──────── - static int lua_addappid(lua_State* L) { - // addappid(integer, integer, string) - int argc = lua_gettop(L); - // Validate argument count and required argument types. - if (argc == 0) { - return luaL_error(L, ""); - } - if (!lua_isinteger(L, 1)) { - return luaL_error(L, ""); - } - - // Read the first argument as app/depot id. - lua_Integer value = lua_tointeger(L, 1); - // Ensure the value fits into uint32_t range. - if (value < 0 || value > UINT32_MAX) - return luaL_error(L, ""); - AppId_t DepotId = (uint32_t)value; - // Read the optional third argument as a key. - std::string Key = ""; - if (argc > 2) { - if (!lua_isstring(L, 3)) - return luaL_error(L, ""); - const char* key = lua_tostring(L, 3); - // Keep only keys with exactly 64 characters. - if (strlen(key) == 64) { - Key = std::string(key); - } - } - // Non-empty keys have priority over existing empty keys. - if (!Key.empty() || !DepotKeySet.count(DepotId)) { - DepotKeySet[DepotId] = Key; - } - - if (!g_currentFile.empty()) { - if (g_fileDepots[g_currentFile].insert(DepotId).second) { - if (++g_depotRefCount[DepotId] == 1) - g_pendingAdditions.push_back(DepotId); - - // Update the appId's purchase time with the current file's mtime, - // keeping the maximum across every contributing file. - auto mtIt = g_fileMtime.find(g_currentFile); - if (mtIt != g_fileMtime.end()) { - uint32_t mt = mtIt->second; - auto& slot = g_purchaseTime[DepotId]; - if (mt > slot) slot = mt; - } - } - } - - return 0; - } - - static int lua_addtoken(lua_State* L) { - // addtoken(integer, string(uint64_t)) - int argc = lua_gettop(L); - // Validate argument count and required argument types. - if (argc == 0) { - return luaL_error(L, ""); - } - if (!lua_isinteger(L, 1)) { - return luaL_error(L, ""); - } - - // Read the first argument as app/depot id. - lua_Integer value = lua_tointeger(L, 1); - // Ensure the value fits into uint32_t range. - if (value < 0 || value > UINT32_MAX) - return luaL_error(L, ""); - AppId_t AppId = (uint32_t)value; - // Read the second argument as a token. - if (argc > 1) { - if (!lua_isstring(L, 2)) - return luaL_error(L, ""); - const char* token = lua_tostring(L, 2); - uint64_t parsedToken = 0; - if (!ParseUInt64Decimal(token, &parsedToken)) { - return luaL_error(L, ""); - } - AccessTokenSet[AppId] = parsedToken; - } - - return 0; - } - - static int lua_pinApp(lua_State* L) { - // pinApp(integer) - int argc = lua_gettop(L); - // Validate argument count and required argument types. - if (argc == 0) { - return luaL_error(L, ""); - } - if (!lua_isinteger(L, 1)) { - return luaL_error(L, ""); - } - - // Read the first argument as appid. - lua_Integer value = lua_tointeger(L, 1); - // Ensure the value fits into uint32_t range. - if (value < 0 || value > UINT32_MAX) - return luaL_error(L, ""); - AppId_t AppId = (uint32_t)value; - - PinnedApps.insert(AppId); - - return 0; - } - - static int lua_setManifestid(lua_State* L) - { - // setManifestid(depotId, gid_string [, size]) - // size is always forced to 0 to prevent incorrect size from breaking Steam. - int argc = lua_gettop(L); - if (argc < 2) - return luaL_error(L, "setManifestid: need depotId, gid"); - - if (!lua_isinteger(L, 1)) - return luaL_error(L, "setManifestid: depotId must be integer"); - if (!lua_isstring(L, 2)) - return luaL_error(L, "setManifestid: gid must be decimal string"); - - lua_Integer val = lua_tointeger(L, 1); - if (val < 0 || val > UINT32_MAX) - return luaL_error(L, "setManifestid: depotId out of range"); - - uint64_t depotId = (uint64_t)(uint32_t)val; - const char* gidStr = lua_tostring(L, 2); - - uint64_t gid = 0; - if (!ParseUInt64Decimal(gidStr, &gid)) - return luaL_error(L, "setManifestid: gid must be all digits"); - - ManifestOverrides[depotId] = { gid, 0 }; - return 0; - } - - // ── Lua: setAppTicket / setETicket ────────────────────────── - static int lua_setAppticket(lua_State* L) { - int argc = lua_gettop(L); - if (argc < 2) - return luaL_error(L, "setAppTicket: need appId and hex string"); - if (!lua_isinteger(L, 1)) - return luaL_error(L, "setAppTicket: appId must be integer"); - if (!lua_isstring(L, 2)) - return luaL_error(L, "setAppTicket: ticket must be hex string"); - - lua_Integer val = lua_tointeger(L, 1); - if (val < 0 || val > UINT32_MAX) - return luaL_error(L, "setAppTicket: appId out of range"); - AppId_t appId = static_cast(val); - - size_t hexLen; - const char* hex = lua_tolstring(L, 2, &hexLen); - - std::vector binary; - binary.reserve((hexLen + 1) / 2); - for (size_t i = 0; i < hexLen; i += 2) { - char byteStr[3] = { - hex[i], - i + 1 < hexLen ? hex[i + 1] : '0', - '\0' - }; - binary.push_back(static_cast(strtoul(byteStr, nullptr, 16))); - } - - if (!AppTicket::WriteAppOwnershipTicket(appId, binary)) - return luaL_error(L, "setAppTicket: failed to write to registry"); - - return 0; - } - - static int lua_setEticket(lua_State* L) { - int argc = lua_gettop(L); - if (argc < 2) - return luaL_error(L, "setETicket: need appId and hex string"); - if (!lua_isinteger(L, 1)) - return luaL_error(L, "setETicket: appId must be integer"); - if (!lua_isstring(L, 2)) - return luaL_error(L, "setETicket: ticket must be hex string"); - - lua_Integer val = lua_tointeger(L, 1); - if (val < 0 || val > UINT32_MAX) - return luaL_error(L, "setETicket: appId out of range"); - AppId_t appId = static_cast(val); - - size_t hexLen; - const char* hex = lua_tolstring(L, 2, &hexLen); - - std::vector binary; - binary.reserve((hexLen + 1) / 2); - for (size_t i = 0; i < hexLen; i += 2) { - char byteStr[3] = { - hex[i], - i + 1 < hexLen ? hex[i + 1] : '0', - '\0' - }; - binary.push_back(static_cast(strtoul(byteStr, nullptr, 16))); - } - - if (!AppTicket::WriteEncryptedTicket(appId, binary)) - return luaL_error(L, "setETicket: failed to write to registry"); - - return 0; - } - - - // ── Lua: setStat ──────────────────────────────────────────── - static int lua_setStat(lua_State* L) { - // setStat(appid, "steamid") - int argc = lua_gettop(L); - if (argc < 2) - return luaL_error(L, "setStat: need appId and steamId string"); - if (!lua_isinteger(L, 1)) - return luaL_error(L, "setStat: appId must be integer"); - if (!lua_isstring(L, 2)) - return luaL_error(L, "setStat: steamId must be string"); - - lua_Integer val = lua_tointeger(L, 1); - if (val < 0 || val > UINT32_MAX) - return luaL_error(L, "setStat: appId out of range"); - AppId_t appId = static_cast(val); - - const char* sidStr = lua_tostring(L, 2); - uint64_t steamId = 0; - if (!ParseUInt64Decimal(sidStr, &steamId)) - return luaL_error(L, "setStat: steamId must be all digits"); - - StatSteamIdSet[appId] = steamId; - return 0; - } - - // ── init / cleanup ─────────────────────────────────────────── - static bool Initialize() { - if (g_lua_state) - return true; - g_lua_state = luaL_newstate(); - if (!g_lua_state) - return false; - // Load standard Lua libraries. - luaL_openlibs(g_lua_state); - - // Set up case-insensitive global lookup via __index on _G's metatable. - lua_getglobal(g_lua_state, "_G"); - if (!lua_getmetatable(g_lua_state, -1)) { - lua_newtable(g_lua_state); - } - lua_pushcfunction(g_lua_state, case_insensitive_global_index); - lua_setfield(g_lua_state, -2, "__index"); - lua_setmetatable(g_lua_state, -2); - lua_pop(g_lua_state, 1); // pop _G - - // Register custom helper functions for scripts. - // All functions use register_func() so any case variant works - // (e.g. setAppTICKET, addAppId, SETManifestid, etc.). - register_func(g_lua_state, "addappid", lua_addappid); - register_func(g_lua_state, "addtoken", lua_addtoken); - // we don't need it? - // register_func(g_lua_state, "pinapp", lua_pinApp); - register_func(g_lua_state, "setmanifestid", lua_setManifestid); - register_func(g_lua_state, "http_get", lua_http_get); - register_func(g_lua_state, "http_post", lua_http_post); - register_func(g_lua_state, "setappticket", lua_setAppticket); - register_func(g_lua_state, "seteticket", lua_setEticket); - register_func(g_lua_state, "setstat", lua_setStat); - return true; - } - - static void Cleanup() { - if (g_lua_state) { - lua_close(g_lua_state); - g_lua_state = nullptr; - } - g_hasManifestCodeFunc = false; - } - - // ── public query API ───────────────────────────────────────── - bool HasDepot(AppId_t DepotId,bool checkOwned) { - return DepotKeySet.count(DepotId) && (!checkOwned || !IsOwned(DepotId)); - } - - bool IsOwned(AppId_t AppId) { - return OwnedAppIdSet.count(AppId); - } - - void MarkOwned(AppId_t AppId) { - if(!OwnedAppIdSet.count(AppId)) { - LOG_PACKAGE_INFO("Marking app {} as owned", AppId); - OwnedAppIdSet.insert(AppId); - } - } - - std::vector GetAllDepotIds() { - std::vector DepotIds; - for (const auto& pair : DepotKeySet) { - DepotIds.push_back(pair.first); - } - return DepotIds; - } - - std::vector GetDecryptionKey(AppId_t DepotId) { - std::vector keyBytes; - if (DepotKeySet.count(DepotId)) { - const std::string& keyStr = DepotKeySet[DepotId]; - // Convert hex string to byte vector. - for (size_t i = 0; i < keyStr.length(); i += 2) { - std::string byteString = keyStr.substr(i, 2); - uint8 byte = (uint8)strtoul(byteString.c_str(), nullptr, 16); - keyBytes.push_back(byte); - } - } - return keyBytes; - } - - uint64_t GetAccessToken(AppId_t AppId) { - if (AccessTokenSet.count(AppId)) { - return AccessTokenSet[AppId]; - } - return 0; - } - - bool pinApp(AppId_t AppId) { - return PinnedApps.count(AppId); - } - - uint64_t GetStatSteamId(AppId_t AppId) { - if (StatSteamIdSet.count(AppId)) - return StatSteamIdSet[AppId]; - return kDefaultStatSteamId; - } - - uint32_t GetPurchaseTime(AppId_t AppId) { - auto it = g_purchaseTime.find(AppId); - return it != g_purchaseTime.end() ? it->second : 0; - } - - const std::unordered_map& GetManifestOverrides() { - return ManifestOverrides; - } - - bool HasManifestCodeFunc() { - return g_hasManifestCodeFunc; - } - - bool CallManifestFetchCode(uint64_t gid, uint64_t* outCode) { - if (!g_hasManifestCodeFunc || !g_lua_state) - return false; - - lua_getglobal(g_lua_state, "fetch_manifest_code"); - lua_pushinteger(g_lua_state, static_cast(gid)); - - if (lua_pcall(g_lua_state, 1, 1, 0) != LUA_OK) { - LOG_MANIFEST_WARN("fetch_manifest_code({}) error: {}", gid, - lua_tostring(g_lua_state, -1)); - lua_pop(g_lua_state, 1); - return false; - } - - // nil → fallback to config url (normal, not an error) - if (lua_isnil(g_lua_state, -1)) { - LOG_MANIFEST_WARN("fetch_manifest_code({}) returned nil", gid); - lua_pop(g_lua_state, 1); - return false; - } - - // The function must return a digit string (uint64 as decimal). - // tonumber() loses precision for values > 2^53; string is safe. - if (lua_isinteger(g_lua_state, -1)) { - *outCode = static_cast(lua_tointeger(g_lua_state, -1)); - } else if (lua_isstring(g_lua_state, -1)) { - const char* s = lua_tostring(g_lua_state, -1); - uint64_t parsed = 0; - if (!ParseUInt64Decimal(s, &parsed)) { - LOG_MANIFEST_WARN("fetch_manifest_code({}) returned invalid numeric string '{}'", - gid, s); - lua_pop(g_lua_state, 1); - return false; - } - *outCode = parsed; - } else { - LOG_MANIFEST_WARN("fetch_manifest_code({}) unexpected type (expected digit-string)", - gid); - lua_pop(g_lua_state, 1); - return false; - } - - LOG_MANIFEST_INFO("fetch_manifest_code({}) = {}", gid, *outCode); - lua_pop(g_lua_state, 1); - return true; - } - - bool HasManifestCodeFuncEx() { - return g_hasManifestCodeFuncEx; - } - - bool CallManifestFetchCodeEx(uint64_t app_id, uint64_t depot_id, uint64_t gid, uint64_t* outCode) { - if (!g_hasManifestCodeFuncEx || !g_lua_state) - return false; - - lua_getglobal(g_lua_state, "fetch_manifest_code_ex"); - lua_pushinteger(g_lua_state, static_cast(app_id)); - lua_pushinteger(g_lua_state, static_cast(depot_id)); - lua_pushinteger(g_lua_state, static_cast(gid)); - - if (lua_pcall(g_lua_state, 3, 1, 0) != LUA_OK) { - LOG_MANIFEST_WARN("fetch_manifest_code_ex({}, {}, {}) error: {}", app_id, depot_id, gid, - lua_tostring(g_lua_state, -1)); - lua_pop(g_lua_state, 1); - return false; - } - - if (lua_isnil(g_lua_state, -1)) { - LOG_MANIFEST_WARN("fetch_manifest_code_ex({}, {}, {}) returned nil", app_id, depot_id, gid); - lua_pop(g_lua_state, 1); - return false; - } - - if (lua_isinteger(g_lua_state, -1)) { - *outCode = static_cast(lua_tointeger(g_lua_state, -1)); - } else if (lua_isstring(g_lua_state, -1)) { - const char* s = lua_tostring(g_lua_state, -1); - uint64_t parsed = 0; - if (!ParseUInt64Decimal(s, &parsed)) { - LOG_MANIFEST_WARN("fetch_manifest_code_ex({}, {}, {}) returned invalid numeric string '{}'", - app_id, depot_id, gid, s); - lua_pop(g_lua_state, 1); - return false; - } - *outCode = parsed; - } else { - LOG_MANIFEST_WARN("fetch_manifest_code_ex({}, {}, {}) unexpected type (expected digit-string)", - app_id, depot_id, gid); - lua_pop(g_lua_state, 1); - return false; - } - - LOG_MANIFEST_INFO("fetch_manifest_code_ex({}, {}, {}) = {}", app_id, depot_id, gid, *outCode); - lua_pop(g_lua_state, 1); - return true; - } - - // ── per-file unload ──────────────────────────────────────── - void UnloadFile(const std::string& filePath) { - auto it = g_fileDepots.find(filePath); - if (it == g_fileDepots.end()) return; - - for (AppId_t id : it->second) { - LOG_PACKAGE_DEBUG("UnloadFile:Ref count for AppId {} is {}", id, g_depotRefCount[id]); - if (--g_depotRefCount[id] == 0) { - g_depotRefCount.erase(id); - DepotKeySet.erase(id); - g_purchaseTime.erase(id); - g_pendingRemovals.push_back(id); - } - } - - LOG_PACKAGE_INFO("UnloadFile: removed {} depots from {}", it->second.size(), filePath); - g_fileDepots.erase(it); - g_fileMtime.erase(filePath); - } - - std::vector TakePendingRemovals() { - std::vector result; - result.swap(g_pendingRemovals); - return result; - } - - std::vector TakePendingAdditions() { - std::vector result; - result.swap(g_pendingAdditions); - return result; - } - - // ── single-file parser ────────────────────────────────────── - void ParseFile(const std::string& filePath) { - if (!Initialize()) return; - - // Remove old entries from this file before re-parsing. - UnloadFile(filePath); - g_currentFile = filePath; - - std::filesystem::path path(filePath); - std::ifstream file(path); - if (!file) { - LOG_WARN("ParseFile: failed to open {}", path.filename().string()); - return; - } - - // Capture the file's last-modified time (unix epoch, seconds) so - // lua_addappid can stamp it onto every appId this file contributes. - // Portable conversion that does not require C++20 clock_cast. - { - std::error_code ec; - auto ftime = std::filesystem::last_write_time(path, ec); - uint32_t mtime = 0; - if (!ec) { - auto sctp = std::chrono::time_point_cast( - ftime - decltype(ftime)::clock::now() + std::chrono::system_clock::now()); - mtime = static_cast(std::chrono::system_clock::to_time_t(sctp)); - } - g_fileMtime[filePath] = mtime; - } - - std::string chunk, line; - int lineNo = 0; - while (std::getline(file, line)) { - ++lineNo; - if (!chunk.empty()) chunk += '\n'; - chunk += line; - - lua_settop(g_lua_state, 0); - int rc = luaL_loadstring(g_lua_state, chunk.c_str()); - if (rc == LUA_OK) { - if (lua_pcall(g_lua_state, 0, 0, 0) != LUA_OK) { - const char* err = lua_tostring(g_lua_state, -1); - LOG_WARN("{}:{}: {}", path.filename().string(), lineNo, - err ? err : "unknown"); - } - chunk.clear(); - } else if (rc == LUA_ERRSYNTAX) { - lua_pop(g_lua_state, 1); - } else { - const char* err = lua_tostring(g_lua_state, -1); - LOG_WARN("{}:{}: {}", path.filename().string(), lineNo, err ? err : "unknown"); - lua_pop(g_lua_state, 1); - chunk.clear(); - } - } - if (!chunk.empty()) { - LOG_WARN("{}: incomplete statement at end of file", path.filename().string()); - } - - // Check for manifest code functions after parsing. - g_hasManifestCodeFunc = false; - lua_getglobal(g_lua_state, "fetch_manifest_code"); - if (lua_isfunction(g_lua_state, -1)) { - g_hasManifestCodeFunc = true; - LOG_INFO("manifest.lua: fetch_manifest_code found"); - } - lua_pop(g_lua_state, 1); - - g_hasManifestCodeFuncEx = false; - lua_getglobal(g_lua_state, "fetch_manifest_code_ex"); - if (lua_isfunction(g_lua_state, -1)) { - g_hasManifestCodeFuncEx = true; - LOG_INFO("manifest.lua: fetch_manifest_code_ex found"); - } - lua_pop(g_lua_state, 1); - - g_currentFile.clear(); - } - - // ── directory scanner ──────────────────────────────────────── - void ParseDirectory(const std::string& directory) { - if (!Initialize()) return; - - 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; - - for (const auto& entry : std::filesystem::directory_iterator(directory, ec)) { - if (ec) break; - if (!entry.is_regular_file()) continue; - if (entry.path().extension() != ".lua") continue; - ParseFile(entry.path().string()); - } - - // Initial parse — discard pending additions so NotifyLicenseChanged - // only sees changes that happen after startup. - g_pendingAdditions.clear(); - } - -} +#include "dllmain.h" +#include "OSTPlatform/include/Http.h" +#include "OSTPlatform/include/Numbers.h" +#include "Utils/Config/LuaConfig.h" +#include "Utils/Tickets/AppTicket.h" + +#include + +#include +#include +#include +#include +#include +#include + +extern "C" { + struct lua_State; +} + +namespace LuaConfig{ + static lua_State* g_lua_state = nullptr; + static bool g_hasManifestCodeFunc = false; + static bool g_hasManifestCodeFuncEx = false; + std::unordered_mapDepotKeySet{}; + std::unordered_mapAccessTokenSet{}; + std::unordered_set PinnedApps{}; + std::unordered_map ManifestOverrides{}; + std::unordered_map StatSteamIdSet{}; + std::unordered_set OwnedAppIdSet{}; + + // Per-file tracking: which depots each .lua file contributed. + static std::string g_currentFile; + static std::unordered_map> g_fileDepots; + static std::unordered_map> g_fileManifestOverrides; + static std::unordered_map g_fileParseSequence; + static uint64_t g_nextFileParseSequence = 0; + // Reference count: how many files provide each depot. + static std::unordered_map g_depotRefCount; + // mtime (unix epoch seconds) of each parsed .lua file, captured at ParseFile entry. + static std::unordered_map g_fileMtime; + // Per-appId purchase time: max(mtime) across every file that currently contributes it. + // Simple variant: never lowered on UnloadFile unless the refcount drops to zero. + static std::unordered_map g_purchaseTime; + // Depot IDs removed by UnloadFile / added by ParseFile, consumed by NotifyLicenseChanged. + static std::vector g_pendingRemovals; + static std::vector g_pendingAdditions; + constexpr uint64_t kDefaultStatSteamId = 76561198028121353ULL; + + // Case-insensitive function registry: lowercase name → C function + static std::unordered_map g_func_registry; + + static bool ParseUInt64Decimal(const char* text, uint64_t* out) { + if (!text || !out) return false; + const auto parsed = OSTPlatform::Numbers::ParseUInt64(text); + if (!parsed) return false; + *out = *parsed; + return true; + } + + static uint8 ParseHexByte(std::string_view text) { + return OSTPlatform::Numbers::ParseHexUInt8(text).value_or(0); + } + + static void SetActiveManifestOverride(uint64_t depotId, const ManifestOverride& override) { + ManifestOverrides[depotId] = override; + } + + static void ClearActiveManifestOverride(uint64_t depotId) { + ManifestOverrides.erase(depotId); + } + + static void RebuildManifestOverride(uint64_t depotId) { + const ManifestOverride* best = nullptr; + uint64_t bestSeq = 0; + + for (const auto& [file, overrides] : g_fileManifestOverrides) { + auto overrideIt = overrides.find(depotId); + if (overrideIt == overrides.end()) continue; + + auto seqIt = g_fileParseSequence.find(file); + if (seqIt == g_fileParseSequence.end()) continue; + + if (!best || seqIt->second > bestSeq) { + best = &overrideIt->second; + bestSeq = seqIt->second; + } + } + + if (best) { + SetActiveManifestOverride(depotId, *best); + } else { + ClearActiveManifestOverride(depotId); + } + } + + // ── Lua HTTP helpers ────────────────────────────────────────── + // http_get(url [, headers]) → body, status_code + // headers: optional table, e.g. {["Accept"]="application/json"} + // + // http_post(url, post_body [, headers]) → body, status_code + // post_body: string payload + // ──────────────────────────────────────────────────────────────── + + // Serialise a Lua {key=val, ...} table at stack index idx into a + // wstring of "Key: Val\r\n" lines. Returns L"" if no table. + static std::wstring LuaHeadersToWstr(lua_State* L, int idx) { + std::wstring headers; + if (idx < 1 || !lua_istable(L, idx)) return headers; + lua_pushnil(L); + while (lua_next(L, idx)) { + std::string key(lua_tostring(L, -2)); + std::string val(lua_tostring(L, -1)); + headers += std::wstring(key.begin(), key.end()) + + L": " + std::wstring(val.begin(), val.end()) + L"\r\n"; + lua_pop(L, 1); + } + return headers; + } + + static int lua_http_get(lua_State* L) { + // http_get(url [, headers]) → body, status_code + auto hdrs = LuaHeadersToWstr(L, lua_gettop(L) >= 2 ? 2 : -1); + auto r = OSTPlatform::Http::Execute(L"GET", luaL_checkstring(L, 1), + nullptr, 0, hdrs.empty() ? nullptr : hdrs.c_str()); + if (r.ok) { + lua_pushstring(L, r.body.c_str()); + lua_pushinteger(L, r.status); + } else { + lua_pushnil(L); + lua_pushstring(L, "HTTP request failed"); + } + return 2; + } + + static int lua_http_post(lua_State* L) { + // http_post(url, post_body [, headers]) → body, status_code + size_t bodyLen = 0; + const char* body = luaL_checklstring(L, 2, &bodyLen); + auto hdrs = LuaHeadersToWstr(L, lua_gettop(L) >= 3 ? 3 : -1); + auto r = OSTPlatform::Http::Execute(L"POST", luaL_checkstring(L, 1), + body, static_cast(bodyLen), + hdrs.empty() ? nullptr : hdrs.c_str()); + if (r.ok) { + lua_pushstring(L, r.body.c_str()); + lua_pushinteger(L, r.status); + } else { + lua_pushnil(L); + lua_pushstring(L, "HTTP request failed"); + } + return 2; + } + + // ── Case-insensitive global function lookup ───────────────── + // __index metamethod on _G: when a global name isn't found, + // lower-case the name and look it up in g_func_registry. + static int case_insensitive_global_index(lua_State* L) { + const char* name = lua_tostring(L, 2); + if (!name) { + lua_pushnil(L); + return 1; + } + std::string lower; + for (const char* p = name; *p; ++p) { + lower += static_cast(std::tolower(static_cast(*p))); + } + auto it = g_func_registry.find(lower); + if (it != g_func_registry.end()) { + lua_pushcfunction(L, it->second); + return 1; + } + lua_pushnil(L); + return 1; + } + + // Register a C function both in _G (lowercase name) and in the + // case-insensitive lookup table so any case variant resolves. + static void register_func(lua_State* L, const char* lowercase_name, lua_CFunction fn) { + g_func_registry[lowercase_name] = fn; + lua_pushcfunction(L, fn); + lua_setglobal(L, lowercase_name); + } + + // ── Lua: addappid / addtoken / pinApp / setManifestid ──────── + static int lua_addappid(lua_State* L) { + // addappid(integer, integer, string) + int argc = lua_gettop(L); + // Validate argument count and required argument types. + if (argc == 0) { + return luaL_error(L, ""); + } + if (!lua_isinteger(L, 1)) { + return luaL_error(L, ""); + } + + // Read the first argument as app/depot id. + lua_Integer value = lua_tointeger(L, 1); + // Ensure the value fits into uint32_t range. + if (value < 0 || value > UINT32_MAX) + return luaL_error(L, ""); + AppId_t DepotId = (uint32_t)value; + // Read the optional third argument as a key. + std::string Key = ""; + if (argc > 2) { + if (!lua_isstring(L, 3)) + return luaL_error(L, ""); + const char* key = lua_tostring(L, 3); + // Keep only keys with exactly 64 characters. + if (strlen(key) == 64) { + Key = std::string(key); + } + } + // Non-empty keys have priority over existing empty keys. + if (!Key.empty() || !DepotKeySet.count(DepotId)) { + DepotKeySet[DepotId] = Key; + } + + if (!g_currentFile.empty()) { + if (g_fileDepots[g_currentFile].insert(DepotId).second) { + if (++g_depotRefCount[DepotId] == 1) + g_pendingAdditions.push_back(DepotId); + + // Update the appId's purchase time with the current file's mtime, + // keeping the maximum across every contributing file. + auto mtIt = g_fileMtime.find(g_currentFile); + if (mtIt != g_fileMtime.end()) { + uint32_t mt = mtIt->second; + auto& slot = g_purchaseTime[DepotId]; + if (mt > slot) slot = mt; + } + } + } + + return 0; + } + + static int lua_addtoken(lua_State* L) { + // addtoken(integer, string(uint64_t)) + int argc = lua_gettop(L); + // Validate argument count and required argument types. + if (argc == 0) { + return luaL_error(L, ""); + } + if (!lua_isinteger(L, 1)) { + return luaL_error(L, ""); + } + + // Read the first argument as app/depot id. + lua_Integer value = lua_tointeger(L, 1); + // Ensure the value fits into uint32_t range. + if (value < 0 || value > UINT32_MAX) + return luaL_error(L, ""); + AppId_t AppId = (uint32_t)value; + // Read the second argument as a token. + if (argc > 1) { + if (!lua_isstring(L, 2)) + return luaL_error(L, ""); + const char* token = lua_tostring(L, 2); + uint64_t parsedToken = 0; + if (!ParseUInt64Decimal(token, &parsedToken)) { + return luaL_error(L, ""); + } + AccessTokenSet[AppId] = parsedToken; + } + + return 0; + } + + static int lua_pinApp(lua_State* L) { + // pinApp(integer) + int argc = lua_gettop(L); + // Validate argument count and required argument types. + if (argc == 0) { + return luaL_error(L, ""); + } + if (!lua_isinteger(L, 1)) { + return luaL_error(L, ""); + } + + // Read the first argument as appid. + lua_Integer value = lua_tointeger(L, 1); + // Ensure the value fits into uint32_t range. + if (value < 0 || value > UINT32_MAX) + return luaL_error(L, ""); + AppId_t AppId = (uint32_t)value; + + PinnedApps.insert(AppId); + + return 0; + } + + static int lua_setManifestid(lua_State* L) + { + // setManifestid(depotId, gid_string [, size]) + // size is always forced to 0 to prevent incorrect size from breaking Steam. + int argc = lua_gettop(L); + if (argc < 2) + return luaL_error(L, "setManifestid: need depotId, gid"); + + if (!lua_isinteger(L, 1)) + return luaL_error(L, "setManifestid: depotId must be integer"); + if (!lua_isstring(L, 2)) + return luaL_error(L, "setManifestid: gid must be decimal string"); + + lua_Integer val = lua_tointeger(L, 1); + if (val < 0 || val > UINT32_MAX) + return luaL_error(L, "setManifestid: depotId out of range"); + + uint64_t depotId = (uint64_t)(uint32_t)val; + const char* gidStr = lua_tostring(L, 2); + + uint64_t gid = 0; + if (!ParseUInt64Decimal(gidStr, &gid)) + return luaL_error(L, "setManifestid: gid must be all digits"); + + ManifestOverride override{ gid, 0 }; + if (!g_currentFile.empty()) { + g_fileManifestOverrides[g_currentFile][depotId] = override; + RebuildManifestOverride(depotId); + } else { + SetActiveManifestOverride(depotId, override); + } + return 0; + } + + // ── Lua: setAppTicket / setETicket ────────────────────────── + static int lua_setAppticket(lua_State* L) { + int argc = lua_gettop(L); + if (argc < 2) + return luaL_error(L, "setAppTicket: need appId and hex string"); + if (!lua_isinteger(L, 1)) + return luaL_error(L, "setAppTicket: appId must be integer"); + if (!lua_isstring(L, 2)) + return luaL_error(L, "setAppTicket: ticket must be hex string"); + + lua_Integer val = lua_tointeger(L, 1); + if (val < 0 || val > UINT32_MAX) + return luaL_error(L, "setAppTicket: appId out of range"); + AppId_t appId = static_cast(val); + + size_t hexLen; + const char* hex = lua_tolstring(L, 2, &hexLen); + + std::vector binary; + binary.reserve((hexLen + 1) / 2); + for (size_t i = 0; i < hexLen; i += 2) { + char byteStr[3] = { + hex[i], + i + 1 < hexLen ? hex[i + 1] : '0', + '\0' + }; + binary.push_back(ParseHexByte(byteStr)); + } + + if (!AppTicket::WriteAppOwnershipTicket(appId, binary)) + return luaL_error(L, "setAppTicket: failed to write credential store"); + + return 0; + } + + static int lua_setEticket(lua_State* L) { + int argc = lua_gettop(L); + if (argc < 2) + return luaL_error(L, "setETicket: need appId and hex string"); + if (!lua_isinteger(L, 1)) + return luaL_error(L, "setETicket: appId must be integer"); + if (!lua_isstring(L, 2)) + return luaL_error(L, "setETicket: ticket must be hex string"); + + lua_Integer val = lua_tointeger(L, 1); + if (val < 0 || val > UINT32_MAX) + return luaL_error(L, "setETicket: appId out of range"); + AppId_t appId = static_cast(val); + + size_t hexLen; + const char* hex = lua_tolstring(L, 2, &hexLen); + + std::vector binary; + binary.reserve((hexLen + 1) / 2); + for (size_t i = 0; i < hexLen; i += 2) { + char byteStr[3] = { + hex[i], + i + 1 < hexLen ? hex[i + 1] : '0', + '\0' + }; + binary.push_back(ParseHexByte(byteStr)); + } + + if (!AppTicket::WriteEncryptedTicket(appId, binary)) + return luaL_error(L, "setETicket: failed to write credential store"); + + return 0; + } + + + // ── Lua: setStat ──────────────────────────────────────────── + static int lua_setStat(lua_State* L) { + // setStat(appid, "steamid") + int argc = lua_gettop(L); + if (argc < 2) + return luaL_error(L, "setStat: need appId and steamId string"); + if (!lua_isinteger(L, 1)) + return luaL_error(L, "setStat: appId must be integer"); + if (!lua_isstring(L, 2)) + return luaL_error(L, "setStat: steamId must be string"); + + lua_Integer val = lua_tointeger(L, 1); + if (val < 0 || val > UINT32_MAX) + return luaL_error(L, "setStat: appId out of range"); + AppId_t appId = static_cast(val); + + const char* sidStr = lua_tostring(L, 2); + uint64_t steamId = 0; + if (!ParseUInt64Decimal(sidStr, &steamId)) + return luaL_error(L, "setStat: steamId must be all digits"); + + StatSteamIdSet[appId] = steamId; + return 0; + } + + // ── init / cleanup ─────────────────────────────────────────── + static bool Initialize() { + if (g_lua_state) + return true; + g_lua_state = luaL_newstate(); + if (!g_lua_state) + return false; + // Load standard Lua libraries. + luaL_openlibs(g_lua_state); + + // Set up case-insensitive global lookup via __index on _G's metatable. + lua_getglobal(g_lua_state, "_G"); + if (!lua_getmetatable(g_lua_state, -1)) { + lua_newtable(g_lua_state); + } + lua_pushcfunction(g_lua_state, case_insensitive_global_index); + lua_setfield(g_lua_state, -2, "__index"); + lua_setmetatable(g_lua_state, -2); + lua_pop(g_lua_state, 1); // pop _G + + // Register custom helper functions for scripts. + // All functions use register_func() so any case variant works + // (e.g. setAppTICKET, addAppId, SETManifestid, etc.). + register_func(g_lua_state, "addappid", lua_addappid); + register_func(g_lua_state, "addtoken", lua_addtoken); + // we don't need it? + // register_func(g_lua_state, "pinapp", lua_pinApp); + register_func(g_lua_state, "setmanifestid", lua_setManifestid); + register_func(g_lua_state, "http_get", lua_http_get); + register_func(g_lua_state, "http_post", lua_http_post); + register_func(g_lua_state, "setappticket", lua_setAppticket); + register_func(g_lua_state, "seteticket", lua_setEticket); + register_func(g_lua_state, "setstat", lua_setStat); + return true; + } + + static void Cleanup() { + if (g_lua_state) { + lua_close(g_lua_state); + g_lua_state = nullptr; + } + g_hasManifestCodeFunc = false; + } + + // ── public query API ───────────────────────────────────────── + bool HasDepot(AppId_t DepotId,bool excludeOwned) { + return DepotKeySet.count(DepotId) && (!excludeOwned || !IsOwned(DepotId)); + } + + bool IsOwned(AppId_t AppId) { + return OwnedAppIdSet.count(AppId); + } + + void MarkOwned(AppId_t AppId) { + if(!OwnedAppIdSet.count(AppId)) { + LOG_PACKAGE_INFO("Marking app {} as owned", AppId); + OwnedAppIdSet.insert(AppId); + } + } + + std::vector GetAllDepotIds() { + std::vector DepotIds; + for (const auto& pair : DepotKeySet) { + DepotIds.push_back(pair.first); + } + return DepotIds; + } + + std::vector GetDecryptionKey(AppId_t DepotId) { + std::vector keyBytes; + if (DepotKeySet.count(DepotId)) { + const std::string& keyStr = DepotKeySet[DepotId]; + // Convert hex string to byte vector. + for (size_t i = 0; i < keyStr.length(); i += 2) { + keyBytes.push_back(ParseHexByte(std::string_view(keyStr).substr(i, 2))); + } + } + return keyBytes; + } + + uint64_t GetAccessToken(AppId_t AppId) { + if (AccessTokenSet.count(AppId)) { + return AccessTokenSet[AppId]; + } + return 0; + } + + bool pinApp(AppId_t AppId) { + return PinnedApps.count(AppId); + } + + uint64_t GetStatSteamId(AppId_t AppId) { + if (StatSteamIdSet.count(AppId)) + return StatSteamIdSet[AppId]; + return kDefaultStatSteamId; + } + + uint32_t GetPurchaseTime(AppId_t AppId) { + auto it = g_purchaseTime.find(AppId); + return it != g_purchaseTime.end() ? it->second : 0; + } + + const std::unordered_map& GetManifestOverrides() { + return ManifestOverrides; + } + + bool HasManifestCodeFunc() { + return g_hasManifestCodeFunc; + } + + bool CallManifestFetchCode(uint64_t gid, uint64_t* outCode) { + if (!g_hasManifestCodeFunc || !g_lua_state) + return false; + + lua_getglobal(g_lua_state, "fetch_manifest_code"); + lua_pushinteger(g_lua_state, static_cast(gid)); + + if (lua_pcall(g_lua_state, 1, 1, 0) != LUA_OK) { + LOG_MANIFEST_WARN("fetch_manifest_code({}) error: {}", gid, + lua_tostring(g_lua_state, -1)); + lua_pop(g_lua_state, 1); + return false; + } + + // nil → fallback to config url (normal, not an error) + if (lua_isnil(g_lua_state, -1)) { + LOG_MANIFEST_WARN("fetch_manifest_code({}) returned nil", gid); + lua_pop(g_lua_state, 1); + return false; + } + + // The function must return a digit string (uint64 as decimal). + // tonumber() loses precision for values > 2^53; string is safe. + if (lua_isinteger(g_lua_state, -1)) { + *outCode = static_cast(lua_tointeger(g_lua_state, -1)); + } else if (lua_isstring(g_lua_state, -1)) { + const char* s = lua_tostring(g_lua_state, -1); + uint64_t parsed = 0; + if (!ParseUInt64Decimal(s, &parsed)) { + LOG_MANIFEST_WARN("fetch_manifest_code({}) returned invalid numeric string '{}'", + gid, s); + lua_pop(g_lua_state, 1); + return false; + } + *outCode = parsed; + } else { + LOG_MANIFEST_WARN("fetch_manifest_code({}) unexpected type (expected digit-string)", + gid); + lua_pop(g_lua_state, 1); + return false; + } + + LOG_MANIFEST_INFO("fetch_manifest_code({}) = {}", gid, *outCode); + lua_pop(g_lua_state, 1); + return true; + } + + bool HasManifestCodeFuncEx() { + return g_hasManifestCodeFuncEx; + } + + bool CallManifestFetchCodeEx(uint64_t app_id, uint64_t depot_id, uint64_t gid, uint64_t* outCode) { + if (!g_hasManifestCodeFuncEx || !g_lua_state) + return false; + + lua_getglobal(g_lua_state, "fetch_manifest_code_ex"); + lua_pushinteger(g_lua_state, static_cast(app_id)); + lua_pushinteger(g_lua_state, static_cast(depot_id)); + lua_pushinteger(g_lua_state, static_cast(gid)); + + if (lua_pcall(g_lua_state, 3, 1, 0) != LUA_OK) { + LOG_MANIFEST_WARN("fetch_manifest_code_ex({}, {}, {}) error: {}", app_id, depot_id, gid, + lua_tostring(g_lua_state, -1)); + lua_pop(g_lua_state, 1); + return false; + } + + if (lua_isnil(g_lua_state, -1)) { + LOG_MANIFEST_WARN("fetch_manifest_code_ex({}, {}, {}) returned nil", app_id, depot_id, gid); + lua_pop(g_lua_state, 1); + return false; + } + + if (lua_isinteger(g_lua_state, -1)) { + *outCode = static_cast(lua_tointeger(g_lua_state, -1)); + } else if (lua_isstring(g_lua_state, -1)) { + const char* s = lua_tostring(g_lua_state, -1); + uint64_t parsed = 0; + if (!ParseUInt64Decimal(s, &parsed)) { + LOG_MANIFEST_WARN("fetch_manifest_code_ex({}, {}, {}) returned invalid numeric string '{}'", + app_id, depot_id, gid, s); + lua_pop(g_lua_state, 1); + return false; + } + *outCode = parsed; + } else { + LOG_MANIFEST_WARN("fetch_manifest_code_ex({}, {}, {}) unexpected type (expected digit-string)", + app_id, depot_id, gid); + lua_pop(g_lua_state, 1); + return false; + } + + LOG_MANIFEST_INFO("fetch_manifest_code_ex({}, {}, {}) = {}", app_id, depot_id, gid, *outCode); + lua_pop(g_lua_state, 1); + return true; + } + + // ── per-file unload ──────────────────────────────────────── + void UnloadFile(const std::string& filePath) { + auto depotsIt = g_fileDepots.find(filePath); + auto manifestIt = g_fileManifestOverrides.find(filePath); + if (depotsIt == g_fileDepots.end() && manifestIt == g_fileManifestOverrides.end()) return; + + if (depotsIt != g_fileDepots.end()) { + for (AppId_t id : depotsIt->second) { + LOG_PACKAGE_DEBUG("UnloadFile:Ref count for AppId {} is {}", id, g_depotRefCount[id]); + if (--g_depotRefCount[id] == 0) { + g_depotRefCount.erase(id); + DepotKeySet.erase(id); + g_purchaseTime.erase(id); + g_pendingRemovals.push_back(id); + } + } + + LOG_PACKAGE_INFO("UnloadFile: removed {} depots from {}", depotsIt->second.size(), filePath); + g_fileDepots.erase(depotsIt); + } + + if (manifestIt != g_fileManifestOverrides.end()) { + std::vector affectedDepots; + affectedDepots.reserve(manifestIt->second.size()); + for (const auto& [depotId, _] : manifestIt->second) { + affectedDepots.push_back(depotId); + } + + g_fileManifestOverrides.erase(manifestIt); + for (uint64_t depotId : affectedDepots) { + RebuildManifestOverride(depotId); + } + + LOG_MANIFEST_INFO("UnloadFile: removed {} manifest override(s) from {}", affectedDepots.size(), filePath); + } + + g_fileParseSequence.erase(filePath); + g_fileMtime.erase(filePath); + } + + std::vector TakePendingRemovals() { + std::vector result; + result.swap(g_pendingRemovals); + return result; + } + + std::vector TakePendingAdditions() { + std::vector result; + result.swap(g_pendingAdditions); + return result; + } + + static std::vector CollectLuaFiles(const std::string& directory) { + 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; + + for (const auto& entry : std::filesystem::directory_iterator(directory, ec)) { + if (ec) break; + if (!entry.is_regular_file()) continue; + if (entry.path().extension() != ".lua") continue; + files.push_back(entry.path().string()); + } + return files; + } + + // ── single-file parser ────────────────────────────────────── + void ParseFile(const std::string& filePath) { + if (!Initialize()) return; + + // Remove old entries from this file before re-parsing. + UnloadFile(filePath); + g_currentFile = filePath; + + std::filesystem::path path(filePath); + std::ifstream file(path); + if (!file) { + LOG_WARN("ParseFile: failed to open {}", path.filename().string()); + g_currentFile.clear(); + return; + } + g_fileParseSequence[filePath] = ++g_nextFileParseSequence; + + // Capture the file's last-modified time (unix epoch, seconds) so + // lua_addappid can stamp it onto every appId this file contributes. + // Portable conversion that does not require C++20 clock_cast. + { + std::error_code ec; + auto ftime = std::filesystem::last_write_time(path, ec); + uint32_t mtime = 0; + if (!ec) { + auto sctp = std::chrono::time_point_cast( + ftime - decltype(ftime)::clock::now() + std::chrono::system_clock::now()); + mtime = static_cast(std::chrono::system_clock::to_time_t(sctp)); + } + g_fileMtime[filePath] = mtime; + } + + std::string chunk, line; + int lineNo = 0; + while (std::getline(file, line)) { + ++lineNo; + if (!chunk.empty()) chunk += '\n'; + chunk += line; + + lua_settop(g_lua_state, 0); + int rc = luaL_loadstring(g_lua_state, chunk.c_str()); + if (rc == LUA_OK) { + if (lua_pcall(g_lua_state, 0, 0, 0) != LUA_OK) { + const char* err = lua_tostring(g_lua_state, -1); + LOG_WARN("{}:{}: {}", path.filename().string(), lineNo, + err ? err : "unknown"); + } + chunk.clear(); + } else if (rc == LUA_ERRSYNTAX) { + lua_pop(g_lua_state, 1); + } else { + const char* err = lua_tostring(g_lua_state, -1); + LOG_WARN("{}:{}: {}", path.filename().string(), lineNo, err ? err : "unknown"); + lua_pop(g_lua_state, 1); + chunk.clear(); + } + } + if (!chunk.empty()) { + LOG_WARN("{}: incomplete statement at end of file", path.filename().string()); + } + + // Check for manifest code functions after parsing. + g_hasManifestCodeFunc = false; + lua_getglobal(g_lua_state, "fetch_manifest_code"); + if (lua_isfunction(g_lua_state, -1)) { + g_hasManifestCodeFunc = true; + LOG_INFO("manifest.lua: fetch_manifest_code found"); + } + lua_pop(g_lua_state, 1); + + g_hasManifestCodeFuncEx = false; + lua_getglobal(g_lua_state, "fetch_manifest_code_ex"); + if (lua_isfunction(g_lua_state, -1)) { + g_hasManifestCodeFuncEx = true; + LOG_INFO("manifest.lua: fetch_manifest_code_ex found"); + } + lua_pop(g_lua_state, 1); + + g_currentFile.clear(); + } + + // ── directory scanner ──────────────────────────────────────── + void ParseDirectory(const std::string& directory) { + if (!Initialize()) return; + + for (const auto& filePath : CollectLuaFiles(directory)) { + ParseFile(filePath); + } + + // Initial parse — discard pending additions so NotifyLicenseChanged + // only sees changes that happen after startup. + g_pendingAdditions.clear(); + } + + void ReloadDirectories(const std::vector& directories, bool clearPendingAdditions) { + if (!Initialize()) return; + + std::unordered_set activeFiles; + std::vector orderedFiles; + + for (const auto& directory : directories) { + for (const auto& filePath : CollectLuaFiles(directory)) { + if (activeFiles.insert(filePath).second) { + orderedFiles.push_back(filePath); + } + } + } + + std::unordered_set trackedSet; + std::vector trackedFiles; + auto rememberTracked = [&](const std::string& filePath) { + if (trackedSet.insert(filePath).second) { + trackedFiles.push_back(filePath); + } + }; + + for (const auto& [filePath, _] : g_fileDepots) { + rememberTracked(filePath); + } + for (const auto& [filePath, _] : g_fileManifestOverrides) { + rememberTracked(filePath); + } + + for (const auto& filePath : trackedFiles) { + if (!activeFiles.contains(filePath)) { + UnloadFile(filePath); + } + } + + for (const auto& filePath : orderedFiles) { + ParseFile(filePath); + } + + if (clearPendingAdditions) { + g_pendingAdditions.clear(); + } + } + +} diff --git a/src/Utils/LuaConfig.h b/src/Utils/Config/LuaConfig.h similarity index 90% rename from src/Utils/LuaConfig.h rename to src/Utils/Config/LuaConfig.h index 61bb53f..1670d92 100644 --- a/src/Utils/LuaConfig.h +++ b/src/Utils/Config/LuaConfig.h @@ -1,40 +1,43 @@ -#ifndef LUACONFIG_H -#define LUACONFIG_H - -#include -#include -#include -#include - -namespace LuaConfig{ - bool HasDepot(AppId_t appId, bool checkOwned=true); - bool IsOwned(AppId_t appId); - void MarkOwned(AppId_t appId); - std::vector GetAllDepotIds(); - std::vector GetDecryptionKey(AppId_t appId); - uint64_t GetAccessToken(AppId_t appId); - uint64_t GetStatSteamId(AppId_t appId); - bool pinApp(AppId_t appId); - uint32_t GetPurchaseTime(AppId_t appId); - - struct ManifestOverride { - uint64_t gid; - uint64_t size; - }; - const std::unordered_map& GetManifestOverrides(); - - void ParseFile(const std::string& filePath); - void UnloadFile(const std::string& filePath); - // Returns and clears the list of depot IDs removed/added since last call. - std::vector TakePendingRemovals(); - std::vector TakePendingAdditions(); - void ParseDirectory(const std::string& directory); - - bool HasManifestCodeFunc(); - bool CallManifestFetchCode(uint64_t gid, uint64_t* outCode); - - bool HasManifestCodeFuncEx(); - bool CallManifestFetchCodeEx(uint64_t app_id, uint64_t depot_id, uint64_t gid, uint64_t* outCode); -} - -#endif // LUACONFIG_H +#ifndef LUACONFIG_H +#define LUACONFIG_H + +#include "Steam/Types.h" + +#include +#include +#include +#include + +namespace LuaConfig{ + bool HasDepot(AppId_t appId, bool checkOwned=true); + bool IsOwned(AppId_t appId); + void MarkOwned(AppId_t appId); + std::vector GetAllDepotIds(); + std::vector GetDecryptionKey(AppId_t appId); + uint64_t GetAccessToken(AppId_t appId); + uint64_t GetStatSteamId(AppId_t appId); + bool pinApp(AppId_t appId); + uint32_t GetPurchaseTime(AppId_t appId); + + struct ManifestOverride { + uint64_t gid; + uint64_t size; + }; + const std::unordered_map& GetManifestOverrides(); + + void ParseFile(const std::string& filePath); + void UnloadFile(const std::string& filePath); + // Returns and clears the list of depot IDs removed/added since last call. + std::vector TakePendingRemovals(); + std::vector TakePendingAdditions(); + void ParseDirectory(const std::string& directory); + void ReloadDirectories(const std::vector& directories, bool clearPendingAdditions = false); + + bool HasManifestCodeFunc(); + bool CallManifestFetchCode(uint64_t gid, uint64_t* outCode); + + bool HasManifestCodeFuncEx(); + bool CallManifestFetchCodeEx(uint64_t app_id, uint64_t depot_id, uint64_t gid, uint64_t* outCode); +} + +#endif // LUACONFIG_H diff --git a/src/Utils/Config/LuaFileWatcher.cpp b/src/Utils/Config/LuaFileWatcher.cpp new file mode 100644 index 0000000..f5bbb07 --- /dev/null +++ b/src/Utils/Config/LuaFileWatcher.cpp @@ -0,0 +1,219 @@ +#include "Hook/Hooks_Package.h" +#include "Utils/Config/LuaFileWatcher.h" +#include "Utils/Config/LuaConfig.h" +#include "Utils/Logging/Log.h" +#include "OSTPlatform/include/DirectoryWatch.h" + +#include +#include +#include +#include +#include + +namespace LuaFileWatcher { +namespace { + +enum class ChangeAction { + Added, + Modified, + Removed, +}; + +struct FileChange { + std::string path; + ChangeAction action = ChangeAction::Modified; +}; + +std::atomic g_running{false}; +std::thread g_watcherThread; +std::vector g_watchDirs; + +constexpr uint32_t kDebounceMs = 500; + +bool IsLuaFile(const std::string& path) { + if (path.size() < 4) return false; + return std::equal(path.end() - 4, path.end(), ".lua", [](char lhs, char rhs) { + return std::tolower(static_cast(lhs)) == std::tolower(static_cast(rhs)); + }); +} + +ChangeAction FromPlatformAction(OSTPlatform::DirectoryWatch::ChangeAction action) { + switch (action) { + case OSTPlatform::DirectoryWatch::ChangeAction::Added: + case OSTPlatform::DirectoryWatch::ChangeAction::RenamedNewName: + return ChangeAction::Added; + case OSTPlatform::DirectoryWatch::ChangeAction::Removed: + case OSTPlatform::DirectoryWatch::ChangeAction::RenamedOldName: + return ChangeAction::Removed; + case OSTPlatform::DirectoryWatch::ChangeAction::Modified: + return ChangeAction::Modified; + } + return ChangeAction::Modified; +} + +const char* ToString(ChangeAction action) { + switch (action) { + case ChangeAction::Added: return "added"; + case ChangeAction::Modified: return "modified"; + case ChangeAction::Removed: return "removed"; + } + return "modified"; +} + +void MergeChanges( + std::unordered_map& accumulated, + std::vector& order, + const std::vector& newChanges) { + for (const auto& ch : newChanges) { + if (!accumulated.contains(ch.path)) { + order.push_back(ch.path); + } + accumulated[ch.path] = ch.action; + } +} + +std::vector FlattenChanges( + const std::unordered_map& accumulated, + const std::vector& order) { + std::vector changes; + changes.reserve(order.size()); + for (const auto& path : order) { + changes.push_back({path, accumulated.at(path)}); + } + return changes; +} + +std::vector ToFileChanges( + const std::string& dir, + const std::vector& changes) { + std::vector result; + result.reserve(changes.size()); + for (const auto& change : changes) { + if (change.relativePath.empty()) continue; + result.push_back({dir + "\\" + change.relativePath, FromPlatformAction(change.action)}); + } + return result; +} + +void ProcessChanges(const std::vector& changes) { + std::vector luaChanges; + for (const auto& change : changes) { + if (IsLuaFile(change.path)) { + luaChanges.push_back(change); + } + } + if (luaChanges.empty()) return; + + LOG_PACKAGE_DEBUG("Processing {} Lua file change(s)", luaChanges.size()); + for (const auto& change : luaChanges) { + LOG_PACKAGE_TRACE("Lua file {}: {}", ToString(change.action), change.path); + if (change.action == ChangeAction::Removed) { + LuaConfig::UnloadFile(change.path); + } else { + LuaConfig::ParseFile(change.path); + } + } + + Hooks_Package::NotifyLicenseChanged(); + LOG_PACKAGE_DEBUG("Lua refresh completed"); +} + +void WatcherThread() { + const size_t numDirs = g_watchDirs.size(); + std::vector watches(numDirs); + std::vector watchPtrs(numDirs, nullptr); + + for (size_t i = 0; i < numDirs; ++i) { + if (!watches[i].Open(g_watchDirs[i], 65536)) { + LOG_PACKAGE_WARN("Failed to open Lua watch directory: {}", g_watchDirs[i]); + continue; + } + if (!watches[i].IssueRead()) { + watches[i].Cancel(); + continue; + } + + watchPtrs[i] = &watches[i]; + LOG_PACKAGE_DEBUG("Watching Lua directory: {}", g_watchDirs[i]); + } + + bool allFailed = true; + for (auto* watch : watchPtrs) { + if (watch && watch->IsOpen()) { + allFailed = false; + break; + } + } + if (allFailed) { + LOG_PACKAGE_WARN("No Lua watch directories could be opened"); + return; + } + + auto drainEvent = [&](size_t idx, + std::unordered_map& accumulated, + std::vector& order) { + auto* watch = idx < watchPtrs.size() ? watchPtrs[idx] : nullptr; + if (!watch || !watch->IsOpen()) return; + + MergeChanges(accumulated, order, ToFileChanges(g_watchDirs[idx], watch->Drain())); + watch->IssueRead(); + }; + + while (g_running) { + auto waitResult = OSTPlatform::DirectoryWatch::WaitAny(watchPtrs, 1000); + + if (!g_running) break; + if (waitResult.status == OSTPlatform::DirectoryWatch::WaitStatus::Timeout) continue; + if (waitResult.status != OSTPlatform::DirectoryWatch::WaitStatus::Signaled || + waitResult.index >= numDirs) { + continue; + } + + std::unordered_map accumulated; + std::vector order; + + drainEvent(waitResult.index, accumulated, order); + + while (g_running) { + auto debounceResult = OSTPlatform::DirectoryWatch::WaitAny(watchPtrs, kDebounceMs); + if (!g_running) break; + if (debounceResult.status == OSTPlatform::DirectoryWatch::WaitStatus::Timeout) break; + if (debounceResult.status != OSTPlatform::DirectoryWatch::WaitStatus::Signaled || + debounceResult.index >= numDirs) { + break; + } + drainEvent(debounceResult.index, accumulated, order); + } + + if (!order.empty()) { + ProcessChanges(FlattenChanges(accumulated, order)); + } + } + + for (auto& watch : watches) { + watch.Cancel(); + } + LOG_PACKAGE_DEBUG("Lua watcher stopped"); +} + +} // namespace + +void Start(const std::vector& directories) { + if (g_running.exchange(true)) { + LOG_PACKAGE_WARN("Lua watcher already running"); + return; + } + + g_watchDirs = directories; + g_watcherThread = std::thread(WatcherThread); +} + +void Stop() { + if (!g_running) return; + g_running = false; + if (g_watcherThread.joinable()) { + g_watcherThread.join(); + } +} + +} // namespace LuaFileWatcher diff --git a/src/Utils/FileWatcher.h b/src/Utils/Config/LuaFileWatcher.h similarity index 70% rename from src/Utils/FileWatcher.h rename to src/Utils/Config/LuaFileWatcher.h index e4ee75a..321d66c 100644 --- a/src/Utils/FileWatcher.h +++ b/src/Utils/Config/LuaFileWatcher.h @@ -1,10 +1,9 @@ -#pragma once +#pragma once + +#include +#include -#include -#include -#include - -namespace FileWatcher { - void Start(const std::vector& directories); - void Stop(); -} \ No newline at end of file +namespace LuaFileWatcher { + void Start(const std::vector& directories); + void Stop(); +} diff --git a/src/Utils/FileWatcher.cpp b/src/Utils/FileWatcher.cpp deleted file mode 100644 index eeb1e63..0000000 --- a/src/Utils/FileWatcher.cpp +++ /dev/null @@ -1,234 +0,0 @@ -#include "dllmain.h" -#include "FileWatcher.h" -#include "LuaConfig.h" -#include "Hook/Hooks_Package.h" -#include "Log.h" -#include -#include -#include -#include -#include - -namespace FileWatcher { - static std::atomic g_running{false}; - static std::thread g_watcherThread; - static std::vector g_watchDirs; - - static const DWORD kDebounceMs = 500; - - struct FileChange { - std::string path; - DWORD action; // FILE_ACTION_ADDED / MODIFIED / REMOVED - }; - - // Merge new changes into accumulated map, keeping last action per file. - static void MergeChanges( - std::unordered_map& accumulated, - std::vector& order, - const std::vector& newChanges) - { - for (const auto& ch : newChanges) { - if (accumulated.find(ch.path) == accumulated.end()) - order.push_back(ch.path); - accumulated[ch.path] = ch.action; - } - } - - static void ProcessChanges( - const std::unordered_map& accumulated, - const std::vector& order) - { - LOG_PACKAGE_INFO("Processing {} Lua file change(s)", order.size()); - for (const auto& path : order) { - DWORD action = accumulated.at(path); - if (action == FILE_ACTION_REMOVED) { - LuaConfig::UnloadFile(path); - } else { - // ADDED or MODIFIED — ParseFile internally calls UnloadFile first. - LuaConfig::ParseFile(path); - } - } - - Hooks_Package::NotifyLicenseChanged(); - LOG_PACKAGE_INFO("Refresh completed"); - } - - // Collects .lua file changes from the notification buffer. - // Deduplicates by filename — keeps only the last action per file. - static std::vector CollectLuaChanges( - const char* buffer, DWORD bytesReturned, const std::string& dir) - { - // Use a map to deduplicate: filename -> last action - std::unordered_map seen; - std::vector order; // preserve first-seen order - - const FILE_NOTIFY_INFORMATION* info = - reinterpret_cast(buffer); - while (info) { - if (info->Action == FILE_ACTION_ADDED - || info->Action == FILE_ACTION_MODIFIED - || info->Action == FILE_ACTION_REMOVED) { - std::wstring_view fname(info->FileName, info->FileNameLength / sizeof(wchar_t)); - if (fname.size() >= 4 && fname.substr(fname.size() - 4) == L".lua") { - std::string name(fname.begin(), fname.end()); - if (seen.find(name) == seen.end()) - order.push_back(name); - seen[name] = info->Action; // last action wins - } - } - if (info->NextEntryOffset == 0) break; - info = reinterpret_cast( - reinterpret_cast(info) + info->NextEntryOffset); - } - - std::vector result; - for (const auto& name : order) { - DWORD action = seen[name]; - LOG_PACKAGE_INFO("Lua file {}: {}", - action == FILE_ACTION_ADDED ? "added" : - action == FILE_ACTION_MODIFIED ? "modified" : "removed", name); - result.push_back({dir + "\\" + name, action}); - } - return result; - } - - static bool IssueRead(HANDLE dir, char* buf, DWORD bufSize, OVERLAPPED* ov) { - DWORD dummy = 0; - if (!ReadDirectoryChangesW(dir, buf, bufSize, FALSE, - FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE, - &dummy, ov, nullptr)) { - if (GetLastError() != ERROR_IO_PENDING) { - LOG_PACKAGE_WARN("ReadDirectoryChangesW failed: {}", GetLastError()); - return false; - } - } - return true; - } - - static void WatcherThread() { - const size_t numDirs = g_watchDirs.size(); - if (numDirs == 0) { - LOG_PACKAGE_WARN("No directories configured for watcher"); - return; - } - - std::vector dirHandles(numDirs, nullptr); - std::vector overlapped(numDirs); - std::vector events(numDirs, nullptr); - std::vector> buffers(numDirs); - - for (size_t i = 0; i < numDirs; ++i) { - events[i] = CreateEventA(nullptr, FALSE, FALSE, nullptr); - overlapped[i].hEvent = events[i]; - buffers[i].resize(65536); - - dirHandles[i] = CreateFileA( - g_watchDirs[i].c_str(), - FILE_LIST_DIRECTORY, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - nullptr, OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED, - nullptr); - - if (dirHandles[i] == INVALID_HANDLE_VALUE) { - LOG_PACKAGE_WARN("Failed to open: {} (err={})", g_watchDirs[i], GetLastError()); - dirHandles[i] = nullptr; - continue; - } - - if (!IssueRead(dirHandles[i], buffers[i].data(), - static_cast(buffers[i].size()), &overlapped[i])) { - CloseHandle(dirHandles[i]); - dirHandles[i] = nullptr; - continue; - } - - LOG_PACKAGE_INFO("Watching: {}", g_watchDirs[i]); - } - - bool allFailed = true; - for (auto& h : dirHandles) if (h) { allFailed = false; break; } - if (allFailed) { - LOG_PACKAGE_WARN("No directories could be opened"); - for (auto& e : events) if (e) CloseHandle(e); - return; - } - - while (g_running) { - DWORD waitResult = WaitForMultipleObjects( - static_cast(numDirs), events.data(), FALSE, 1000); - - if (!g_running) break; - if (waitResult == WAIT_TIMEOUT) continue; - if (waitResult < WAIT_OBJECT_0 || waitResult >= WAIT_OBJECT_0 + numDirs) continue; - - // First event arrived — collect it and start debounce window. - // Keep draining events until no new events arrive within kDebounceMs. - std::unordered_map accumulated; - std::vector order; - - auto drainEvent = [&](size_t idx) { - HANDLE dir = dirHandles[idx]; - if (!dir) return; - DWORD bytesReturned = 0; - if (GetOverlappedResult(dir, &overlapped[idx], &bytesReturned, FALSE) - && bytesReturned > 0) { - auto changes = CollectLuaChanges( - buffers[idx].data(), bytesReturned, g_watchDirs[idx]); - MergeChanges(accumulated, order, changes); - } - // Re-arm immediately so we catch events during debounce - IssueRead(dir, buffers[idx].data(), - static_cast(buffers[idx].size()), &overlapped[idx]); - }; - - drainEvent(waitResult - WAIT_OBJECT_0); - - // Debounce loop: keep waiting for more events - while (g_running) { - DWORD debounceResult = WaitForMultipleObjects( - static_cast(numDirs), events.data(), FALSE, kDebounceMs); - if (!g_running) break; - if (debounceResult == WAIT_TIMEOUT) break; // quiet period — done accumulating - if (debounceResult < WAIT_OBJECT_0 || debounceResult >= WAIT_OBJECT_0 + numDirs) break; - drainEvent(debounceResult - WAIT_OBJECT_0); - } - - if (!order.empty()) - ProcessChanges(accumulated, order); - } - - for (auto& h : dirHandles) if (h) CloseHandle(h); - for (auto& e : events) if (e) CloseHandle(e); - LOG_PACKAGE_INFO("Stopped"); - } - - void Start(const std::vector& directories) { - if (directories.empty()) { - LOG_PACKAGE_WARN("No directories configured for watcher start"); - return; - } - - std::vector effectiveDirs = directories; - if (effectiveDirs.size() > MAXIMUM_WAIT_OBJECTS) { - LOG_PACKAGE_WARN("Too many watch directories: {} (max supported by WaitForMultipleObjects: {})", - effectiveDirs.size(), MAXIMUM_WAIT_OBJECTS); - effectiveDirs.resize(MAXIMUM_WAIT_OBJECTS); - } - - if (g_running.exchange(true)) { - LOG_PACKAGE_WARN("Already running"); - return; - } - g_watchDirs = effectiveDirs; - g_watcherThread = std::thread(WatcherThread); - } - - void Stop() { - if (!g_running) return; - g_running = false; - if (g_watcherThread.joinable()) { - g_watcherThread.join(); - } - } -} diff --git a/src/Utils/Hash.h b/src/Utils/Hash.h deleted file mode 100644 index dac762a..0000000 --- a/src/Utils/Hash.h +++ /dev/null @@ -1,71 +0,0 @@ -#pragma once -#include -#include -#include -#include -#include - -// ---- compile-time FNV-1a hash (32-bit) ---- -constexpr uint32_t Fnv1aHash(const char* str) -{ - uint32_t hash = 0x811c9dc5; - while (*str) { - hash ^= static_cast(*str++); - hash *= 0x01000193; - } - return hash; -} - -// ---- SHA-256 of an entire file (BCrypt) ---- -// Returns lowercase hex string (64 chars), or empty string on any failure. -inline std::string Sha256OfFile(const std::string& path) -{ - HANDLE hFile = CreateFileA(path.c_str(), GENERIC_READ, FILE_SHARE_READ, - nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); - if (hFile == INVALID_HANDLE_VALUE) return {}; - - BCRYPT_ALG_HANDLE hAlg = nullptr; - if (!BCRYPT_SUCCESS(BCryptOpenAlgorithmProvider( - &hAlg, BCRYPT_SHA256_ALGORITHM, nullptr, 0))) { - CloseHandle(hFile); - return {}; - } - - DWORD cbData = 0; - - DWORD hashObjSize = 0; - BCryptGetProperty(hAlg, BCRYPT_OBJECT_LENGTH, - reinterpret_cast(&hashObjSize), - sizeof(DWORD), &cbData, 0); - std::vector hashObj(hashObjSize); - - DWORD hashSize = 0; - BCryptGetProperty(hAlg, BCRYPT_HASH_LENGTH, - reinterpret_cast(&hashSize), - sizeof(DWORD), &cbData, 0); - std::vector hashBuf(hashSize); - - BCRYPT_HASH_HANDLE hHash = nullptr; - BCryptCreateHash(hAlg, &hHash, hashObj.data(), hashObjSize, nullptr, 0, 0); - - // Stream in 64 KB chunks - constexpr DWORD kChunk = 65536; - std::vector buf(kChunk); - DWORD bytesRead = 0; - while (ReadFile(hFile, buf.data(), kChunk, &bytesRead, nullptr) && bytesRead > 0) - BCryptHashData(hHash, buf.data(), bytesRead, 0); - - BCryptFinishHash(hHash, hashBuf.data(), hashSize, 0); - BCryptDestroyHash(hHash); - BCryptCloseAlgorithmProvider(hAlg, 0); - CloseHandle(hFile); - - static constexpr char kHex[] = "0123456789abcdef"; - std::string result; - result.reserve(hashSize * 2); - for (uint8_t b : hashBuf) { - result += kHex[b >> 4]; - result += kHex[b & 0xF]; - } - return result; -} diff --git a/src/Utils/VehCommon.cpp b/src/Utils/HookSupport/VehCommon.cpp similarity index 55% rename from src/Utils/VehCommon.cpp rename to src/Utils/HookSupport/VehCommon.cpp index ff16847..103b94e 100644 --- a/src/Utils/VehCommon.cpp +++ b/src/Utils/HookSupport/VehCommon.cpp @@ -1,38 +1,31 @@ #include "VehCommon.h" +#include "OSTPlatform/include/Memory.h" + namespace { std::vector g_sites; - PVOID g_vehHandle = nullptr; + OSTPlatform::Trap::HandlerHandle g_vehHandle = nullptr; } namespace VehCommon { -static LONG CALLBACK VehHandler(PEXCEPTION_POINTERS pExInfo) { - auto code = pExInfo->ExceptionRecord->ExceptionCode; - PCONTEXT ctx = pExInfo->ContextRecord; - - if (code == EXCEPTION_BREAKPOINT && OnBreakpoint(ctx)) - return EXCEPTION_CONTINUE_EXECUTION; - if (code == EXCEPTION_SINGLE_STEP && OnSingleStep(ctx)) - return EXCEPTION_CONTINUE_EXECUTION; - return EXCEPTION_CONTINUE_SEARCH; +static bool VehHandler(OSTPlatform::Trap::ExceptionKind kind, OSTPlatform::Trap::Context& ctx) { + if (kind == OSTPlatform::Trap::ExceptionKind::Breakpoint && OnBreakpoint(ctx)) return true; + if (kind == OSTPlatform::Trap::ExceptionKind::SingleStep && OnSingleStep(ctx)) return true; + return false; } static void EnsureHandlerInstalled() { if (!g_vehHandle) - g_vehHandle = AddVectoredExceptionHandler(1, VehHandler); + g_vehHandle = OSTPlatform::Trap::AddVectoredHandler(VehHandler); } static void ArmInt3(void* target) { - DWORD oldProtect = 0; - VirtualProtect(target, 1, PAGE_EXECUTE_READWRITE, &oldProtect); - *static_cast(target) = 0xCC; + OSTPlatform::Memory::WriteExecutableByte(target, 0xCC); } static void RestoreByte(void* target, uint8_t original) { - DWORD oldProtect = 0; - VirtualProtect(target, 1, PAGE_EXECUTE_READWRITE, &oldProtect); - *static_cast(target) = original; + OSTPlatform::Memory::WriteExecutableByte(target, original); } void Arm(Int3Site site) { @@ -45,20 +38,20 @@ bool HasSites() { return !g_sites.empty(); } -bool OnBreakpoint(PCONTEXT ctx) { +bool OnBreakpoint(OSTPlatform::Trap::Context& ctx) { for (auto& site : g_sites) { - if (!site.target || !IsAt(ctx->Rip, site.target)) continue; + if (!site.target || !IsAt(ctx.InstructionPointer(), site.target)) continue; // Restore the original byte so the CPU can execute the real // first instruction when we resume. - *site.target = site.originalByte; + RestoreByte(site.target, site.originalByte); if (site.onHit) site.onHit(ctx, site); if (site.persistent) { // Set TF: CPU executes one instruction then raises SINGLE_STEP, // where we re-arm the int3. - ctx->EFlags |= 0x100; + ctx.EnableSingleStep(); } // For one-shot sites, leaving the byte restored permanently is the // desired behavior -- no further action needed. @@ -67,11 +60,11 @@ bool OnBreakpoint(PCONTEXT ctx) { return false; } -bool OnSingleStep(PCONTEXT ctx) { +bool OnSingleStep(OSTPlatform::Trap::Context& ctx) { for (auto& site : g_sites) { if (!site.persistent || !site.target) continue; - if (!IsPostInt3Step(ctx->Rip, site.target)) continue; - *site.target = 0xCC; // re-arm + if (!IsPostInt3Step(ctx.InstructionPointer(), site.target)) continue; + ArmInt3(site.target); return true; } return false; @@ -88,7 +81,7 @@ void DisarmAll() { void RemoveHandler() { if (g_vehHandle) { - RemoveVectoredExceptionHandler(g_vehHandle); + OSTPlatform::Trap::RemoveVectoredHandler(g_vehHandle); g_vehHandle = nullptr; } } diff --git a/src/Utils/VehCommon.h b/src/Utils/HookSupport/VehCommon.h similarity index 91% rename from src/Utils/VehCommon.h rename to src/Utils/HookSupport/VehCommon.h index b9c5984..d19a7f8 100644 --- a/src/Utils/VehCommon.h +++ b/src/Utils/HookSupport/VehCommon.h @@ -1,8 +1,9 @@ #pragma once -#include "Log.h" +#include "OSTPlatform/include/Trap.h" +#include "Utils/Logging/Log.h" +#include "Utils/SteamMetadata/PatternLoader.h" -#include #include #include #include @@ -33,18 +34,10 @@ namespace VehCommon { // Only valid before RSP moves -- i.e. on an int3 hit at the first // instruction of the function. template - inline T GetArg(PCONTEXT ctx, int index) { + inline T GetArg(OSTPlatform::Trap::Context& ctx, int index) { static_assert(sizeof(T) <= sizeof(uint64_t), "GetArg: T must fit in a 64-bit register slot"); - uint64_t raw; - switch (index) { - case 1: raw = ctx->Rcx; break; - case 2: raw = ctx->Rdx; break; - case 3: raw = ctx->R8; break; - case 4: raw = ctx->R9; break; - default: raw = *reinterpret_cast(ctx->Rsp + 8 * index); - break; - } + uint64_t raw = ctx.Argument(index); if constexpr (std::is_pointer_v) { return reinterpret_cast(raw); } else { @@ -70,7 +63,7 @@ namespace VehCommon { uint8_t* target; // first byte of the hooked function uint8_t originalByte; // saved before arming bool persistent; - void (*onHit)(PCONTEXT ctx, const Int3Site& site); + void (*onHit)(OSTPlatform::Trap::Context& ctx, const Int3Site& site); void* callbackData; // site-specific data consumed by onHit const char* label; // logging tag }; @@ -87,8 +80,8 @@ namespace VehCommon { // handled (caller should return EXCEPTION_CONTINUE_EXECUTION) // - returns false if nothing matched (caller should fall through to // EXCEPTION_CONTINUE_SEARCH) - bool OnBreakpoint(PCONTEXT ctx); - bool OnSingleStep(PCONTEXT ctx); + bool OnBreakpoint(OSTPlatform::Trap::Context& ctx); + bool OnSingleStep(OSTPlatform::Trap::Context& ctx); // Restore any still-armed bytes and clear the registry. Call during global // hook shutdown before removing the VEH handler. @@ -100,7 +93,7 @@ namespace VehCommon { // Convenience onHit for the "capture this" pattern. // callbackData must point to a `void*` slot that will receive RCX (the // implicit `this` argument in the Windows x64 fastcall convention). - inline void CaptureRcxTo(PCONTEXT ctx, const Int3Site& site) { + inline void CaptureRcxTo(OSTPlatform::Trap::Context& ctx, const Int3Site& site) { *static_cast(site.callbackData) = GetArg(ctx, 1); LOG_MISC_DEBUG("CaptureRcxTo {}: captured {}", site.label, GetArg(ctx, 1)); } @@ -131,7 +124,7 @@ namespace VehCommon { // ARM_CAPTURE_C below. #define ARM_INT3(module, name, persistent_, onHit_, callbackData_) \ do { \ - if (auto* _p_ = FIND_SIG(module, name)) { \ + if (auto* _p_ = PatternLoader::FindPattern(module, #name)) { \ auto* _t_ = static_cast(_p_); \ VehCommon::Arm(VehCommon::Int3Site{ \ _t_, *_t_, persistent_, onHit_, callbackData_, #name, \ @@ -162,7 +155,7 @@ namespace VehCommon { // address into `o##name` so callers can invoke the original after capture. #define ARM_CAPTURE(module,name) \ do { \ - if (auto* _p_ = FIND_SIG(module, name)) { \ + if (auto* _p_ = PatternLoader::FindPattern(module, #name)) { \ o##name = reinterpret_cast(_p_); \ auto* _t_ = static_cast(_p_); \ VehCommon::Arm(VehCommon::Int3Site{ \ diff --git a/src/Utils/Log.cpp b/src/Utils/Log.cpp deleted file mode 100644 index ca3722c..0000000 --- a/src/Utils/Log.cpp +++ /dev/null @@ -1,86 +0,0 @@ -#include "Log.h" - -#ifdef OPENSTEAMTOOL_LOGGING_ENABLED - -#include "Config.h" -#include -#include -#include - -namespace { - std::atomic_bool g_mainReady{false}; - - std::filesystem::path ResolveDllDir(HMODULE selfModule) { - wchar_t buf[MAX_PATH] = {}; - DWORD len = GetModuleFileNameW(selfModule, buf, MAX_PATH); - if (len == 0 || len == MAX_PATH) return L"."; - return std::filesystem::path(buf).parent_path(); - } - - spdlog::level::level_enum ToSpdlog(Config::LogLevel lv) { - switch (lv) { - case Config::LogLevel::Trace: return spdlog::level::trace; - case Config::LogLevel::Debug: return spdlog::level::debug; - case Config::LogLevel::Info: return spdlog::level::info; - case Config::LogLevel::Warn: return spdlog::level::warn; - case Config::LogLevel::Error: return spdlog::level::err; - default: return spdlog::level::info; - } - } - - std::shared_ptr MakeLogger(const std::string& dir, - const std::string& name) { - auto path = std::filesystem::path(dir) / (name + ".log"); - auto logger = spdlog::basic_logger_mt(name, path.string(), /*truncate=*/true); - logger->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%^%l%$] [tid=%t] [%s:%# %!()] %v"); - logger->flush_on(spdlog::level::trace); - return logger; - } -} - -namespace Log { - - void Init(HMODULE selfModule) { - bool expected = false; - if (!g_mainReady.compare_exchange_strong(expected, true)) return; - - try { - auto dir = ResolveDllDir(selfModule); - auto logDir = (dir / "opensteamtool").string(); - std::filesystem::create_directories(logDir); - Main = MakeLogger(logDir, "main"); - Main->set_level(spdlog::level::trace); // early boot: log everything - LOG_INFO("Log initialised at {}", logDir); - } catch (const std::exception&) { - g_mainReady.store(false); - } - } - - void InitModules() { - if (!g_mainReady) return; - - try { - std::filesystem::create_directories(Config::logDir); - auto lvl = ToSpdlog(Config::logLevel); - - Main->set_level(lvl); - - auto initOne = [&](std::shared_ptr& logger, const char* name) { - logger = MakeLogger(Config::logDir, name); - logger->set_level(lvl); - }; - - #define OST_MOD(v, n) initOne(v, n); - #include "ost_log_modules.h" - #undef OST_MOD - - LOG_INFO("Module loggers initialised at {} level={}", Config::logDir, - static_cast(Config::logLevel)); - } catch (const std::exception& e) { - LOG_WARN("InitModules failed: {}", e.what()); - } - } - -} - -#endif // OPENSTEAMTOOL_LOGGING_ENABLED diff --git a/src/Utils/Logging/Log.cpp b/src/Utils/Logging/Log.cpp new file mode 100644 index 0000000..786e165 --- /dev/null +++ b/src/Utils/Logging/Log.cpp @@ -0,0 +1,135 @@ +#include "Log.h" + +#ifdef OPENSTEAMTOOL_LOGGING_ENABLED + +#include "OSTPlatform/include/DynamicLibrary.h" +#include "OSTPlatform/include/Log.h" +#include "Utils/Config/Config.h" +#include +#include +#include +#include + +namespace { + std::atomic_bool g_mainReady{false}; + + spdlog::level::level_enum ToSpdlog(Config::LogLevel lv) { + switch (lv) { + case Config::LogLevel::Trace: return spdlog::level::trace; + case Config::LogLevel::Debug: return spdlog::level::debug; + case Config::LogLevel::Info: return spdlog::level::info; + case Config::LogLevel::Warn: return spdlog::level::warn; + case Config::LogLevel::Error: return spdlog::level::err; + default: return spdlog::level::info; + } + } + + std::shared_ptr MakeLogger(const std::string& dir, + const std::string& name) { + auto path = std::filesystem::path(dir) / (name + ".log"); + auto logger = spdlog::basic_logger_mt(name, path.string(), /*truncate=*/true); + logger->set_pattern("[%Y-%m-%d %H:%M:%S.%e] [%^%l%$] [tid=%t] [%s:%# %!()] %v"); + logger->flush_on(spdlog::level::trace); + return logger; + } + + spdlog::level::level_enum ToSpdlog(OSTPlatform::Log::Level lv) { + switch (lv) { + case OSTPlatform::Log::Level::Trace: return spdlog::level::trace; + case OSTPlatform::Log::Level::Debug: return spdlog::level::debug; + case OSTPlatform::Log::Level::Info: return spdlog::level::info; + case OSTPlatform::Log::Level::Warn: return spdlog::level::warn; + case OSTPlatform::Log::Level::Error: return spdlog::level::err; + } + return spdlog::level::info; + } + + void SetLoggerLevel(const std::shared_ptr& logger, + spdlog::level::level_enum level) { + if (logger) logger->set_level(level); + } + + void SetAllLoggerLevels(spdlog::level::level_enum level) { + SetLoggerLevel(Log::Main, level); + #define OST_MOD(v, n) SetLoggerLevel(Log::v, level); + #include "LogModules.def" + #undef OST_MOD + } + + // Forwards every OSTPlatform log line into the host's single "platform" + // logger. The message is already formatted; the originating file/line/ + // function are reconstructed into spdlog's source_loc so the log pattern + // still shows where in the platform library the line came from. + void PlatformLogSink(OSTPlatform::Log::Level level, + const OSTPlatform::Log::Source& src, + std::string_view msg) { + if (!Log::Platform) return; + Log::Platform->log( + spdlog::source_loc{src.file ? src.file : "", src.line, + src.function ? src.function : ""}, + ToSpdlog(level), "{}", msg); + } +} + +namespace Log { + + void Init(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) { + bool expected = false; + if (!g_mainReady.compare_exchange_strong(expected, true)) return; + + try { + auto dir = OSTPlatform::DynamicLibrary::GetModuleDirectory(selfModule); + if (dir.empty()) dir = "."; + auto logDir = (dir / "opensteamtool").string(); + std::filesystem::create_directories(logDir); + Main = MakeLogger(logDir, "main"); + Main->set_level(spdlog::level::trace); // early boot: log everything + LOG_INFO("Log initialised at {}", logDir); + } catch (const std::exception&) { + g_mainReady.store(false); + } + } + + void InitModules() { + if (!g_mainReady) return; + + try { + const std::string logDir = Config::GetLogDir(); + const Config::LogLevel configLevel = Config::GetLogLevel(); + auto lvl = ToSpdlog(configLevel); + + std::filesystem::create_directories(logDir); + SetLoggerLevel(Main, lvl); + + auto initOne = [&](std::shared_ptr& logger, const char* name) { + logger = MakeLogger(logDir, name); + logger->set_level(lvl); + }; + + #define OST_MOD(v, n) initOne(v, n); + #include "LogModules.def" + #undef OST_MOD + + LOG_INFO("Module loggers initialised at {} level={}", logDir, + static_cast(configLevel)); + } catch (const std::exception& e) { + LOG_WARN("InitModules failed: {}", e.what()); + } + } + + void ApplyConfigLevel() { + if (!g_mainReady) return; + + const Config::LogLevel configLevel = Config::GetLogLevel(); + SetAllLoggerLevels(ToSpdlog(configLevel)); + LOG_INFO("Log level updated to {}", static_cast(configLevel)); + } + + void InstallPlatformLogSink() { + OSTPlatform::Log::SetSink(&PlatformLogSink); + LOG_INFO("OSTPlatform log sink installed -> platform module"); + } + +} + +#endif // OPENSTEAMTOOL_LOGGING_ENABLED diff --git a/src/Utils/Log.h b/src/Utils/Logging/Log.h similarity index 72% rename from src/Utils/Log.h rename to src/Utils/Logging/Log.h index 71a8163..bf0f58c 100644 --- a/src/Utils/Log.h +++ b/src/Utils/Logging/Log.h @@ -10,7 +10,7 @@ // Module macros → /opensteamtool/.log // // Adding a new module logger: -// 1. Add OST_MOD(NewMod, "newmod") in ost_log_modules.h. +// 1. Add OST_MOD(NewMod, "newmod") in LogModules.def. // 2. Run CMake configure (the LOG_NEWMOD_* macros are auto-generated). #ifdef OPENSTEAMTOOL_LOGGING_ENABLED @@ -22,18 +22,23 @@ #include #include #include -#include +#include "OSTPlatform/include/DynamicLibrary.h" #include namespace Log { - void Init(HMODULE selfModule); - void InitModules(); + 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; - // Module loggers — auto-generated from ost_log_modules.h - #define OST_MOD(v, f) inline std::shared_ptr v; - #include "ost_log_modules.h" + // Module loggers — auto-generated from LogModules.def + #define OST_MOD(v, f) inline std::shared_ptr v; + #include "LogModules.def" #undef OST_MOD } @@ -46,12 +51,14 @@ namespace Log { #else // OPENSTEAMTOOL_LOGGING_ENABLED -#include +#include "OSTPlatform/include/DynamicLibrary.h" -namespace Log { - inline void Init(HMODULE) {} - inline void InitModules() {} -} +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) diff --git a/src/Utils/ost_log_modules.h b/src/Utils/Logging/LogModules.def similarity index 92% rename from src/Utils/ost_log_modules.h rename to src/Utils/Logging/LogModules.def index 0533ee3..e353c8a 100644 --- a/src/Utils/ost_log_modules.h +++ b/src/Utils/Logging/LogModules.def @@ -5,7 +5,7 @@ // // Usage: // #define OST_MOD(v, f) ... // declare logger -// #include "ost_log_modules.h" +// #include "LogModules.def" // #undef OST_MOD // // Adding a new module: @@ -19,10 +19,11 @@ OST_MOD(Manifest, "manifest") OST_MOD(KeyValue, "keyvalue") OST_MOD(DecryptionKey, "decryptionkey") OST_MOD(Misc, "misc") -OST_MOD(WinHttp, "winhttp") OST_MOD(Achievement, "achievement") OST_MOD(Pics, "pics") OST_MOD(OnlineFix, "onlinefix") OST_MOD(RichPresence, "richpresence") OST_MOD(Package, "package") OST_MOD(SteamUI, "steamui") +OST_MOD(Pipe, "pipe") +OST_MOD(Platform, "platform") diff --git a/src/Utils/IPCLoader.cpp b/src/Utils/SteamMetadata/IPCLoader.cpp similarity index 93% rename from src/Utils/IPCLoader.cpp rename to src/Utils/SteamMetadata/IPCLoader.cpp index 953f376..9819f67 100644 --- a/src/Utils/IPCLoader.cpp +++ b/src/Utils/SteamMetadata/IPCLoader.cpp @@ -1,8 +1,9 @@ #include "IPCLoader.h" #include "IPCMessages.gen.h" -#include "Log.h" -#include "RemoteToml.h" -#include "SteamDiagnostics.h" +#include "OSTPlatform/include/Numbers.h" +#include "Utils/Logging/Log.h" +#include "Utils/SteamMetadata/RemoteToml.h" +#include "Utils/SteamMetadata/SteamDiagnostics.h" #include #include @@ -69,18 +70,12 @@ namespace { // ---- TOML helpers ---- - static bool ParseHexU32(std::string_view s, uint32_t& out) + static bool ParseHexU32(std::string_view s, uint32_t& out) { - try { - size_t pos = 0; - uint64_t v = std::stoull(std::string(s), &pos, 16); - if (v > UINT32_MAX) return false; - out = static_cast(v); - return true; - } catch (...) { - return false; - } - + const auto parsed = OSTPlatform::Numbers::ParseHexUInt32(s); + if (!parsed) return false; + out = *parsed; + return true; } static bool ParseInterfaceTable(std::string_view name, diff --git a/src/Utils/IPCLoader.h b/src/Utils/SteamMetadata/IPCLoader.h similarity index 100% rename from src/Utils/IPCLoader.h rename to src/Utils/SteamMetadata/IPCLoader.h diff --git a/src/Utils/ManifestClient.cpp b/src/Utils/SteamMetadata/ManifestClient.cpp similarity index 55% rename from src/Utils/ManifestClient.cpp rename to src/Utils/SteamMetadata/ManifestClient.cpp index 5df7d1b..7cef98b 100644 --- a/src/Utils/ManifestClient.cpp +++ b/src/Utils/SteamMetadata/ManifestClient.cpp @@ -1,8 +1,9 @@ #include "ManifestClient.h" -#include "Config.h" -#include "Log.h" -#include "LuaConfig.h" -#include "WinHttp.h" +#include "OSTPlatform/include/Http.h" +#include "Utils/Config/Config.h" +#include "Utils/Config/LuaConfig.h" +#include "Utils/Logging/Log.h" + #include #include #include @@ -40,41 +41,11 @@ namespace ManifestClient { struct Provider { std::string_view name; // matches [manifest] url = "..." const char* urlTemplate; // full literal with one %llu — for log & path - std::string_view host; // ASCII slice into urlTemplate - const char* pathFormat; // suffix of urlTemplate, null-terminated, e.g. "/path/%llu" - INTERNET_PORT port; - bool tls; Parser parse; }; consteval Provider Make(std::string_view name, const char* url, Parser parse) { - std::string_view sv{url}; - bool tls = false; - INTERNET_PORT port = INTERNET_DEFAULT_HTTP_PORT; - if (sv.starts_with("https://")) { - tls = true; - port = INTERNET_DEFAULT_HTTPS_PORT; - sv.remove_prefix(8); - } - else if (sv.starts_with("http://")) { - sv.remove_prefix(7); - } - - size_t slash = sv.find('/'); - std::string_view hostPart = sv.substr(0, slash); - // sv.data() points into url; the suffix at slash is null-terminated - // because url itself is a null-terminated literal. - const char* pathFormat = (slash == std::string_view::npos) ? "/" : (sv.data() + slash); - - std::string_view host = hostPart; - if (size_t colon = hostPart.find(':'); colon != std::string_view::npos) { - host = hostPart.substr(0, colon); - INTERNET_PORT custom = 0; - for (char c : hostPart.substr(colon + 1)) - custom = static_cast(custom * 10 + (c - '0')); - port = custom; - } - return {name, url, host, pathFormat, port, tls, parse}; + return {name, url, parse}; } static constexpr Provider kProviders[] = { @@ -84,8 +55,10 @@ namespace ManifestClient { }; static const Provider* g_active = &kProviders[0]; // opensteamtool + static std::mutex g_mutex; bool SetProvider(std::string_view name) { + std::lock_guard lock(g_mutex); for (const auto& p : kProviders) if (p.name == name) { g_active = &p; @@ -94,79 +67,36 @@ namespace ManifestClient { return false; } - const char* ActiveProviderName() { + const char* ActiveProviderName() { + std::lock_guard lock(g_mutex); return g_active->name.data(); } - // ── connection ──────────────────────────────────────────────── - - static std::mutex g_mutex; - static HINTERNET g_hSession = nullptr; - static HINTERNET g_hConnect = nullptr; - static const Provider* g_connected = nullptr; // provider g_hConnect is tied to - - static void CloseConnection() { - if (g_hConnect) { WinHttpCloseHandle(g_hConnect); g_hConnect = nullptr; } - if (g_hSession) { WinHttpCloseHandle(g_hSession); g_hSession = nullptr; } - g_connected = nullptr; - } - - // Widen an ASCII string_view into a wide stack buffer (null-terminated). - template - static void WidenAscii(std::string_view s, wchar_t (&dst)[N]) { - size_t n = (std::min)(s.size(), N - 1); // parens defeat the windows.h min macro - for (size_t i = 0; i < n; ++i) dst[i] = static_cast(s[i]); - dst[n] = 0; - } - - static void EnsureConnection() { - if (g_hConnect && g_connected == g_active) return; - CloseConnection(); - - g_hSession = WinHttpOpen(L"OpenSteamTool/1.0", - WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, - WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); - if (!g_hSession) return; - - WinHttpSetTimeouts(g_hSession, - Config::manifestTimeoutResolve, Config::manifestTimeoutConnect, - Config::manifestTimeoutSend, Config::manifestTimeoutRecv); - - wchar_t hostW[256]; - WidenAscii(g_active->host, hostW); - - g_hConnect = WinHttpConnect(g_hSession, hostW, g_active->port, 0); - if (!g_hConnect) { - WinHttpCloseHandle(g_hSession); - g_hSession = nullptr; - return; - } - g_connected = g_active; - } + // ── request ─────────────────────────────────────────────────── void Shutdown() { std::lock_guard lock(g_mutex); - CloseConnection(); } // ── fetch ───────────────────────────────────────────────────── static bool FetchActive(uint64_t gid, uint64_t* outCode) { - EnsureConnection(); - if (!g_hConnect) return false; const Provider& p = *g_active; - - char pathN[160]; - std::snprintf(pathN, sizeof(pathN), p.pathFormat, gid); - wchar_t pathW[160]; - WidenAscii(pathN, pathW); + const Config::ManifestTimeouts timeouts = Config::GetManifestTimeouts(); char urlLog[256]; std::snprintf(urlLog, sizeof(urlLog), p.urlTemplate, gid); - auto r = WinHttp::ExecuteEx(g_hSession, g_hConnect, p.tls, - L"GET", pathW, nullptr, 0, nullptr, urlLog); - if (!r.ok) CloseConnection(); + auto r = OSTPlatform::Http::Execute( + L"GET", + urlLog, + nullptr, + 0, + nullptr, + timeouts.resolve, + timeouts.connect, + timeouts.send, + timeouts.recv); LOG_MANIFEST_INFO("Manifest {} status={} gid={}", p.name, r.status, gid); diff --git a/src/Utils/ManifestClient.h b/src/Utils/SteamMetadata/ManifestClient.h similarity index 100% rename from src/Utils/ManifestClient.h rename to src/Utils/SteamMetadata/ManifestClient.h diff --git a/src/Utils/PatternLoader.cpp b/src/Utils/SteamMetadata/PatternLoader.cpp similarity index 87% rename from src/Utils/PatternLoader.cpp rename to src/Utils/SteamMetadata/PatternLoader.cpp index b4c544d..cba686a 100644 --- a/src/Utils/PatternLoader.cpp +++ b/src/Utils/SteamMetadata/PatternLoader.cpp @@ -1,11 +1,12 @@ #include "PatternLoader.h" -#include "Hash.h" -#include "Log.h" -#include "RemoteToml.h" -#include "SteamDiagnostics.h" +#include "OSTPlatform/include/Memory.h" +#include "OSTPlatform/include/Numbers.h" +#include "Utils/Logging/Log.h" +#include "Utils/SteamMetadata/RemoteToml.h" +#include "Utils/SteamMetadata/SteamDiagnostics.h" +#include "Utils/Support/FnvHash.h" #include -#include #include #include #include @@ -33,18 +34,18 @@ struct PatternEntry { using PatternMap = std::unordered_map; // module → its pattern map -static std::unordered_map g_moduleMaps; +static std::unordered_map g_moduleMaps; // Modules whose Load() call failed (popup already shown). FindPattern // silently returns nullptr for these — without re-logging or adding the // function to g_missingFunctions — so we don't follow one "TOML missing" // popup with a second popup listing every dependent hook. -static std::unordered_set g_failedModules; +static std::unordered_set g_failedModules; // functions whose names were not found during FindPattern static std::vector g_missingFunctions; -// ---- byte-pattern scanner (independent of old ByteSearch) ---- +// ---- byte-pattern scanner ---- static bool ParseSig(const std::string& str, std::vector& bytes, @@ -74,22 +75,21 @@ static bool ParseSig(const std::string& str, return !bytes.empty(); } -static void* ScanModule(HMODULE module, +static void* ScanModule(OSTPlatform::DynamicLibrary::ModuleHandle module, const std::vector& bytes, const std::vector& mask) { - MODULEINFO mi{}; - if (!GetModuleInformation(GetCurrentProcess(), module, &mi, sizeof(mi))) - return nullptr; + const auto image = OSTPlatform::Memory::GetModuleImage(module); + if (!image) return nullptr; - auto* base = static_cast(mi.lpBaseOfDll); - SIZE_T size = mi.SizeOfImage; - SIZE_T patLen = bytes.size(); + auto* base = image->base; + size_t size = image->size; + size_t patLen = bytes.size(); if (size < patLen) return nullptr; - for (SIZE_T i = 0; i <= size - patLen; ++i) { + for (size_t i = 0; i <= size - patLen; ++i) { bool found = true; - for (SIZE_T j = 0; j < patLen; ++j) { + for (size_t j = 0; j < patLen; ++j) { if (mask[j] && base[i + j] != bytes[j]) { found = false; break; } } if (found) return base + i; @@ -109,17 +109,16 @@ static PatternMap TableToPatternMap(const toml::table& tbl) if (!val.is_table()) continue; auto& sub = *val.as_table(); - uint32_t hashKey = 0; - try { - hashKey = static_cast( - std::stoull(std::string(rawKey), nullptr, 16)); - } catch (...) { continue; } + const auto parsedKey = OSTPlatform::Numbers::ParseHexUInt32(std::string(rawKey)); + if (!parsedKey) continue; + const uint32_t hashKey = *parsedKey; PatternEntry entry; if (auto v = sub["name"].value()) entry.name = *v; if (auto v = sub["rva"].value()) { - try { entry.rva = static_cast(std::stoull(*v, nullptr, 16)); } - catch (...) {} + if (const auto rva = OSTPlatform::Numbers::ParseHexUInt64(*v)) { + entry.rva = static_cast(*rva); + } } if (auto v = sub["sig"].value()) entry.sig = *v; @@ -172,7 +171,7 @@ namespace PatternLoader { constexpr const char* kPatternChannel = "pattern"; -bool Load(HMODULE module, const std::string& dllPath, const std::string& component) +bool Load(OSTPlatform::DynamicLibrary::ModuleHandle module, const std::string& dllPath, const std::string& component) { namespace fs = std::filesystem; @@ -204,7 +203,7 @@ bool Load(HMODULE module, const std::string& dllPath, const std::string& compone return false; } -void* FindPattern(HMODULE module, const char* funcName) +void* FindPattern(OSTPlatform::DynamicLibrary::ModuleHandle module, const char* funcName) { // If the whole module's pattern file failed to load, stay quiet — the // user already saw one popup and the main.log already has the warning. diff --git a/src/Utils/PatternLoader.h b/src/Utils/SteamMetadata/PatternLoader.h similarity index 54% rename from src/Utils/PatternLoader.h rename to src/Utils/SteamMetadata/PatternLoader.h index 13c04dc..fca2714 100644 --- a/src/Utils/PatternLoader.h +++ b/src/Utils/SteamMetadata/PatternLoader.h @@ -1,14 +1,16 @@ #pragma once -#include + +#include "OSTPlatform/include/DynamicLibrary.h" + #include namespace PatternLoader { // Load metadata before installing hooks for a module. - bool Load(HMODULE module, const std::string& dllPath, const std::string& component); + bool Load(OSTPlatform::DynamicLibrary::ModuleHandle module, const std::string& dllPath, const std::string& component); // Resolve by RVA first, then fall back to signature scanning. - void* FindPattern(HMODULE module, const char* funcName); + void* FindPattern(OSTPlatform::DynamicLibrary::ModuleHandle module, const char* funcName); // Report unresolved functions after all hooks have been installed. void ReportMissingFunctions(); diff --git a/src/Utils/RemoteToml.cpp b/src/Utils/SteamMetadata/RemoteToml.cpp similarity index 92% rename from src/Utils/RemoteToml.cpp rename to src/Utils/SteamMetadata/RemoteToml.cpp index 66cc67d..5bab6f3 100644 --- a/src/Utils/RemoteToml.cpp +++ b/src/Utils/SteamMetadata/RemoteToml.cpp @@ -1,8 +1,8 @@ #include "RemoteToml.h" -#include "Config.h" -#include "Log.h" -#include "SteamDiagnostics.h" -#include "WinHttp.h" +#include "OSTPlatform/include/Http.h" +#include "Utils/Config/Config.h" +#include "Utils/Logging/Log.h" +#include "Utils/SteamMetadata/SteamDiagnostics.h" #include #include @@ -55,16 +55,17 @@ namespace { static std::vector BuildUrlTemplates() { - if (Config::remoteUrlTemplate.empty()) + const std::string remoteUrlTemplate = Config::GetRemoteUrlTemplate(); + if (remoteUrlTemplate.empty()) return { kGithubTemplate, kJsdelivrTemplate }; - if (!IsValidTemplate(Config::remoteUrlTemplate)) { + if (!IsValidTemplate(remoteUrlTemplate)) { LOG_WARN("RemoteToml: remote.url_template must contain " "{channel}, {component}, and {sha256}; remote fetch disabled"); return {}; } - return { Config::remoteUrlTemplate }; + return { remoteUrlTemplate }; } } // namespace @@ -102,7 +103,7 @@ Result Fetch(const Request& request) // 3. Try remote (mirror chain with early-out on 404). const std::vector urlTemplates = BuildUrlTemplates(); - WinHttp::Result http; + OSTPlatform::Http::Result http; std::string lastUrl; for (size_t i = 0; i < urlTemplates.size(); ++i) { @@ -110,8 +111,8 @@ Result Fetch(const Request& request) LOG_INFO("RemoteToml({}/{}): downloading {}", request.channel, request.component, lastUrl); - http = WinHttp::Execute(L"GET", lastUrl.c_str(), - nullptr, 0, nullptr); + http = OSTPlatform::Http::Execute(L"GET", lastUrl.c_str(), + nullptr, 0, nullptr); if (http.ok && http.status == 200) break; diff --git a/src/Utils/RemoteToml.h b/src/Utils/SteamMetadata/RemoteToml.h similarity index 100% rename from src/Utils/RemoteToml.h rename to src/Utils/SteamMetadata/RemoteToml.h diff --git a/src/Utils/SteamDiagnostics.cpp b/src/Utils/SteamMetadata/SteamDiagnostics.cpp similarity index 76% rename from src/Utils/SteamDiagnostics.cpp rename to src/Utils/SteamMetadata/SteamDiagnostics.cpp index 15cc1b7..b7d31b7 100644 --- a/src/Utils/SteamDiagnostics.cpp +++ b/src/Utils/SteamMetadata/SteamDiagnostics.cpp @@ -1,17 +1,20 @@ #include "SteamDiagnostics.h" -#include "Hash.h" -#include "Log.h" +#include "OpenSteamToolBuildInfo.h" +#include "OSTPlatform/include/Dialog.h" +#include "OSTPlatform/include/DynamicLibrary.h" +#include "OSTPlatform/include/Hash.h" +#include "Utils/Logging/Log.h" #include #include #include -#include namespace SteamDiagnostics { namespace { struct Snapshot { + std::string openSteamToolVersion = OPENSTEAMTOOL_VERSION; std::string buildID = "(unavailable)"; std::string steamclientPath; std::string steamclientSha256 = "(unavailable)"; @@ -25,7 +28,7 @@ namespace { { using GetBootstrapperVersion_t = int64_t (*)(); - const HMODULE steam = GetModuleHandleA("steam.exe"); + const auto steam = OSTPlatform::DynamicLibrary::GetLoaded("steam.exe"); if (!steam) { LOG_WARN("SteamDiagnostics: steam.exe module not loaded; build id unavailable"); return "(unavailable)"; @@ -33,7 +36,7 @@ namespace { const auto getBootstrapperVersion = reinterpret_cast( - GetProcAddress(steam, "GetBootstrapperVersion")); + OSTPlatform::DynamicLibrary::GetSymbol(steam, "GetBootstrapperVersion")); if (!getBootstrapperVersion) { LOG_WARN("SteamDiagnostics: steam.exe!GetBootstrapperVersion not exported"); return "(unavailable)"; @@ -44,7 +47,7 @@ namespace { static std::string HashOrUnavailable(const std::string& path) { - std::string sha256 = Sha256OfFile(path); + std::string sha256 = OSTPlatform::Hash::Sha256OfFile(path); return sha256.empty() ? "(unavailable)" : std::move(sha256); } @@ -52,6 +55,7 @@ namespace { { message += "\n\nSteam diagnostics:\n" + " OpenSteamTool version: " + g_snapshot.openSteamToolVersion + "\n" " Build ID: " + g_snapshot.buildID + "\n" " steamclient64.dll SHA: " + g_snapshot.steamclientSha256 + "\n" " steamui.dll SHA: " + g_snapshot.steamUISha256; @@ -69,7 +73,8 @@ void Initialize(const std::string& steamclientPath, g_snapshot.steamUIPath = steamUIPath; g_snapshot.steamUISha256 = HashOrUnavailable(steamUIPath); - LOG_INFO("SteamDiagnostics: build={} steamclient64.sha256={} steamui.sha256={}", + LOG_INFO("SteamDiagnostics: ost.version={} build={} steamclient64.sha256={} steamui.sha256={}", + g_snapshot.openSteamToolVersion, g_snapshot.buildID, g_snapshot.steamclientSha256, g_snapshot.steamUISha256); @@ -87,15 +92,14 @@ std::string Sha256Of(const std::string& path) ? std::string{} : g_snapshot.steamUISha256; - return Sha256OfFile(path); + return OSTPlatform::Hash::Sha256OfFile(path); } void ShowWarning(std::string title, std::string message) { std::thread([title = std::move(title), message = AppendSnapshot(std::move(message))]() { - MessageBoxA(nullptr, message.c_str(), title.c_str(), - MB_OK | MB_ICONWARNING | MB_TOPMOST); + OSTPlatform::Dialog::ShowWarning(title, message); }).detach(); } diff --git a/src/Utils/SteamDiagnostics.h b/src/Utils/SteamMetadata/SteamDiagnostics.h similarity index 100% rename from src/Utils/SteamDiagnostics.h rename to src/Utils/SteamMetadata/SteamDiagnostics.h diff --git a/src/Utils/Support/FnvHash.h b/src/Utils/Support/FnvHash.h new file mode 100644 index 0000000..d097541 --- /dev/null +++ b/src/Utils/Support/FnvHash.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +// ---- compile-time FNV-1a hash (32-bit) ---- +constexpr uint32_t Fnv1aHash(const char* str) +{ + uint32_t hash = 0x811c9dc5; + while (*str) { + hash ^= static_cast(*str++); + hash *= 0x01000193; + } + return hash; +} diff --git a/src/Utils/Support/Stopwatch.h b/src/Utils/Support/Stopwatch.h new file mode 100644 index 0000000..4d43b41 --- /dev/null +++ b/src/Utils/Support/Stopwatch.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace Utils { + +class Stopwatch { +public: + double ElapsedMs() const { + const auto elapsed = std::chrono::steady_clock::now() - start_; + return std::chrono::duration(elapsed).count(); + } + +private: + std::chrono::steady_clock::time_point start_ = std::chrono::steady_clock::now(); +}; + +} // namespace Utils diff --git a/src/Utils/Tickets/AppTicket.cpp b/src/Utils/Tickets/AppTicket.cpp new file mode 100644 index 0000000..69431f3 --- /dev/null +++ b/src/Utils/Tickets/AppTicket.cpp @@ -0,0 +1,172 @@ +#include "AppTicket.h" +#include "Hook/Hooks_Decryption.h" +#include "OSTPlatform/include/SteamCredentialStore.h" +#include "Utils/Config/LuaConfig.h" +#include "Utils/Logging/Log.h" + +namespace AppTicket { + constexpr AppId_t kLocalAppTicketSourceAppId = 7; + constexpr size_t kSteamIdTicketMinimumSize = 16; + + static uint64_t GetSteamIDFromCredentialStore(AppId_t appId) { + uint64_t steamId = 0; + const auto status = OSTPlatform::SteamCredentialStore::GetSteamId(appId, steamId); + if (status != OSTPlatform::SteamCredentialStore::Status::Ok) { + LOG_TRACE("GetSpoofSteamID for AppId {}: SteamID unavailable in credential store ({})", + appId, OSTPlatform::SteamCredentialStore::ToString(status)); + return 0; + } + + LOG_DEBUG("GetSpoofSteamID for AppId {}: SteamID credential -> 0x{:X}({})", appId, steamId, steamId); + return steamId; + } + + std::vector GetAppOwnershipTicketFromCredentialStore(AppId_t appId) { + // exclude those appids that are not in addappid + if (!LuaConfig::HasDepot(appId)) { + LOG_DEBUG("GetAppOwnershipTicketFromCredentialStore for AppId {}: not in addappid, skip", appId); + return {}; + } + std::vector ticket; + const auto status = OSTPlatform::SteamCredentialStore::GetAppTicket(appId, ticket); + if (status != OSTPlatform::SteamCredentialStore::Status::Ok) { + LOG_TRACE("Read App Ownership Ticket for AppId {}: cached credential unavailable ({})", + appId, OSTPlatform::SteamCredentialStore::ToString(status)); + return {}; + } + + LOG_INFO("Successfully retrieved App Ownership Ticket from credential store, AppId: {}, Ticket Size: {}", appId, ticket.size()); + return ticket; + } + + // Exploit steamdrmp's off-by-four ticket parsing vulnerability: + static std::vector ForgeLocalAppOwnershipTicket(AppId_t appId) { + std::vector source = Hooks_Decryption::GetCacheAppOwnershipTicket(kLocalAppTicketSourceAppId); + if (source.size() <= kAppTicketSignatureSize) { + LOG_DEBUG("ForgeLocalAppOwnershipTicket for AppId {}: no source appticket", appId); + return {}; + } + + const size_t signedSize = source.size() - kAppTicketSignatureSize; + std::vector ticket; + ticket.reserve(source.size() + sizeof(AppId_t)); + ticket.insert(ticket.end(), source.begin(), source.begin() + signedSize); + + const uint8_t* appIdBytes = reinterpret_cast(&appId); + ticket.insert(ticket.end(), appIdBytes, appIdBytes + sizeof(AppId_t)); + ticket.insert(ticket.end(), source.begin() + signedSize, source.end()); + + LOG_INFO("Forged App Ownership Ticket, AppId: {}, SourceAppId: {}, Physical Size: {}, Total Size: {}", + appId, kLocalAppTicketSourceAppId, ticket.size(), source.size()); + return ticket; + } + + bool GetAppOwnershipTicket(AppId_t appId, AppOwnershipTicket& ticket, AppTicketSource source) { + ticket = {}; + + if (source == AppTicketSource::CredentialStoreOnly || source == AppTicketSource::CredentialStoreThenForge) { + ticket.data = GetAppOwnershipTicketFromCredentialStore(appId); + if (!ticket.data.empty() && ticket.data.size() >= sizeof(uint32)) { + ticket.totalSize = static_cast(ticket.data.size()); + ticket.appIdOffset = kAppTicketAppIdOffset; + ticket.steamIdOffset = kAppTicketSteamIdOffset; + ticket.signatureOffset = *reinterpret_cast(ticket.data.data()); + ticket.signatureSize = kAppTicketSignatureSize; + return true; + } + } + + if (source == AppTicketSource::CredentialStoreOnly) return false; + + ticket.data = ForgeLocalAppOwnershipTicket(appId); + if (ticket.data.empty()) return false; + + ticket.totalSize = static_cast(ticket.data.size() - sizeof(AppId_t)); + ticket.appIdOffset = ticket.totalSize - kAppTicketSignatureSize; + ticket.steamIdOffset = kAppTicketSteamIdOffset; + ticket.signatureOffset = ticket.appIdOffset + sizeof(AppId_t); + ticket.signatureSize = kAppTicketSignatureSize; + return true; + } + + std::vector GetEncryptedTicketFromCredentialStore(AppId_t appId) { + LOG_DEBUG("appid={}", appId); + // exclude those appids that are not in addappid + if (!LuaConfig::HasDepot(appId)) { + LOG_DEBUG("GetEncryptedTicketFromCredentialStore for AppId {}: not in addappid, skip", appId); + return {}; + } + std::vector ticket; + const auto status = OSTPlatform::SteamCredentialStore::GetETicket(appId, ticket); + if (status != OSTPlatform::SteamCredentialStore::Status::Ok) { + LOG_TRACE("Read Encrypted App Ticket for AppId {}: cached credential unavailable ({})", + appId, OSTPlatform::SteamCredentialStore::ToString(status)); + return {}; + } + + LOG_INFO("Successfully retrieved Encrypted App Ticket from credential store, AppId: {}, Ticket Size: {}", appId, ticket.size()); + return ticket; + } + + bool WriteAppOwnershipTicket(AppId_t appId, const std::vector& data) { + // we can't execlude appids here + const auto status = OSTPlatform::SteamCredentialStore::WriteAppTicket(appId, data); + if (status != OSTPlatform::SteamCredentialStore::Status::Ok) { + LOG_ERROR("Failed to write AppTicket for AppId {} to credential store: {}", + appId, OSTPlatform::SteamCredentialStore::ToString(status)); + return false; + } + + LOG_INFO("Wrote AppTicket for AppId {} ({} bytes)", appId, data.size()); + return true; + } + + bool WriteEncryptedTicket(AppId_t appId, const std::vector& data) { + // we can't execlude appids here + const auto status = OSTPlatform::SteamCredentialStore::WriteETicket(appId, data); + if (status != OSTPlatform::SteamCredentialStore::Status::Ok) { + LOG_ERROR("Failed to write ETicket for AppId {} to credential store: {}", + appId, OSTPlatform::SteamCredentialStore::ToString(status)); + return false; + } + + LOG_INFO("Wrote ETicket for AppId {} ({} bytes)", appId, data.size()); + return true; + } + + bool WriteSteamID(AppId_t appId, uint64_t steamId) { + const auto status = OSTPlatform::SteamCredentialStore::WriteSteamId(appId, steamId); + if (status != OSTPlatform::SteamCredentialStore::Status::Ok) { + LOG_ERROR("Failed to write SteamID for AppId {} to credential store: {}", + appId, OSTPlatform::SteamCredentialStore::ToString(status)); + return false; + } + + LOG_INFO("Wrote SteamID for AppId {} ({})", appId, steamId); + return true; + } + + uint64_t GetSpoofSteamID(AppId_t appId) { + // exclude those appids that are not in addappid + if (!LuaConfig::HasDepot(appId)) { + LOG_DEBUG("GetSpoofSteamID for AppId {}: not in addappid, skip spoofing", appId); + return 0; + } + const uint64_t credentialSteamID = GetSteamIDFromCredentialStore(appId); + if (credentialSteamID != 0) { + return credentialSteamID; + } + + // 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]; + LOG_DEBUG("GetSpoofSteamID for AppId {}: -> 0x{:X}({})", appId, steamID, steamID); + return steamID; + } + return 0; + } +} diff --git a/src/Utils/AppTicket.h b/src/Utils/Tickets/AppTicket.h similarity index 55% rename from src/Utils/AppTicket.h rename to src/Utils/Tickets/AppTicket.h index 2ece5c8..e36afe8 100644 --- a/src/Utils/AppTicket.h +++ b/src/Utils/Tickets/AppTicket.h @@ -1,12 +1,21 @@ #pragma once -#include "dllmain.h" +#include "Steam/Types.h" + +#include +#include namespace AppTicket { inline constexpr uint32 kAppTicketSteamIdOffset = 8; inline constexpr uint32 kAppTicketAppIdOffset = 16; inline constexpr uint32 kAppTicketSignatureSize = 128; + enum class AppTicketSource { + CredentialStoreOnly, + ForgeOnly, + CredentialStoreThenForge, + }; + struct AppOwnershipTicket { std::vector data; uint32 totalSize = 0; @@ -16,24 +25,25 @@ namespace AppTicket { uint32 signatureSize = kAppTicketSignatureSize; }; - // Reads the app ownership ticket cached by Steam under - // HKCU\Software\Valve\Steam\Apps\\AppTicket (REG_BINARY) + // Reads the app ownership ticket cached by Steam's local credential store. // Returns an empty vector when no ticket is available. - std::vector GetAppOwnershipTicketFromRegistry(AppId_t appId); + std::vector GetAppOwnershipTicketFromCredentialStore(AppId_t appId); - bool GetAppOwnershipTicket(AppId_t appId, AppOwnershipTicket& ticket); + bool GetAppOwnershipTicket(AppId_t appId, AppOwnershipTicket& ticket, AppTicketSource source); - // Reads the encrypted app ticket cached by Steam under - // HKCU\Software\Valve\Steam\Apps\\ETicket (REG_BINARY) + // Reads the encrypted app ticket cached by Steam's local credential store. // Returns an empty vector when no ticket is available. - std::vector GetEncryptedTicketFromRegistry(AppId_t appId); + std::vector GetEncryptedTicketFromCredentialStore(AppId_t appId); //Get spoof steamID From the cached AppOwnershipTicket for the given AppId. uint64_t GetSpoofSteamID(AppId_t appId); - // Write AppTicket binary data to registry. + // Write AppTicket binary data to Steam's local credential store. bool WriteAppOwnershipTicket(AppId_t appId, const std::vector& data); - // Write ETicket binary data to registry. + // Write ETicket binary data to Steam's local credential store. bool WriteEncryptedTicket(AppId_t appId, const std::vector& data); + + // Write authorized SteamID to Steam's local credential store. + bool WriteSteamID(AppId_t appId, uint64_t steamId); } diff --git a/src/Utils/WinHttp.cpp b/src/Utils/WinHttp.cpp deleted file mode 100644 index 21cce65..0000000 --- a/src/Utils/WinHttp.cpp +++ /dev/null @@ -1,202 +0,0 @@ -#include "WinHttp.h" -#include "dllmain.h" -#include - -#pragma comment(lib, "winhttp.lib") - -namespace WinHttp { - - ParsedUrl ParseUrl(const char* rawUrl) { - ParsedUrl out; - std::string url(rawUrl); - - bool isHttps = false; - if (url.starts_with("https://")) { - isHttps = true; - url = url.substr(8); - } else if (url.starts_with("http://")) { - url = url.substr(7); - } else { - return out; - } - out.tls = isHttps; - out.port = isHttps ? INTERNET_DEFAULT_HTTPS_PORT : INTERNET_DEFAULT_HTTP_PORT; - - size_t slash = url.find('/'); - std::string hostPart = url.substr(0, slash); - out.path = (slash != std::string::npos) - ? L"/" + std::wstring(url.begin() + slash + 1, url.end()) - : L"/"; - - size_t colon = hostPart.find(':'); - if (colon != std::string::npos) { - out.host = std::wstring(hostPart.begin(), hostPart.begin() + colon); - out.port = static_cast(std::stoi(hostPart.substr(colon + 1))); - } else { - out.host = std::wstring(hostPart.begin(), hostPart.end()); - } - - out.valid = !out.host.empty(); - return out; - } - - Result Execute(LPCWSTR method, const char* url, - const void* reqBody, DWORD reqBodyLen, - const wchar_t* headers, - DWORD timeoutResolve, DWORD timeoutConnect, - DWORD timeoutSend, DWORD timeoutRecv) { - Result r; - - ParsedUrl pu = ParseUrl(url); - if (!pu.valid) { - LOG_WINHTTP_WARN("Invalid URL: {}", url); - return r; - } - - LOG_WINHTTP_DEBUG("{} (timeout: resolve={} connect={} send={} recv={})", - url, timeoutResolve, timeoutConnect, timeoutSend, timeoutRecv); - - auto t0 = std::chrono::steady_clock::now(); - - HINTERNET hSession = WinHttpOpen(L"OpenSteamTool/1.0", - WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, - WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); - if (!hSession) { - LOG_WINHTTP_WARN("{} - WinHttpOpen failed", url); - return r; - } - - WinHttpSetTimeouts(hSession, timeoutResolve, timeoutConnect, timeoutSend, timeoutRecv); - - HINTERNET hConnect = WinHttpConnect(hSession, pu.host.c_str(), pu.port, 0); - if (!hConnect) { - LOG_WINHTTP_WARN("{} - WinHttpConnect failed (port={})", url, pu.port); - WinHttpCloseHandle(hSession); - return r; - } - - DWORD flags = pu.tls ? WINHTTP_FLAG_SECURE : 0; - HINTERNET hRequest = WinHttpOpenRequest(hConnect, method, pu.path.c_str(), - nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags); - if (!hRequest) { - LOG_WINHTTP_WARN("{} - WinHttpOpenRequest failed", url); - WinHttpCloseHandle(hConnect); - WinHttpCloseHandle(hSession); - return r; - } - - if (headers && headers[0]) { - WinHttpAddRequestHeaders(hRequest, headers, - static_cast(wcslen(headers)), - WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE); - } - - DWORD totalLen = reqBodyLen; - if (!WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, - const_cast(reqBody), reqBodyLen, totalLen, 0)) { - LOG_WINHTTP_WARN("{} - WinHttpSendRequest failed (error={})", - url, GetLastError()); - } else if (!WinHttpReceiveResponse(hRequest, nullptr)) { - LOG_WINHTTP_WARN("{} - WinHttpReceiveResponse failed (error={})", - url, GetLastError()); - } else { - DWORD sz = sizeof(r.status); - WinHttpQueryHeaders(hRequest, - WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, - WINHTTP_HEADER_NAME_BY_INDEX, &r.status, &sz, - WINHTTP_NO_HEADER_INDEX); - - DWORD avail = 0; - while (WinHttpQueryDataAvailable(hRequest, &avail) && avail) { - size_t off = r.body.size(); - r.body.resize(off + avail); - DWORD read = 0; - if (!WinHttpReadData(hRequest, r.body.data() + off, avail, &read)) break; - r.body.resize(off + read); - if (r.body.size() > 256 * 1024) break; - } - - if (r.status < 200 || r.status >= 300) { - LOG_WINHTTP_WARN("{} - unexpected HTTP {} body={}", url, r.status, - r.body.size() > 512 ? r.body.substr(0, 512) + "..." : r.body); - } - r.ok = true; - } - - WinHttpCloseHandle(hRequest); - WinHttpCloseHandle(hConnect); - WinHttpCloseHandle(hSession); - - auto elapsed = std::chrono::duration_cast( - std::chrono::steady_clock::now() - t0).count(); - LOG_WINHTTP_INFO("{} - elapsed: {}ms (status={}, body={} ({}bytes))", - url, elapsed, r.status,r.body, r.body.size()); - - return r; - } - - Result ExecuteEx(HINTERNET hSession, HINTERNET hConnect, bool tls, - LPCWSTR method, const wchar_t* path, - const void* reqBody, DWORD reqBodyLen, - const wchar_t* headers, - const char* urlForLog) { - Result r; - if (!hSession || !hConnect) return r; - - auto t0 = std::chrono::steady_clock::now(); - - DWORD flags = tls ? WINHTTP_FLAG_SECURE : 0; - HINTERNET hRequest = WinHttpOpenRequest(hConnect, method, path, - nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, flags); - if (!hRequest) { - LOG_WINHTTP_WARN("{} - WinHttpOpenRequest failed", urlForLog); - return r; - } - - if (headers && headers[0]) { - WinHttpAddRequestHeaders(hRequest, headers, - static_cast(wcslen(headers)), - WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE); - } - - DWORD totalLen = reqBodyLen; - if (!WinHttpSendRequest(hRequest, WINHTTP_NO_ADDITIONAL_HEADERS, 0, - const_cast(reqBody), reqBodyLen, totalLen, 0)) { - LOG_WINHTTP_WARN("{} - WinHttpSendRequest failed (error={})", urlForLog, GetLastError()); - } else if (!WinHttpReceiveResponse(hRequest, nullptr)) { - LOG_WINHTTP_WARN("{} - WinHttpReceiveResponse failed (error={})", urlForLog, GetLastError()); - } else { - DWORD sz = sizeof(r.status); - WinHttpQueryHeaders(hRequest, - WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, - WINHTTP_HEADER_NAME_BY_INDEX, &r.status, &sz, - WINHTTP_NO_HEADER_INDEX); - - DWORD avail = 0; - while (WinHttpQueryDataAvailable(hRequest, &avail) && avail) { - size_t off = r.body.size(); - r.body.resize(off + avail); - DWORD read = 0; - if (!WinHttpReadData(hRequest, r.body.data() + off, avail, &read)) break; - r.body.resize(off + read); - if (r.body.size() > 256 * 1024) break; - } - - if (r.status < 200 || r.status >= 300) { - LOG_WINHTTP_WARN("{} - unexpected HTTP {} body={}", urlForLog, r.status, - r.body.size() > 512 ? r.body.substr(0, 512) + "..." : r.body); - } - r.ok = true; - } - - WinHttpCloseHandle(hRequest); - - auto elapsed = std::chrono::duration_cast( - std::chrono::steady_clock::now() - t0).count(); - LOG_WINHTTP_INFO("{} - elapsed: {}ms (status={}, body={} ({}bytes))", - urlForLog, elapsed, r.status, r.body, r.body.size()); - - return r; - } - -} diff --git a/src/Utils/WinHttp.h b/src/Utils/WinHttp.h deleted file mode 100644 index 3c16421..0000000 --- a/src/Utils/WinHttp.h +++ /dev/null @@ -1,52 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace WinHttp { - - struct ParsedUrl { - std::wstring host; - std::wstring path; - INTERNET_PORT port = INTERNET_DEFAULT_HTTP_PORT; - bool tls = false; - bool valid = false; - }; - - ParsedUrl ParseUrl(const char* rawUrl); - - struct Result { - std::string body; - DWORD status = 0; - bool ok = false; - }; - - // Perform an HTTP request. - // method: L"GET" or L"POST" - // url: full URL (http:// or https://) - // reqBody: raw POST body (nullptr for GET) - // reqBodyLen: byte length of reqBody - // headers: nullptr or wstring L"Key: Val\r\n..." - // timeoutResolve: DNS timeout in ms (default 5000) - // timeoutConnect: TCP connect timeout in ms (default 5000) - // timeoutSend: send timeout in ms (default 10000) - // timeoutRecv: receive timeout in ms (default 10000) - Result Execute(LPCWSTR method, const char* url, - const void* reqBody, DWORD reqBodyLen, - const wchar_t* headers, - DWORD timeoutResolve = 5000, - DWORD timeoutConnect = 5000, - DWORD timeoutSend = 10000, - DWORD timeoutRecv = 10000); - - // Reuse caller-owned hSession + hConnect handles to skip DNS/TCP/TLS - // setup on repeated requests to the same host. The caller is - // responsible for the lifetime of both handles. - Result ExecuteEx(HINTERNET hSession, HINTERNET hConnect, bool tls, - LPCWSTR method, const wchar_t* path, - const void* reqBody, DWORD reqBodyLen, - const wchar_t* headers, - const char* urlForLog = ""); - -} diff --git a/src/cmake/LogMacros.cmake b/src/cmake/LogMacros.cmake index 0254446..fb85f88 100644 --- a/src/cmake/LogMacros.cmake +++ b/src/cmake/LogMacros.cmake @@ -1,14 +1,14 @@ # Generate per-module LOG_XXX_TRACE/DEBUG/INFO/WARN/ERROR macros from -# ost_log_modules.h so that adding a new module only requires one line -# in ost_log_modules.h — Log.h never needs to change. +# LogModules.def so that adding a new module only requires one line +# there — Log.h never needs to change. # # Output: ${CMAKE_CURRENT_BINARY_DIR}/generated/ost_log_macros.h -set(_log_modules_h "${CMAKE_CURRENT_SOURCE_DIR}/Utils/ost_log_modules.h") +set(_log_modules_h "${CMAKE_CURRENT_SOURCE_DIR}/Utils/Logging/LogModules.def") set(_log_macros_out "${CMAKE_CURRENT_BINARY_DIR}/generated/ost_log_macros.h") file(STRINGS "${_log_modules_h}" _lines REGEX "^OST_MOD\\(") -string(REGEX MATCHALL "OST_MOD\\([A-Za-z_]+" _matches "${_lines}") +string(REGEX MATCHALL "OST_MOD\\([A-Za-z_][A-Za-z0-9_]*" _matches "${_lines}") set(_debug "") set(_release "") @@ -33,7 +33,7 @@ foreach(_m ${_matches}) endforeach() set(_new_content -"// Auto-generated from ost_log_modules.h -- DO NOT EDIT\n\ +"// Auto-generated from LogModules.def -- DO NOT EDIT\n\ #pragma once\n\n\ #ifdef OPENSTEAMTOOL_LOGGING_ENABLED\n\n\ ${_debug}\ diff --git a/src/cmake/OpenSteamToolBuildInfo.h.in b/src/cmake/OpenSteamToolBuildInfo.h.in new file mode 100644 index 0000000..4f285cd --- /dev/null +++ b/src/cmake/OpenSteamToolBuildInfo.h.in @@ -0,0 +1,3 @@ +#pragma once + +#define OPENSTEAMTOOL_VERSION "@OPENSTEAMTOOL_VERSION@" diff --git a/src/dllmain.cpp b/src/dllmain.cpp index d0b8f43..4307250 100644 --- a/src/dllmain.cpp +++ b/src/dllmain.cpp @@ -1,42 +1,49 @@ #include "dllmain.h" #include "Hook/HookManager.h" -#include "Utils/FileWatcher.h" -#include "Utils/IPCLoader.h" -#include "Utils/PatternLoader.h" -#include "Utils/SteamDiagnostics.h" +#include "Utils/Config/ConfigFileWatcher.h" +#include "Utils/Config/LuaFileWatcher.h" +#include "Utils/SteamMetadata/IPCLoader.h" +#include "Utils/SteamMetadata/PatternLoader.h" +#include "Utils/SteamMetadata/SteamDiagnostics.h" +#include "OSTPlatform/include/DynamicLibrary.h" +#include "OSTPlatform/include/Thread.h" + +#include // prepare key runtime paths. bool InitializeSteamComponents() { - if (!GetCurrentDirectoryA(MAX_PATH, SteamInstallPath)) { + const std::string steamInstallPath = OSTPlatform::DynamicLibrary::GetCurrentDirectoryPath(); + if (steamInstallPath.empty()) { return false; } - sprintf_s(SteamclientPath, MAX_PATH, "%s\\steamclient64.dll", SteamInstallPath); - sprintf_s(SteamUIPath, MAX_PATH, "%s\\steamui.dll", SteamInstallPath); - sprintf_s(DiversionPath, MAX_PATH, "%s\\bin\\diversion.dll", SteamInstallPath); - sprintf_s(LuaDir, MAX_PATH, "%s\\config\\lua", SteamInstallPath); - sprintf_s(ConfigPath, MAX_PATH, "%s\\opensteamtool.toml", SteamInstallPath); + 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 = LoadLibraryA(SteamclientPath); + client_hModule = OSTPlatform::DynamicLibrary::Load(SteamclientPath); if (!client_hModule) { - LOG_ERROR("LoadLibraryA failed: {} (err={})", SteamclientPath, GetLastError()); + LOG_ERROR("Load steamclient64.dll failed: {} (err={})", + SteamclientPath, OSTPlatform::DynamicLibrary::GetLastErrorCode()); return false; } - LOG_INFO("Loaded diversion.dll from {}", SteamclientPath); + LOG_INFO("Loaded steamclient64.dll from {}", SteamclientPath); - ui_hModule = GetModuleHandleA("steamui.dll"); + ui_hModule = OSTPlatform::DynamicLibrary::Load(SteamUIPath); if(!ui_hModule) { - LOG_ERROR("GetModuleHandleA failed for steamui.dll: err={}", GetLastError()); + LOG_ERROR("Load failed for steamui.dll: err={}", OSTPlatform::DynamicLibrary::GetLastErrorCode()); return false; } return true; } -// All initialisation that touches the filesystem, calls LoadLibrary, scans +// All initialisation that touches the filesystem, loads modules, scans // memory, or installs detours runs here on a worker thread — we MUST NOT do // any of that from inside DllMain (loader lock). -static DWORD WINAPI InitThread(LPVOID param) { - HMODULE selfModule = static_cast(param); +static uint32_t InitThread(OSTPlatform::DynamicLibrary::ModuleHandle selfModule) { Log::Init(selfModule); LOG_INFO("OpenSteamTool init thread started"); @@ -47,6 +54,7 @@ static DWORD WINAPI InitThread(LPVOID param) { Config::Load(ConfigPath); Log::InitModules(); + Log::InstallPlatformLogSink(); SteamDiagnostics::Initialize(SteamclientPath, SteamUIPath); // Load pattern files for steamclient64.dll and steamui.dll. @@ -59,12 +67,13 @@ static DWORD WINAPI InitThread(LPVOID param) { // IPC method metadata (funcHash, fencepost, argc, ...) IPCLoader::Load(SteamclientPath); - std::vector watchDirs = Config::luaPaths; + std::vector watchDirs = Config::GetLuaPaths(); watchDirs.push_back(std::string(LuaDir)); for (const auto& dir : watchDirs) LuaConfig::ParseDirectory(dir); - FileWatcher::Start(watchDirs); + LuaFileWatcher::Start(watchDirs); + ConfigFileWatcher::Start(ConfigPath, LuaDir); SteamUI::CoreHook(); SteamClient::CoreHook(); @@ -82,13 +91,15 @@ BOOL APIENTRY DllMain(HMODULE hModule, DWORD dwReason, PVOID pvReserved) { DisableThreadLibraryCalls(hModule); // Hand off all real work to a worker thread to avoid running file I/O, - // LoadLibrary, and detour transactions under the loader lock. - HANDLE h = CreateThread(nullptr, 0, InitThread, hModule, 0, nullptr); - if (h) CloseHandle(h); + // module loading and detour transactions under the loader lock. + OSTPlatform::Thread::StartDetached([module = reinterpret_cast(hModule)] { + return InitThread(module); + }); } else if (dwReason == DLL_PROCESS_DETACH) { - FileWatcher::Stop(); + ConfigFileWatcher::Stop(); + LuaFileWatcher::Stop(); SteamUI::CoreUnhook(); SteamClient::CoreUnhook(); } diff --git a/src/dllmain.h b/src/dllmain.h index c6576a0..f45507a 100644 --- a/src/dllmain.h +++ b/src/dllmain.h @@ -1,7 +1,8 @@ #ifndef DLLMAIN_H #define DLLMAIN_H -#include +#include "OSTPlatform/include/DynamicLibrary.h" + #include #include #include @@ -18,20 +19,22 @@ #include "Steam/Enums.h" #include "Steam/Structs.h" #include "Steam/Callback.h" -#include "Utils/LuaConfig.h" -#include "Utils/Log.h" -#include "Utils/Config.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 HMODULE client_hModule = nullptr; -inline HMODULE ui_hModule = nullptr; +inline constexpr size_t kRuntimePathCapacity = 260; -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 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; diff --git a/tools/ipc_codegen/ipc_codegen.cpp b/tools/ipc_codegen/ipc_codegen.cpp index ac55208..17bd946 100644 --- a/tools/ipc_codegen/ipc_codegen.cpp +++ b/tools/ipc_codegen/ipc_codegen.cpp @@ -66,6 +66,7 @@ struct CommandDecl { std::string enumValue; FrameDecl request; FrameDecl response; + bool responseNone = false; int line = 0; }; @@ -313,7 +314,13 @@ class Parser { hasRequest = true; } else if (section.text == "response") { if (hasResponse) fail(section.line, "duplicate command response section"); - command.response = parseFrame(); + if (token_.kind == Tok::Ident && token_.text == "none") { + command.responseNone = true; + advance(); + expect(Tok::Semi); + } else { + command.response = parseFrame(); + } hasResponse = true; } else { fail(section.line, "expected 'request' or 'response', got '" + section.text + "'"); @@ -448,10 +455,22 @@ class Parser { const ProtocolDecl& protocol = file.protocols.front(); if (!findStruct(file, protocol.requestHeader)) fail(protocol.line, "unknown protocol request header '" + protocol.requestHeader + "'"); - if (protocol.commands.size() != 1 || protocol.commands.front().name != "IPCInterfaceCall") - fail("protocol IPC must declare exactly one IPCInterfaceCall command"); - const CommandDecl& command = protocol.commands.front(); + const CommandDecl* interfaceCall = nullptr; + for (const auto& command : protocol.commands) { + validateFrame(file, command.request, "request"); + if (!command.responseNone) validateFrame(file, command.response, "response"); + if (command.name == "IPCInterfaceCall") interfaceCall = &command; + } + if (!interfaceCall) fail("protocol IPC must declare IPCInterfaceCall command"); + validateIPCInterfaceCall(*interfaceCall, file); + } + + void validateIPCInterfaceCall(const CommandDecl& command, const File& file) { + if (command.enumValue != "EIPCCommand::InterfaceCall") + fail(command.line, "IPCInterfaceCall must use EIPCCommand::InterfaceCall"); + if (command.responseNone) + fail(command.line, "IPCInterfaceCall response must be a frame"); validateFrame(file, command.request, "request"); validateFrame(file, command.response, "response"); if (!framePayload(command.request) || !framePayload(command.response)) @@ -604,6 +623,7 @@ class Emitter { emitHelpers(); emitLayouts(); emitIPCRequest(); + emitProtocolCommandViews(); emitIPCInterfaceCall(); emitIPCResponse(); out_ << "template \n" @@ -758,6 +778,79 @@ class Emitter { "};\n\n"; } + void emitProtocolCommandViews() { + for (const auto& protocol : file_.protocols) { + if (protocol.name != "IPC") continue; + for (const auto& command : protocol.commands) { + if (command.name == "IPCInterfaceCall") continue; + if (!canEmitFixedFrame(command.request)) continue; + if (!command.responseNone && !canEmitFixedFrame(command.response)) continue; + emitFixedFrameView(command.name + "Req", command.request, false); + emitFixedFrameView(command.name + "Resp", command.response, command.responseNone); + } + } + } + + void emitFixedFrameView(const std::string& className, const FrameDecl& frame, bool noneFrame) { + out_ << "class " << className << " {\n" + "public:\n" + << " explicit " << className << "(std::span bytes) : bytes_(bytes) {}\n" + << " bool ok() const { return "; + if (noneFrame) + out_ << "bytes_.empty()"; + else + out_ << "bytes_.size() >= " << frameFixedSizeExpr(frame); + out_ << "; }\n"; + if (!noneFrame) { + for (size_t i = 0; i < frame.fields.size(); ++i) { + const FrameField& field = frame.fields[i]; + const std::string offset = frameOffsetExpr(frame, i); + out_ << " " << field.type << " " << field.name + << "() const { return detail::Read<" << field.type << ">(bytes_, " << offset << "); }\n" + << " void set_" << field.name << "(" << field.type + << " value) { detail::Write(bytes_, " << offset << ", value); }\n"; + } + } + emitFrameDebugString(className, noneFrame ? FrameDecl{} : frame); + out_ << "private:\n" + " std::span bytes_;\n" + "};\n\n"; + } + + static bool canEmitFixedFrame(const FrameDecl& frame) { + for (const auto& field : frame.fields) { + if (field.payload || !isIntegral(field.type)) return false; + } + return true; + } + + static std::string frameFixedSizeExpr(const FrameDecl& frame) { + std::vector terms; + for (const auto& field : frame.fields) terms.push_back("sizeof(" + field.type + ")"); + return sumExpr(terms); + } + + static std::string frameOffsetExpr(const FrameDecl& frame, size_t index) { + std::vector terms; + for (size_t i = 0; i < index; ++i) + terms.push_back("sizeof(" + frame.fields[i].type + ")"); + return sumExpr(terms); + } + + void emitFrameDebugString(const std::string& cls, const FrameDecl& frame) { + out_ << " std::string DebugString() const {\n" + " std::ostringstream os;\n" + << " os << \"" << cls << "{\";\n"; + for (size_t i = 0; i < frame.fields.size(); ++i) { + if (i) out_ << " os << ' ';\n"; + out_ << " detail::AppendField(os, \"" << frame.fields[i].name + << "\", " << frame.fields[i].name << "());\n"; + } + out_ << " os << '}';\n" + " return os.str();\n" + " }\n"; + } + void emitIPCInterfaceCall() { out_ << "class IPCInterfaceCall {\n" "public:\n" @@ -931,6 +1024,14 @@ class Emitter { return std::find(names.begin(), names.end(), name) != names.end(); } + static bool isIntegral(std::string_view type) { + static const std::unordered_set types = { + "bool", "byte", "uint8", "int8", "uint16", "int16", + "uint32", "int32", "uint64", "int64", "size_t", + }; + return types.contains(type); + } + static std::string lengthExpr(const Field& field, const std::vector& sideFields, const std::vector& external) { From d587952a9b5ecc30d3d93f8040c2e457016d4817 Mon Sep 17 00:00:00 2001 From: OpenSteam001 <248184252+OpenSteam001@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:01:24 +0800 Subject: [PATCH 2/4] fix: address wide-string build issues and only mark processes injected after successful load --- src/OSTPlatform/Windows/Http.cpp | 3 ++- src/OSTPlatform/Windows/RemoteProcess.cpp | 3 ++- src/OSTPlatform/Windows/Trap.cpp | 10 ++++++++-- src/Pipe/Features/Injection/Injection.cpp | 12 +++++++++--- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/OSTPlatform/Windows/Http.cpp b/src/OSTPlatform/Windows/Http.cpp index 92817ab..f7956ac 100644 --- a/src/OSTPlatform/Windows/Http.cpp +++ b/src/OSTPlatform/Windows/Http.cpp @@ -1,5 +1,6 @@ #include "include/Http.h" +#include "include/Encoding.h" #include "include/Log.h" #include "include/Numbers.h" @@ -93,7 +94,7 @@ Result Execute(const wchar_t* method, if (!hConnect) { OSTP_LOG_WARN("{} - WinHttpConnect(host='{}', port={}) failed (error={})", url ? url : "", - std::string(pu.host.begin(), pu.host.end()), + Encoding::WideToUtf8(pu.host), pu.port, GetLastError()); WinHttpCloseHandle(hSession); diff --git a/src/OSTPlatform/Windows/RemoteProcess.cpp b/src/OSTPlatform/Windows/RemoteProcess.cpp index e9e17df..5874d88 100644 --- a/src/OSTPlatform/Windows/RemoteProcess.cpp +++ b/src/OSTPlatform/Windows/RemoteProcess.cpp @@ -1,5 +1,6 @@ #include "include/RemoteProcess.h" +#include "include/Encoding.h" #include "include/Log.h" #include "include/PE.h" @@ -123,7 +124,7 @@ std::optional ResolveRemoteExport( module = FindModule(modules, L"kernelbase.dll"); } if (!module) { - OSTP_LOG_WARN("ResolveRemoteExport: module '{}' not found", std::string(moduleName.begin(), moduleName.end())); + OSTP_LOG_WARN("ResolveRemoteExport: module '{}' not found", Encoding::WideToUtf8(moduleName)); return std::nullopt; } diff --git a/src/OSTPlatform/Windows/Trap.cpp b/src/OSTPlatform/Windows/Trap.cpp index 4212650..a6ef4d3 100644 --- a/src/OSTPlatform/Windows/Trap.cpp +++ b/src/OSTPlatform/Windows/Trap.cpp @@ -57,16 +57,22 @@ PCONTEXT AsWindowsContext(void* nativeContext) { return static_cast(nativeContext); } -bool TryReadStackSlot(uintptr_t address, uint64_t& out) { +bool TryReadStackSlotRaw(uintptr_t address, uint64_t& out) { __try { out = *reinterpret_cast(address); return true; } __except (EXCEPTION_EXECUTE_HANDLER) { - OSTP_LOG_TRACE("TryReadStackSlot({:#x}) failed", address); return false; } } +bool TryReadStackSlot(uintptr_t address, uint64_t& out) { + if (TryReadStackSlotRaw(address, out)) return true; + + OSTP_LOG_TRACE("TryReadStackSlot({:#x}) failed", address); + return false; +} + } // namespace uint64_t Context::InstructionPointer() const { diff --git a/src/Pipe/Features/Injection/Injection.cpp b/src/Pipe/Features/Injection/Injection.cpp index 81948c4..454d364 100644 --- a/src/Pipe/Features/Injection/Injection.cpp +++ b/src/Pipe/Features/Injection/Injection.cpp @@ -18,9 +18,14 @@ namespace { std::mutex g_mutex; std::unordered_set g_injected; - bool ClaimInjection(const ProcessKey& key) { + bool WasInjected(const ProcessKey& key) { std::scoped_lock lock(g_mutex); - return g_injected.insert(key).second; + return g_injected.contains(key); + } + + void MarkInjected(const ProcessKey& key) { + std::scoped_lock lock(g_mutex); + g_injected.insert(key); } std::filesystem::path ResolveLibraryPath(const std::string& configured) { @@ -55,11 +60,12 @@ void Apply(const PipeContext& ctx) { const auto architecture = OSTPlatform::RemoteProcess::GetArchitecture(ctx.process.pid); const std::string* configuredLibrary = ConfiguredLibraryFor(settings, architecture); if (!configuredLibrary) return; - if (!ClaimInjection(ctx.process)) return; + if (WasInjected(ctx.process)) return; 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 { From 71ffce6c0d734dd104f25be0346bf8cd3288fe3a Mon Sep 17 00:00:00 2001 From: OpenSteam001 <248184252+OpenSteam001@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:45:53 +0800 Subject: [PATCH 3/4] docs: add animated logo and polish README headers --- README.md | 85 ++++++++++++++++++------------------- README_ES.md | 54 ++++++++++++------------ docs/logo-1024.png | Bin 0 -> 72360 bytes docs/logo-128.png | Bin 0 -> 8018 bytes docs/logo-512.png | Bin 0 -> 34078 bytes docs/logo-animated.svg | 92 +++++++++++++++++++++++++++++++++++++++++ docs/logo-static.svg | 22 ++++++++++ 7 files changed, 185 insertions(+), 68 deletions(-) create mode 100644 docs/logo-1024.png create mode 100644 docs/logo-128.png create mode 100644 docs/logo-512.png create mode 100644 docs/logo-animated.svg create mode 100644 docs/logo-static.svg diff --git a/README.md b/README.md index e808460..0ad9914 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,35 @@ -# 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) - -
- - - - - -
- United States Flag -
- English -
- Spain Flag -
- Español -
-
- -OpenSteamTool is a Windows DLL project built with CMake. - -## Feature +
+ 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 + +

+
+ +## Feature ### Core Unlocks - Unlock an unlimited number of unowned games. @@ -43,11 +47,11 @@ OpenSteamTool is a Windows DLL project built with CMake. ### 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. +- 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` @@ -85,10 +89,7 @@ The `extract_tickets` tool dumps the `AppTicket` and `ETicket` hex strings you n - 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. @@ -107,9 +108,9 @@ addtoken(1361510,"2764735786934684318") -- add access token ("276473578693468431 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 +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, default SteamID 76561198028121353 is used. @@ -119,9 +120,9 @@ All function names are **case-insensitive**. `setAppTicket`, `setappticket`, `Se ### 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. +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] diff --git a/README_ES.md b/README_ES.md index 6ef5110..edfaa72 100644 --- a/README_ES.md +++ b/README_ES.md @@ -1,30 +1,34 @@ -# 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) -
- - - - - -
- United States Flag -
- English -
- Spain Flag -
- Español -
+ Logo de OpenSteamTool + +

OpenSteamTool

+ +

+ Herramienta de código abierto para desbloquear Steam +

+ +

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

+ +

+ + Bandera de Estados Unidos + English + +  |  + + Bandera de España + Español + +

-OpenSteamTool es un proyecto de DLL para Windows desarrollado con CMake. - ## Características ### Desbloqueos principales @@ -85,9 +89,7 @@ La herramienta `extract_tickets` vuelca las cadenas hexadecimales de `AppTicket` - Añade `-onlinefix` a los parámetros de lanzamiento de Steam para habilitar el juego en línea basado en el AppId 480 en juegos que utilizan emparejamiento (matchmaking) por salas (lobbies). La limitación actual es que solo se puede ejecutar uno de estos juegos a la vez. Para revertirlo, simplemente elimina -onlinefix de los parámetros de lanzamiento; el juego en línea volverá a la normalidad en el próximo inicio. ## Futuro -- Para los juegos protegidos por Denuvo y SteamStub, encontrar un momento seguro para cambiar `GetSteamID` (ver el TODO en `src/Hook/Hooks_IPC.cpp#Handler_IClientUser_GetSteamID`) de modo que los archivos de guardado no se vean afectados. (**Se aceptan sugerencias: ¿cuál es el punto más temprano después de la inicialización del juego en el que podemos cambiar el SteamID de forma segura sin afectar a la vinculación de la partida guardada?**). - Soporte para la sincronización con Steam Cloud (este es un proyecto enorme). -- Añadir la función de compartir automáticamente la autorización de Denuvo para cuentas legítimas. ## Uso 1. Ejecuta `build.bat` desde la raíz del proyecto para compilarlo. diff --git a/docs/logo-1024.png b/docs/logo-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..0b4784bca35c6c82926ae77892255a84a46821f2 GIT binary patch literal 72360 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℘QXv|np-fgbt(*18&`*NX0?~qe+;+GF& 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
  • v`uR>pNp9zx_7MeM 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 HoD!M<^J08{ literal 0 HcmV?d00001 diff --git a/docs/logo-128.png b/docs/logo-128.png new file mode 100644 index 0000000000000000000000000000000000000000..9ffdbac21cb000fff05613e03a7b952575210b93 GIT binary patch literal 8018 zcmZvhWmMEp)W?5IEDcL{H_{>94blSA2vSlDNY_%*NFzvhgLEw{p&;EzNFyi+2rM0s z|L5g%o-=3Wd(XVN-XuC^*3HWfAi0C?(ZO8Wm4_TR(=|62!TUXT3~u#Kjw z67cljD1=v}0RTOQx{~}$zuZ50PJSeYi^ES9TzTlax+dr*B!pmP0T*>&1?B;Ucxtp^ zbP_^{3Wb%P!`G#mOK%eIHCnq@Xdrn!1~Eqnt(uxIQMf$1Jc%+yPxocAh`R5Or#nU* z^%G6jF#p?OFWG7D!}+S}wC>!%V@?L`TrrRBp;%(}{~MfdBnY>lAmx30G9Uf-=}lP= zGB2mUgX0{CX4Q!Et6(LtbQ~zA5+?(Olv~iBgN!RL#X}4F`z19A0D>1OFT9y!Hsepr$lT}3>EX$o%_`?;^528k_ zd^$QnBv>D90dWH4$Wv`ny@7jf8~F6PqK%hMi<&uI0P@a}LBt2UaoTo7L3OI<2VSY- zi(Dh{CWCOW=1-`?F_^!w2w4RJ)t8J25m+=m1`{?Gkz!}f4FEKe<*scTN~eqf`B#Qf zppgLa0iw%w>z}NK0yRpU<#_MxLOPQ6d$xxaKP*mO8x9?%hS@d;W0Dj^7OJ9EaK6M{ zmmzwMgEj==cqtCv^;fVww>|8nBH}A`d&jox33&i6fMiG*{->ypldb$i*@Vs9 zfc{+1L;`J2RKLyUu)25cfiht=3NIfhH{-#l4U}1u1oQEpAgD{T z=->33zYOK5VtlVGP@GA`%8R_~zj4|Ep${B`Tsd!Wpl-=;@1p#Q=ToJwerd{z8M%)} zh1r^24V^W4xrGfdGdYr1(#67ycis;N{3Ktj2niEo#ocW9B*+f(!wRDn+l%Z;KH`1o zF0uN%74VYBhK98&akNDx6uf7Nmao;p_)%)-?B4j$Kr~di76duV(t4mry#dZW@q&BY@1WsZ?jwYe| z=U48H?LxvL2R*`Ca3+tDI+bEK=Gtxa#xQN-lKao5145n%s2LT9ckGf z?+iJK`krPzqhC-J-PodL)L#76qW-5skd;gffd=shZ7k!-{_RH{Sda9$-mEdx$BIJu zqkdlxyr0Y7&f!t^qf5wTgpm8}=3{!(IP z{m;)JfSDN73b%884^v& zgrp^hszTe}p)0AIY=3@i4s?~E`_&3;hGZcVh)osr?2c?)XPDZbnm2B5W=8O`Bt&4b zH7Lu>S!>k8V_5=e3fCf% zr__yF)W+!dAeEe*mux&{bQ~Yv1o4+mubWuAC@J%6XobuPKWyvryyBj(0=?jDfuoN) zo@`(xWIK8H(q%#v*vRc_i3jK^Uox?GbBt?m=#|PGDgJrzWlLoRBc3K-=KK0NxBj%S`4dcg&*P(;aRzPUk4n6`i!d;}A*y1&ta_-4O0w*fh zX-*%#+gK~R!Paj%EnW`ysXqmMV=fPh0_=lG-k9oR*S^&f+1!l0vJ9Mj(-1z1^GG9qfaURa{s-~vI{625&`S{UYRlESgBIMCD zYkUmqnxqtSpkjrdOWBBC`3l1kviAOwKkQFj>ca}GXSTfVYC~<5fsd9)qvPNwUhtGl zLG}|p3I`x@>kZUpW=x8kwDqeN@J^Ix=eGxAvY{--PAP z|67&Gn(2>P;BT+*`>y4Uy?6?QSp77aFqV?6XvjZCHgqyHHYP zn{idb^)5@7aTkE0Q`T#-oCWX368@4<(|sWX6L1ELvhfdeFAJvkP-MuZw1s^NjTP4z zev$6@gkO00xT`YUE`g~9vfv8YnQ7c3;=6f=ikSFUf34LA7fYPvclQBAiE!@AW@Tfg zeFVb{6BGwwz~!%4v34&?@_DT$gNt3j+owTmxX)yxPd3&|4Z*)V2HiUj>rF)vSYB6eI;A60f(|pEA z$*}?U-VcNSuAs@2^!eLes1vK*RCO(#ml?Epx{tStl6SG=lu_HlLsL9C=$LoOL2WVh z-DCtKtDiD{NY8j)nq;en4iW>^D2AHyJF#kfpB?4oziBzUZUAC^U#EEbCPzAOp&>MB zkNn>NO#Dj^L+9~W<#JG~DoS8=AS_vJ>F&QQ6=&)EP(PnH$r+RHj5vy=t_Mw5Ujv+@ z&RJ;?f?f~114H>xig1Fck1r2JXYm>AQTgp~b1Hd&qJvoi#Geo>0nBPm?`WF_T-RXANuu)Ud^iJ2y0{ zg`5WL_^hNP85=U7s}oz4Pz+)ng2zfxfDBn#P8ktaIz#;?1jocD5@Zdu?*IPkoUFFP zbBB*4=%;t0(NbT*3>#S|3=6zT_#o(>#frys&tn0CiWR00j4~W=mTPGGaT8^afh9JI zQ};<S?^j>A%|)kl>@;&2?JTrjKY^xCe+CK1Seyem%e*uKJ^Z;QWJr)`fp^7L#-cF!v~a883KkRC|hFuI=w__ic~|CQ+sXq zU_FW{qqsQRUi6y!Jq$*lKTo0H`jpPo)j1cpedQ)cj51CpF4cVw#$-s|*JN%yT_Kpo zsHHK#cIxnalJ3~V0AxQF**+AtrCK&KG1;TWbmHhHC&>r_o;tOjHHDRT54fQf-tuagFV(hbDbjwK z6cN@C4Z;{=THm@PBo4%p6Gl?y9tj1Il+e29K zF-T$+Ew8AXK|{irqQH7e;7kT~8VeFrS|MxysKOF@s#Z>WcZK+@@hY(gf zc)ll-+G?Ptz0LO~XP|Yyzc85OH?DbAPiaQZP{=g;7O9ymG2xXaG>)t`rY-B zw3cL;Lf|m=9xxkH@N|^Y==x9gaA@nZZAv1QYaHQOy+XLGV}|Q`qpxf0^Pp6YhN~}3 zPhT3MQ`c(}l*YRYwM2nyH}nRf%%pXr$1ThyI7%rIW>>(0A=*%fXVjmF_0M*O8#s-d zBz`vxjYD^q%}w3xsbV!g7SDS?9MA?7T|?Yr*I-H8#RRCUn@@3KMzRyOn7Kg#q%4Ez zjn!ZGRWoCItL6EwZ%C;yc2X!43Z;E!30l63Pw@oK`jsF5UhN__DQaz+vW@m9>hpN2 z>m*l@UZCr`Yf9#15ndYMU7Vql>z8TE!jX}Y$@B(viq1=_eX_uMX~wO}eRDOxxi`vA z;4e(qQ%cw%jCfx8D;2*{qEQ2Vef(OwuCDQozhO1AbCoSCSmocc7A?&9d1z=+3D0ek znS29RQ_67j0O1%=Ew$c-G)xcG$TS+|j0tiajwMM9#S0n^m{AS5ua2-CcO}W-f^rtXChmg>4soiFS22lf{5~E>$-!ZHh!t zho0UYG!Quz0C@DJ zB`BIm&j`4_UkHTuT5qk5fSycB%;x+C>jGsJ5u<|Xs`aTP}!IlY; zYdCTt?g(|H>B8<8&Y$JW#y0b%A@R{pBJ5u;Wj^saOD2#U6R2A9Ti7ZS8egyVmB0AL z>JkU)@cwq?@m7~<1@W_Y9H^_9#GUTZFz(;9MA8 zS~dR8JV|BAUv2$K-ZdE|1aCK20L}Qy(oItKBMq8Zd-YIwaIbVW0>YOa{fSMKCq_>g zNb7T?9xzY^NULD1u`yE!Nxf^iA<$WPn`1He~0yJ*fn|`WVt+=MO?rfs=HIm6rVIoV`nXa=Q9=EbG*Kw}OhcoA{EOc(VKc;9i zw`pR!{_BSDtnv-2#AE6vY_hblir7M2EPn#;d326VaKV0c2h;N^c7X{1S0IzPvb7eg z-?gea+@bjZE^NyX)=#=W;tqZ@#9s1jZ3LdRI(dT3^p-zyIpl+9nHv7nc6f80Q0>X9 zDFC*uH_3zD(fL~(XDB5jkJO`97NDS_LoW8Is7EtjH)DIc&6o0i7G)1^s;9XU<~sj| zpyDZ~M3m1c7jSNCHgvwF&d(5(`tGk$70Vu>etKFyQ!fon3rR|A`JW1eeU20BZqg1^ zzAw8#dwyv#+fE9kC-7eDg2qTd5eJQFfei^7PkQw>%FEOon=L1-!4dZO#%L3lsJgy7 zFO@X3Ou(N5<}lCCn6qKriUy402FEb1qB|R@<9FXrg!}+D6LvD0Q1(cNKa$lw zzWfa>Jp9!~cV?2kj7~9;>V6gsK3irlu^_xN3V&SDUs#pL;>ROLqq{kn%t5IZsvdg81al9SM>-YFzt!-gF0A)w%BldmMhXQah@p`CjHT zRj+mpEnREMMO?|70jwvkkBVQ|9dcOwZd26ik2H!$wX8WFMK)U*%(9X!sf0!HOe|pR zGtv?4!AypG2oJj+l8NA`Vr^XOvG4a58e6Oz(fz~Sd>V)5&Ag;bMs!Q1FQ^HC?%}w(gjOVf{nMy1!XERx4ne8^`^YaEWjSD(A#PHU!H97#_6N?~(< z)Zs5}7^T|FMV@w&6Lgqcd~F1#xiP2h)nXnxVPa}GUrTGnwwYzXBHfoc;xjWP`6+@3 zpJgY`(&q)F;skH=pZw206K@6a@NoSW4rpIJY~y6Gy7W3I`eTbtS9d#gLS~XhSc$#O zt$y|j94s;UY)-`|6TiW}<8J$6EPeJ27H83f;=g+?z^mGE>ur*{DAHe2S6Jx0X=>gV z{Gv!Yz4o7@JJ9uya_<3Kb*zO@-@qRd#F1A?45=$HzORcDSK%-T7Z6~5H&LI>;`wlI zC>1ngG5RCkEN6=Y^(=VyA4^88%?pRYFMe9;3TIk(YyQUKi;d}EXNnF{$hQ+Rx-K- z^hrk&eO9W!Z^-}$KiXU6Ib_Qp(fv?#;0KwVxn*`}9~W*su)&y&idXQKPOA#d39M+r z7@h*~VtX$0F!^c1KH$~~(sZw>ky!Mhy)=qhLy6qtgY-P^eGD&5QIv~XI(F3Xq`a4% zD(Iy#gFsOovTmF#l$Wr#kR}o+R`JwXq2j#*;zq{vW8fs-co$;%$uqA5P0db?4IkAn zSH66v$ukkfnZW5yoIUDkRL2Z^F8MT4#H@Pu)%M9p7=5UC+q8San=IDLpQjz72+?=& zbnsj+6?w>diDT6qZ($s*t$8zPFr|a3I#Sk1>Ey1K3D@<;KxtWo>BGo%r2|l4PCb>9wTM#Ss zK6d;8zNS}utMoI;qt%vL-=WMDpPjW&8NvQGF1+1$-4wD_Uw%-vBpqZyR@y33nE^Qd z2~I#@l-ZRnJ@9j0=sQF!Q@XxWls3KjhuXgAZD)8@?j;t+Meb72y$f$R7a0t{q5L5+`RUwC4NG8W6&^*<4_e@rawW;Wj_!X8H%zNlhcf2BLs`G z>l(lD59&|3Z1_GYNVFjR4=G`$vz>^Br-gphy->0EO~YA{3jz?G%`Sh6Jjf8blAepe z**L)ld96sDGehZwd5soA7NvuZ)avOBxW?V(!roXj7PoPiTwRRHxg{PkQw$nw9FZHC zkkzhyn)(sFoh}UHn#@b{Vdk7-L`dAJcf=0`aqzCl`VSsg){8uAB}|dR3daTUpoNl* z;cnIovN%%ciJFz8V2zV-&k>J)ow_*HvoPy;|q=ocTO z>`6+LBvpz}Cg|U5(P}D_=Que3rhP=^Y;L|-j1D>AD$Uqz%}C1CRWBulq35IdpYH!f zueiuk*Y-!PEdJ7WYgrll2!VZq^tMm&AdFBx0d6Imt7vCticW!Pl?$Vz!Avt^81L6j zm0F3Syf~$F(BSgwQSRJ|{GJ)x7pQ6RGO?Sv;GGr$7?<^s{%^^{{{qrBur3}W zkA0bg?l;UBD(sPy-`8=0n6)I1&q8D>66Yj!k?(dcL;=2>2O+p(_#`7kK$<5fS5v0a znMZ7nA8-up5rY=v-F9g^ny+i7EAnLigS`a!fdk8RiM*xzL0alwtg{LuM#(Fvk_u}I z_ZrYX-liqRWAXXz_9?<0cIeLk$C>{RdH((B34^|ay%;19D)|S=0d-|lg;!MH7w?@J7`miETHuRxcS$QDAfO=K4bnXb(jg$-sWeDSceiwh(%t>8zxCc< z@z$(0!&=kH&fS0CfnqM{P;a@y&?DM3pX!T+bv|wWb0_7#viqaBZR*Ysx1+b+ov2|^~lGf!J zOGYRILeRe2^esDHpanP<@ouZuvs=UEEioi9~%zV{=S=eg{mSC zzKTOxGBmtA&NUYKZ5R8M+wius1V2z}yJdzC%fvsXM7V`)D`7QHh!)*Ha7ZqUD22RZ zL`L8SeIb3haWEF6y|aw83zMY+T4AfRBdiKG$j1y2X40nExx!x;NfTGLjP%k)F;IyR zGjzXaR%75R|lry5|ks+zV)EG23|nt`Sa$^)EA_6WA?NH(in=6d`51^@8k=K4!`q z;`@xNPLrZ1O5n9VNU~C={^fU5>RcB;pqn}Z7@Kxg?f^R6#*$!JCYcGWTe`lpX=Dpb zXfdcDSxeR?hTlS~yD{vy#cKzL z=>!VlsNOQ+hq$kQkJi6eMj8y6tMAXn;z<$#foWGUTBZ_{j&C+2o{#g!e{Jj1 zBXIRV7PNRL;9A2WeUr?4sodg%aWZV%PP{nt+$O(-o zM%^|RrixM1go;Sozh&>k45ZZn%_LUux^%1^sU;180jaJ_kCwO0gr5JHcIWc8nPUJ2 z8h;Lookysr;gF}ctBpH?^wkoiqFKv1$vvRDVSiA&ZT#;kf`3mr^)AQ=4&9;y=C3qo z@m(#UX4c!{LD8WlKS7ZrX^;`}WNUR87=+udW`QYk)4_mFiH5Ewa$fI z*I1a8=299Rh;it!NAW{GxF?n1oH!FMf_P&e_X0kcJ-vCqdrNLM_V;IdXQ)&wr(8dK zajkW>e@Lyfeae3$K#;{|;0#%N3&`y+Yrj_-!^Y}yidsJ|?fvFw1d{e;p-?HKmHaZZ zSt}>9p~(>`d+RJ|4?pIUu*W~sQM@hH{MO7cWtN4({q0P-RjS4{8tE)Xao5d2jI2!+ z;Ii4;3eZM;N38pyQ>ouX9+(6IpcSA|Hx*bjtfPFy{+miNJL4)wSpz2vXEDnl!%|h*lMeu*Y$xb0(VXOZskLMr|!({i2@h1;mJz@!X9Xw!Q)2J(D zBg@O8GFx?6Xh@Q66GAZa{&zjp3JGq0mR6rH3`X18?q8scAu z?Z%#JDU27>0t%$Lc&fRU4T(I#B(oGI4yXnGGwy*7H?5AOox0gFenaob&qgdnMZ2IP zV`3uZ;!3<(@JN;za@vS+Jk`bH1C7?3_Y#Ek&ThejE~FfV@ga+Ycr|~BXOu<{p=8xC zgn!iRVEN;VT`w|{SVl73e{9%Qd6 zD6lgi<*+`9^zg%F`a$5t(s1#ei>5XX<9Zw<@N3fsikNDOY_QY7az%QGJPsI2M*nGP zD`lvaV-5OZ%k5CdXo&jwe0ZwzX5%bjzdHTvY!&%3_F*ciTM*}(2^0PYb&plWjZ@36zBfo(Vo&y)QlCR@}phWe=P9WdoLl8hbH{ z0;#S_41^Ww>U1_xhb4+7VsSJm=;nD0ph_4!?Eknpxb)-#%9ZJF=<0#j?IKhCYEP(c ztP7e)`XPC^dT|TOk)|2YzfO<=snfK(zk7hbyH?#PyA30)n##|6ROx38rW)G>v>8aN z04M|!>0L;7a$$Sz;EG(r;UL_|KvO6pV4_`={zD( z2+lR$hJy{>4{@m8DF+|P3>X6nICq%bD0K8Jg-O?1b?x(P#F4Y!B;i%3t2C38HzfPN zIXtXAwq7U<`Av?>_x-fG@GWWokO}xQ<2>waeJPyoUkrI}E1cQ<9;;XMVZ`|5avf+0%3uIuXt301-|6B(4&$tvxkx6tG-eC&j@fdL+Eqw|GaGrtxIo;eZTM>V@ zn)E?R8-t#J6V1cGyQm+tR>Gt)+IF5*M-tulyjOe;858LpV9JeoJvxsJI{eEqaTaXOA*A>dGd5#^<=YDeT2|JL^hID+t;<*=9k} z=WJ?EN(r5XhI4dLc+@syB_qG&rtxvHK-K&p z4*&%@7UDRBXW4wO(!p#~SM(mt^pb9=Nso}%Hw+3Z-M{lt+CUhNM@7NcV$3dD_P z1`G<*7mSKh+z`tPNA&qg}QV<8@n|FEtM=SEdEy4!aKPcu+tc zw|qI$jRgsn6!{+;X4damwrHLO9^g0218Wm+4a}|G(D8H)1mlk+ybnZ0X-E6KA84l* z>vJ4X@hOA<@WMklZgQiAqnHG1x@wYxD^$=CJpBKHUumt4A7#YK!f>FO6=Caj3niic z+^`htQDEZb@^_)H7mPq+`yqAv4r^`EWT%Yj6p<5df&%1gkDa@4J-gM83&N)COADSf z|6*K~dsz4LXUjE%QYb=s!Z8!+*&1^2qt3i}++5x^TSztXl#68c0boyKfPrrh`E6$L zAS$@aOnfq`O{r6!Pk9$w+L4 z-PSuh;J;b+B;MmIAW%G9kRIH3uIZpl2?ahIXi_a9Iw(CTX>=!?tCuAFa^(_$f=l7)<9ps2Zl@Jo+POV8p zjOkRS^TC~qGXoe~Rzs)ynhWpW)ZUQ;rFU8iyYECBfdVKxuMJ5hGd)zww_90jjv7-F zE|0d0=KZP3P*(+jV7eVYuXC|B!mj74=uSt6q#w+Mb4^?)Cmtzu87~PC?R@j*s~Gw3 zTD?9Ei2WRIsi5mud6mOi_Ths=^#l)&gdE0jqrtDnKd!&%KE5NW8*8x-`%Wm_Dt5jA zO}wC{#e|Mg6h7z}1I&=2cdtjAzRNksH+7G@8wSmPn7Sf^ub@$P9$ki3B+MQ7>6eFH zJ=p2Y#tbmhpU};)Vq64HI2mTA#-Lh%pA_#b9KqT`f zq$0t8XFh-H@Tl26^uRlI_+k*_EQ=$OG@X^Fb=jri3Fbrl^mDBCIGF&jTn&nEe_lET z1rAtv#24sYmSLiUEmdQ+TXrSr6l+{hkkv`xe!^7@E%=zm+H-{Fs=?WFfAZr@Q~ZqN z6kHD&Te<2XU9kv8S|DCi?LEp5(B(bDc2LqXq=(agI|7YVb~mmED%3rpclf*#D2{BMsN z`ku0ybIJ^=k;Ox{RH!D3#!tP--Z|ATzoBKTMH2u_f5Y-0gw1}uM>@Vz_76Ss$}-;! zdguPImr@sH|7CY~5Lkr1vR=5!!!sLWVQ4vM*ZX5Xugrk=<&BiTZRsK!pu6idNkvV> zUN5dsIhXeg6tHhLzxk?P?L$7`c|BY5CXo4YqWE5wYT;ZuDZ7@Hy~*Gzwl5uAa1RgX zldieR;-UP`)sZ{%!hMgY zB}PXr(D4IXPc~n!DLp3Jvf{+61LR}Hw$b*MbG1~x@X=Vm@J&X>CV~o6nKjt))%p`V z+>`OH`zgiLMSwwlt;cG8H*=B;1fY`JIooL* z6{PJ~z>$b;wED2t4NzW{N_G^zN2nyt(w&yt+mi9Qf~MlH?U~j$lcSKNim`fVJ963h z{rn?oRk~tMy^-ZpR8-h7{7&F8csmRwT1TY4?lRC(4R}%M_y$Av**NpDJ?Wf$gmn=8 znYHf>3-kVWH1&3YK3BG!56gz##8F3tAMAu>cE=UD6Y{3@#UCq zxv<5@NmVx%PL}2<0=LfAbyLMa)Ky`BX_%n9A60T66DdeEuH3=FJm69 zCvxNNo45_QW>7lDT+^nhceH7L-vmVbv2`nI0Xq*?`t$g*@>&+Ljx8%E0{6eJ@teO5 z5kEP;xrix;C)5XD%XS>RP55|Y*4OiM2JIgll{J}eBH{k#K9*k{77J{AhZ1W8 zIVWIWrOULn9q92RuktxNm~HEd|D{$({dsN+H}?KqfUP<`ykcFd{`%p%>C|stxvTbH z@M6dQARaGiemzdf&F1w(<#ZH1g$)cO&$QU~QZfE=0m))K{Jl$KyLq8fu}8^INv021 zgR8U7zzw)UO54S3)TA<&TzNlTdQG`m3tq>|n5e$x6YXN9H=KNBZYOOhZoI?1f3 z@V3MxqqQTjvI@mq@Ssiwg`;n;Hso!?(i~-Mf6B8|OsJwms&>5_=YL&*$g*bR4?!kU z_D3x?rhDDRP%RFJB%EF*ygF@oi}~tGW1R}=Y`s^pGUC3(F2z0RmF9WTpD(`6e5qd< zu#?rR=qese7~XmM4v~7W8nYgq+aF1*%i~Bu8=JVCp-iIW%TK}&!UgXh^S~{Y*J708 z#!HMwOB9TDCgF)Ea$GU!1|8flPu>SfjihDRf$Y=qcZ9r|JBzT zc`wIe`SOUCaq$aBMbOEsvO~9wXNn72PjYSb@me(AO7&c^75@ZTZoIs6F(WKC7^9)& zuc0}Fdy1sAZgWR3;3;K&7>dr8s zj4e+Fk;Gi`7U^@fsHN?KW-5`WMYM>T(Q}Ub>kOPM?Ge#6$&VL9@d!6++jGMCeIa|Km;kaEtNb7TQpMftP@jPW(A^3u<9$Y{*SFRVE~sTwPN_!pn>srA zr0GB{NyuvasU8Ze(?kB-DQaic(Y1KWEMnJ*pUBSSo#D@IyETrgk9S6gy&m`AnQ`=e zFXzU#h{A;|pa&uV)-jQ?vq+J?_-6wv)u3j;cQCn+7@J zYd#BsX2w79KAVQ*8TFqOeuNh<5h~8ExU#wYFwN;DoLWpdt=l zZ_1$bcekD}5J|7x-VVafNb95ASdC2XR-8t|=WrSHYV)X9290C3jXntXa2(fa^^!;s zn{=DDQ-|lFw8f4S`gq~(&>tTjx_*cLVcfeL&SefzdA9v(!Q-BwRSq@n+hKTr(x;sX z18hsrm}hQiTlRg~V$tVpPbT(MB+VkuSw)c)ir6)G{eB@E3y2Nhn0+Vlk1f^599m~D zfl^Vc4&=3ZIVZYekudgMu%m#STl9&G%SuPHeR8V)x{$K?7KAx7I6%k?sRN zD5ZaEt;alGuIQykMI*=KuOC~hyvgEd=n>e3XaO|^&W_&YVO-Xo|SQ)d*S(dW{U{|Jxt30Bu-?cvcoIDE^ zv;4h1@z&bp7c=C3I+uaZs$NbcL;ogH?xdTtk6I$Lq0CEM#5<#>WJGZ~{E6_*otw$n zLG9TrEB2~$!}XoHk>q)4efUS*gt?J!#-+CNp~v)@^5^Q~{4=gxE_1wU`sr9ldK-&c z?J+AUqj|vbIDL#ki2r00dGnB;FH60^`j%KSfs?Tt_t?lKsssEZN4hAJ`039pFIHch z*M=rbQDlrrt2ap3l-EBY0N45A0S}Mfx8Y%!Ci z{SOx9uo0Bi{!~oN&8V{fM5e-L*Zmao{{&S2K}P8!5JMW94!>5c_x|K-l0#hiz`>BR zGWEriJOM)ut}86~K110pU14oq-hl^CN!oL%qMrJGW#cC1yDj5SMq)XPa+(UQvm3vJ z()CvI%sP|rpbqVJ&I5ij6gZHJ{JMrc-}y%D@jG?)(IML|_~MARm74gei>HBF2yKWM zf_9ZrkFm-PPyGC2vz43(>P|IG&as6!nF-JRg2qQW%|_m`D?lmnMQ#nJaN6IIvYSYp zRurIYbaz}c+&m@&NGStXcDUU9a;R_pQ7U~nf64CLGn}YMJMN zi7#%%jRF-LZyU_3cx!$bqPWxWsdrgS7_3zqg(wB0Lc>C?G41A=ol_(jMTuDann^9a z)+a0;*Dx=Lb~7;xnQFI_&i0sOOjkT`b@myM5xs0taDB+OiOOeueNjR2+bqUPpHGg} z$E33q_g7c}UP@@P;&n*@7FprB$o$ayg{vSm?$F~t|Av?wrSwX>cRVC7eiQ>@c#*Zb!wbKhK>ofYyphPwRDUkGfp_ao!kYtbt@sR8a5?_SKNh9tD5ZZz3+YN7mP zyhWmQX~?53hgF>kHZ=rTpql027i=buR{d_V`1~fPU7ULxu1{vn5}aerxRscgitb^_ z(~48&i9qv+yslEImzl0q_hg&3d5MGHGeViBF=@7tDt(=fvQ|v$T~DyJ9K#7v(&nJ7 z4tI+eWV0*&4HpM0up>ScaUm4a*+%C-&p_w6J;#<$acR19*!bpnuBHNN&GqBz8Ncj z{DR>&SZ?ho^LfO_Jyje1h@yf1tX<7aU~x_jeUHFt#w4AWbLrof-i7pJ4!}C6zV}4- znJ{h-%ctym3=L;S$7mO!kZAN2#{{yn;{yIvi7FMLv4s+e47Yhu21ZqV`lS>@C?HA$ z+7BxCH-=y7q#Ui|^CZp_%GYPGB0Co@Znw+7B)kTbB&@_wn}lpH!)Lmn-?=t)J69q{ z*9Vx6H`Z~0xU<1Q-UgYW>jKFee<})LuZm{9s^%Z#^$DcpqSvKxT({2JD|QV9$f|#N zPWwv0_;C2FV*RdtdswMn*ClcE&uOmX({CMp*0uA*W+J-pYWpELFaxW+Yhs~8q^A_E z%b09;tW#;=j$E%9CRjyHzZ6mnGll>r)Wayr&5LVh`;P=Rq64<>-{-D*_Ae@ z-bZgGHcPEmjUgJaQN7Rpbwf^QN@WHA`zr!9h(yVbH@ou0SuYSJg&EAsjCZKF?{|xw z)|3?9#rX`bTu|4R%gSKb8~hd5v3d+;M5mqhqzdF`3qvZ0Zu=KJigJf#ve+!HtAM#s z`~h5;!@ zk`^=cOBWz$SE3>5wRr%?e?xEeWdiIu``q0K1E}18QhP@3)bmAzAwBJMb6O#^84sU7 zx~tB8Cz>bHGa8d2M5Z^mZ$a~Wn7~Jl0a>&=SpIjc5hNC$JPM3aNnGlQ6+!gm^D^x6 zjmr6)V++B5SMaT}!Nzh&aJa;FKjI2D6*@h0-^n@s{DB&ilOmCHROADR;EMC2Geb0h zsjZ{x9wtXzi$&U|OWAd?Btz)2#HxD_=`d-y{{A0ya-;;K-D3aj4l6Cj!&FMlU6sf0 zSi9FJF*y^31JkSZ8is&cP;-m74r6({s=jbnmYPQ;j_=O4v}vY#ac^eJGh0r>`SACp z4`Ira{9ixZwUQ^DkM2Kq>UR{;0dd>-AaJFrL94Udk8bcmf->V`U zc!-V*?K^=Cdp>bnfGM4V+oMw2&v)n+v-rZ*sgXlm~3Opm7{n+bf)TkcETT>=L%*Mv@yRC-On+nH;;_AfH zEpTV<)(G$G*9`z@o7$y>^CgM-vYpJX4=n!b(f_yOXamenO`KccWhfq8y|onk{ne!x zv<2UvIaXI6k9;#Ycfy_FO)4`PXcHouHpC5KY-{At_w`#_?-0sd>#gxbz8J!2lTgY1 zKzi=1I>!K9rAEDx2=-a`uTtz)l2ZeCCJ$8$zmwo7e!P8WXi|AnU&|2^ieX#*d}h}Y z;{bi|Q5!cMy@1r~)?V@6Q`he<__SjUh&p*uNrk)e(|Wx^)a%y|8_}fYGRD^(L;&!% zk=qN3uop!YEh{r-B$Gu)m`$|BV%HBgdZz!U^XkmaF9d!!6Ay>|x#$wuPJ=gnFc|zTc~%P}Icl#IKK(53 zA4wke4Lsi9j$p&8fnST@j9_9s&Cl#V%d2Jj`?Xc;aVfRuqrU6g7kS@r6Z{~NseYHu z?yY4PJR`bE3#1WH@s`XIM@iiq{Bxa)ZU-aMV4lnR>q4QbOH~fO6fxI4$K=t**iU(Y zMUYjmD7l2rq$Aq5^Wukx=Xj?F)g^!DEVW*Q>0d^Qpc~BjJyJ=#u(9q5_~RY#EXrrS zU82fz+YMi9J23i>LPcc7e2xgg03s|x?QLMT zftQZx#2iN^2OYl}uG@te1gii3nn_?hzdRB49QzXAaB!9InuSPT=SfiN;iscqm!JRq z&*hte6(%rh@^PXbx6PWtxBz1ZVu7AGVl11m_JvJs?osKx-|R9kJIC1?9}xgW zd9?IekvTOiIP!kTcy@GA=KfAY8P3hM7uBwCCMMD#vV6^n1kY^bsH-~gc_Rz@2?RfL zPjdEz{(Hjjxv9jGkiMR08N9k<+xdoHfDrMs%9`EuJt3c_x+ju_@t+jCsgYmf%o7G% zFTz`pJ6?4fQv5-RxO|z1!32=)G|99-2bM5{msY1uo`xNU0<+s&e~#^Y{hJ5-;wtmg z_}%q1k)e|Am)0n!X1aU9oq~n}6j9V$l)fQEBm=SpU1iQ0W^>2shVAJgX_Xp*%gJie zRNcd1qAosDuc>*I{)+nWj}m5L*WdQL;gF8y86J5F72a_CxY>53N|R zuNnFF-vkWAoCLc#G@U8Pm?6T6_vD@+byBxNZF*g38yLZseSX@4-B^hvW>L7 z9M|j_{IA~M*sPrL?>(Y^+KcY&*8l(m{tc!iU;3|(^RsgSRS`XkU`L-B@ zT_drW6U7rT0BtMD2Zs?ZZT`D`2|H`LkAga@>k6jnlUeot@Y8%{^w>3KclUP=g3)T8 z)pg9d9{FT(_8os%4d1mF8ou<<^6pN#x_H{f5!mm4mPZ?D zQOb^DTui*8VkGjQxN<5qQ#j@j>>s15&+=y-mMeUSKapf}U;ixq&zao|UT~#-{UgsI zSzpGp0vXjVrdp|RPxQ6~uby#V{+dt4#X9-e8g@lW@`cK7HZpq($^7F3mGxMj%#;ql z^XXZQjB&;eaUo#fT!szD5eG%fOCsgO1KsLvpiyVH)kkKLoin}bsAL^5A^@t?Usj56 zeUr5bJQQGw+^(anQ$tDtnw`TW$6zQg_%$!u%Hxd09Y%(GHf+)oI+oQ+_IObDb}m`U zdpR?nw$gyQMwiFC7G!AtAKvr(t)?eG#89K;si+Fd<70>2<=V6^yXgbEnZee1yEKWZUw z6BHdsuZS#XBONcTZJYrP7$xx#J)7ckj;RWE||5t z$jFh&?u+W^MX@_o8`>5N`lVTi44@2Dd>7o-MTkM%X#z5RTYluUyX~xT{Fa0GVnzg= zr*&H`mLOwArdObjav3KTI$&1YR{Gu10!~WZiW+Lb?~SyE-a4aY&OQ@Gqb3p4>w&*6 zj0|Ygiqd=uQ0Ed6PuxO#tJZBUU=Ub6@BY&2@bQt}a!F0FOM5aXW&gU7-s6ocAN{4= z7i29dCp{^#!M62Se~efwqBc=S0S>y6lSqzwOzr@=SLg^&1;|^Rk=bP`>fGF?ZVyw0XvC$3NNW)JXe?{zI zn(!P8>m8U)td!E*kgd^oZXrg@*5I{to(g-zt0|6hp3v0DO3dGlM`rENp)mZFoWY2ris$s#C^U%p*fM)TAcTdNv7PYs$qQ=mV5GPGb^_52PMxHDF zd6%-Oh%gE#hNGjKgj1@`_QiAvqLvH+hp4fTS$U}gZX#P@30P{jp^6_El z-P7sMoCYqEZJhPTE(yjcdr@OU2g0OQI<*z96C#8VbP+Ft%`O55#lVVkh}~Fd56+$a zY-SQ1`Y~t0WP@@nzEe!}00%*5>6n1a)kWmq17g@OQp_E*?b%lWC$Z>we(n2D8|Ji| z)ZG|}D-3i>;QIA~M_wTxaQhh*bNNBKJuaA4v5WY7M|CS!`|2DLmnMK)PlZ@e>V89i zO4LUA`$t3z=6}Yys-(;r6ctU&^b+dv7`HQ@%zN$Ju1qRd+o2st>q5Jk7od7-$8KquMu;H1MOZ&OP{JKNA365)oN*A@@OZ z?)~)B9S_97%&W<}qDyP9_ZBlw!fuh5E8uBNhq}ICl+&mFLTf8q#&v$93}dd0pdBYq ztFO>i^Ptf_%l<4oc=&bX30C@~^<`@&Q@_s7IR9vyMG3q@yq5jE6%xM+U#FL;i3_e; zhFxberuwv4oRcyZ4(AsC_#eup0dV>hwpx_KF>xL(soY5@I8_F*p1&26>sx@%4$XqU7IJ! zapQ$c>iz_PxKzY4ywlcxp{(cij9ZRl>%ujIqU#QC(E1sClydR+jut+Sdcgrfpd+uM zfXh6h&k5|_5@1#}e@8kr<|T4_96K z=3vyVzkAhzj>cSsoeqG>yhG(fxGv6RZ5(e<-xE~@PPh7kn=7j9`pM4CBW>8k!;`T# zYqd+%REoM;(Z}iY$_MKN8(F@dM7DPp^N4L|NT$1Q-%nP>_qNCGZd4laacft;FStM| z5q+EItw0>OrdI>$_T`Sx-3FRIKTgl{|5ufu1^JqhzgZ_RBD59n|8FYhg=);49W&exsiM!7GN->5 zB2sHDQ@v6g@UpcMoDZ2ClkJ6YG%$>w{3DaOYK#fLJHDYAtR;y^Jb$gs>WG$P$OC+$7}n?)gZf1Neg#N{j-#m^A1pZ8 zb2-nyI=w5Gt*ob5Gqd64gTzZkRJBju-Fv|6Qt{4&((HwWA`x`@ z=8xm8r`+%s?u#S9wmOl2hyEdSjG$S`1-X;+!di<3xhLn*`P~46PWpmZ$<2N4%bea8Z*Nm=cJyhJA$#(>D+Guv19F=k`oOl0W0@K0 zY!%ACEGLGJo;5cA>Lw5xvQ0Hr8w)82kPQx3h?<3s9%0!h+e8lWENPJY)sBPMVmT68CpI6ZmoH%k!-rg{-n43ID=^->IsY_QO3-!mP zIkq)8_b@f+F4vSloYtQsvv>YfSO>!VGfL$a0s&8*$$3k!)v|}$M z?cuB`<)AZ71=Tseh5me@nm<`r&W$d@Ks6768WHlm5_rAL0C%*v94p9c=3U7DA#PS#dsN0BB$+D(7L5AxAlC@g1D3_Mq_d^q4`u;^`(sxlA2X8 ztuDTl&D|a>!RJ6jIL8;<^3^krGQWpOIo)teF~I)EhjA3mB?zBiL-&<`K1Ky4JuEjf zQ-ogt))tzeF1VK0(7vZ9bFMe&;cnz6c^rQr5@Ub=kcSySKjyc0HP2iw;_O3_-m4QAMgam@ojt8P4Lha3ACv1xjatuEMZ9n>8RA)`2gY=PvtN?MxGu;UBb95f7{TpPMR=tgP@b{dK2FdBWzfe&DIHiV1}bW zKI14hskiitKnt?RiT5v70TA8~^Ta@iIi<~MJlA^L_s2ttU5AgNLH-mBPogP}ma7(8 z-76xF${d>q%NR7f9eLB__qK0vz^VaZCNySHpHy{#aDi{+$NOQH-bbKa&5ao2T$*>> z<8Ki~)TD>t?!30tiuJng8nso;QFs^2w%1dFOA?O zR{rm%+gC4OC>$P6^vEC&0ER;11w$w#7}*y51ZTENmpE%zkY?{%IoX!J3q6kX@t?yElop#aPoU8}7N^W7`83Vbdimv^#J&a5=rVDb=8rX%!m^ARH{yAA7a0cNFTI;I zTOsWHyZL8cSvKSyzx`O#0Z^M9CRdYo34=_6?Am;LWD~gjw%i^Ii%eEO4;#4f!5jnV z{F-N@7=DDUIei|@;NfRKQ2FnbdnoB%#Dl9s4r$X!jJ`23v)K2eiES5lO=o^k~IWQkaSq| zMg$ivc+*EC2ObA5Td@#SVM43Z5iSv$1-GAT+PhzNv8{1b7hl{M+%CK7`_bJJMbm4J z<5dNyo0v+{M9fQin2>?};*)-7)6xocN;N?yQI2 zSQwn50TOT^ZyH%bXgy201{Q3zef31F=h#W_x^TCu}h@`)sY$BjB%XdB2UL2#H3m{Z4Abg+b}dpkZ_| zVI$9@D9YzAY1GggJ{s>{^_IQ9gHnUaN!5O*PHlIH`kO5L#cquOXa|ffWMuE-2=(P7 zC~MCw;YO>^Df&pnOf)%~a!psqL8}JuK&DuCo!|471snpt`SH6yc;XJyq$fF5SvjZi z8Z()cj+(@Nb_^iQOG84jmds<6?>GTVUEp1vLz=|LRP@Ai#sibHc?Hu{_$Zrzf6|-e zu_;%v;osP8Qmk%W>;i_$$3OYua=EqmRwelQST)7N5t)P1-+Pn246km!B+(>3nbhsA zd{vNS?g|=0XQMB8)-C)fWPJU4h0^Dl^)l~CLFoH`h(-h7G#O9~8{o#F@D%PBr?xCo zSFq!ZU6`0iEZNkFKxFe@Snjt zTG_y(Nngn-Nkw+?r-ukpv#GF2UtBwF(C=!Z+U0&tzzq7EB;!}=u`kRGY94xTW%)qN zWbd14sy=H4zLrAs;-uhWzSh|;Wo~yyf!)1mtBf07DTTqJ(>R0$?z*(3dI~gF$d?}g zFxJWr?Rn$tg0!Q^IU1fo(&LNxw0o|#m;?xp8CN!Vjp=IkKE;V!(9i&tg>Z5vxe#|! zXPerKrw!t9MWnR1ailst-m-m@$gUxMv)np;N;;mzKutt_yPCj3t72_D804*+Z8uOY znhfr0`;J`&xQEF_=ma*dXVCo&uPb=_zkcutwlKgZxnpQMiD9MT6XGjdXZM;j&i_zV z=&j%hOf&)rKF@LGyDf##tj*uNHBLb?qi~1vgO3q(m`>2!B^vI}YyWa}>7RCLvy11{ zdxhbdhUde}l#NraWLWLs+_W16x9=^dQ#bp5`qyq1Jok}`ZLAbWdYI!LzpPoEX3BAI zDeIMw)7a2GZKqtBq;e94NV>lP^f|`dvroBu%1QXUP#j!l)0Zlxc{6;V2c)D>{Cz1Q z8&vteBc!1-7bk!dH^Sa#Yu%p?t=i%^V)g97Cqp`fMC)!YLtcnAYr|MHRB?WGp7j)! zdKd$tsT|bb@0Eu_4Qb{S(I!GHz#{T%olc?A`TDgM4&bjk`)4O`1*Wu0NA z5@2rq-avQN`Z4F#!X2}Yn$4ZPRZT5<#708TClgj=!~s$XEeDAXrq4z`3bp?*Dc5(i z<>PHagL_DXZS80jrLE|<0{Y(3zSg7bhFe>_-ov4cw@1su1 z3{r@JMjJi?M&=ks8@$FL3QC`_4-{C`I|cK$S%80;%t6!@O0(Xv*T#700$DVZ8Ud-_ z8|i=uQK&(J9AK#ra8%5_lT6l7uZmu|3GYee&$*9Fnf9l}wKHg2Or=JvhHMXYu~UiD z2^hCUtgvz*~ez5psl`LfW+4mBj)t<&XIWPPsmgxW5f*kkgS~ArFtw z1J4QHgGW1CPKFp&%Ot7|pSg7|0S*YF-%=J|;f>j16_eLP?1uSYim5aS%qv!Ph0x!! z1{)a8Ok7&vg2vyKn8aTqBjBW7ND^J3+R=u|dF=0+*2bBuHAQ(V23%(fwaD9=lctLN z%TbOjcNIUgzQs2+Xgu(VVO^`+P0=J}Hb{cIam44I<^ z1bpL$J{hckBy9Ha0Dlo(v^VbMB|lii{G`wIf*XX}{?wkGVCIq&Xiz?R9*@Xg!8t^C zRW)g4zG_B$5X{e}A`hVTkpK&5v$A;+kw~UFFs1HiuFaf3*B@>=nO`Xi;K+}+zly+w z+b#b#T8W35|8cS3&K#z~`+#W>6`UMu(&O&3-1ljt+sQ)v2QPxCV7)=PAOcd6j<7AM z9QjyId?&=%cue9?j2SIP?v$2Gl4RAWHPw0WpX*ete>wlX5@cpPk?U9&y2++vWV@&P z$MYw)M3(8|?3o|DXbUT)ZYf+OPGO!HAoDCOe2)oEI-|cN*RJ(#57}8kv|Nyz`}-r4GMmsbs> z_*zh(43kk5V6s^Y*;ag4x7`yV2dM|)f^wg}lPG;I(7d{TD<W0g&k6I!)81Eg)zvg%?t>E?g1aXW+#P}k3liK3 z?(P-{t|7p|C0K&HySoJs?(S~0-*2t?1#>$$=LS}Hb#?VqPnYxt53b6;Z>sn<`n>F3 zgnUi(K}j^C$;J24lj=B1q-S1d3(Mnrw^lC$GT#gC*yKG1y;iCg=AoVAz3(S%Uj`x= zJPwl!E@po>N_hOr_l2;U+zpZT%voz*DlMbYO%0JEsoOTnKM z%cEQzY9;Qi?sZl*Q^gZ|y;3Kn0}6$InFPnJic{GD#FhYTCX-p!p>ILi!9Y?Q?n&$1 zdPGee(fuVV;CHu5H2 z743s3Mu)fX_HOup9R{~rhbxGmPqSrI2|Q|CB(B)o7v<;iw<}%4csS-NXWb zC~O}B4EDY_XzzphHeV$ZSNaF3*FFI{HoGQ7u)o^I7wfNHmzpo{iiQsT*Rn20ZrD zG;AIh!|(+B?WOS9c@ol*G8jcxOTGQ^`ldUSX`hwy>u%-DQC;o246cDUQJt|UA^%uT zA7YvoyN1}GcrS)>H5%nK(A6en}KLi6;+0E}Q_)qmI z!e+9P-Lsp2x){DrXXd|EFB|KN;i$Nt*e0b!0|I2`t7i=xm_dfc`I)bdUCryu8nTn* z7}XkI;UU|AWJdyi>zY4YdXkbz7>!uTsBihu_3JA*z36`S)G=^F_-2mgbX!mh_w&=p z66{cYyAUfYLFxt4-QRsLd84(JHkSJ=LLS97rC=^-QFWum1zgAN7WoGN6B<6pG4h0w zQ_+c0EW;Ct$mA={f-XPO%oSnacA?L93u%ShdZ}hRQNVR zNB7hLxiwW-WuN8;C<Oc$NQlM6UdeU=H;kj=;jr$Kr(FQEUepCo6=Wk(Xcnu#8fp z08LS%fNp}XB@jakOF%*`Nx#|!Bp9aMxQ=9^@BOs`QV=fPxo{|M;gH0g!$|v5+pJ?GvWc%%gsQg1_saq{Cn#-4c}nHnKl8~Kd{LeqrZeHAD>kmK z8x#7NtqTW@pnfQ|o%YFpn$AeUY=CK@Md`$@@Up zR6h>$x~w=ysgi|Ig%+~(wtWEwli1GOBspFsS@+oN*OonI#is);gG@;YhCl6ocp)0B5^RtI9QC>mr}4HDM}8F zNJP(r+#z#w3-)|K3t-(nE*_p`Atg9clF)#eKX4IZv)Y;BtUt_w*9#6KG7oO*Uh^Cp z%>iRDpAe^jCxYovgeHrUt_2+se{ zG?R5nJ-+=bvjgz>^v1(kC%TT+H#5q_Y1&)hTbE!gOc}@+2_d{cMizYGgk7k!Q~Hy> z!cj2X)83bnT1lHbsd}5)iKl7@x-ep9xNUidI|hiQ5wze&l>*#zr5d->lF7`Ar=t6^ zCWDD`+~3aSR$*2hV8{^TM%t@F?{*${5E0>b1@vS%zan)I8Xb?prwBY?;d+j1R&Y|H zbzniyDO1wA%N4TlA8NGO9c2G3*V_JmDZd{7=kBlyue~q)=InGULl}nWBYCE9JOMy@ zotBL8`kHe~`e2WEbBgh}d(V)=+mV{I7l!^5|EIsn_zDdc3?f!~lIoAkg5h5U-_B7A z<@oVn$j1Hn9WW?dZ4k8;3XQ$AmyOWS?Ad57`NJ4_=`asbOhb#pqS=)j#|y$^mNdf#sbD)Z&|( zivH&|(kQ=16d!l?O?#j!y=?#8;@{uDoiPF&74e;P-Cm!Pk$e0*Y$_nx?jyaQ$el1c zunw}+Ua3PGwdQJa^PX|dLR)^c6tvy9A&RdCb90=_5fOAnAr!=%vgz9)7?h=$G1f0` zXYFQfWSxr4^)0)R1tz1vd6C1pHC)4qjCD_EY3mmlU+SCMjH5vFj+IRmYEHarxoj=u zm>B%)2w(AcmQ)673Lr6BH$^EX)HV8Gh>g*${rmRYitY2|lTxT|PPE|@RsaV_CmRuK zLtKi;)#&ADxox!QLUz?Q+5zVta$mdQ>*)f{Z}IGQ0li=u;fNU)LkB^L1bt%$b$(Eh z?~uM;bWi2pKCS4X_Q_d!qFfN&XWl+9=j#;{_@MkSze@phM@PsXGH*wV75o#vCo`3x z>k~h51);?G#G>e@UHT}x7e-^|L|t2PXZLYl(w?ro>- z06y&9!Y^-G2As7}cBMhDdhTZ!J?#n?Q|L#108(guPlkoywd-vLP7_wU`R;A$fHG|t4{?v>JDgMNo6+;y3K*{Pqy zaRcJxve>C#MrFSd@{b=z|O_*po{f>x@CZyf13VH)(!1Q6VJc@OA*wu+jTL^8iloh*iQ{zLtP z`r-Yt4iPsl%d!lt1F9}5I!qWeq7aHefffSwWqC+F#Ds_Fnkq-w13pM=rgVO(;pERR z@Lg~K3rrDEF&D7~cjZ?6S#tdTg`+eq%LPf-PvOihAj`Go?MEY9!ayu(>68-)z&;|m zr2(EM5q6w4HZ|1J`RPZ`Rd2W}Zmifq^_9jI=i$#uRo%*bw0xv4NRfb@IqOj}pl`ep z!1!^y^V`}%QJrzzExf&V={}6{31Wf4x2RFBiN(@;1^@6>@^?r!Y;QR{u^LQeBX#Ph zV+!OOrhcTuo&PcxzE@%p(Wq}SMgmLgV=b&#+Y&G{Ix^y&^| zA3``~@QU3lUz5WQHBMef~4V_ zgYO4_clSJ_g*@e{bDkl`$pQU2$Si3s-JvOEwch0Ndu+{0jHEd{ofh%)FPj2pf+Jk( zb;D)I|1{a&-Gx*hI>VJU&^44iEml<`qW`s3jA{<6Xbj>5W%~jKG7sg72Wx$ZA z*|lO7uD>3uCvWF`YRx>Io{V{pj#z#)C~ovameOm zyH^Z@Lc_X(ho;G2d0ZN)%Y!Dl16hqQGq3)BevEipd&cF_e2jcZSdY9(-bH*R*Uak7 zUZtpfgl}T|OA=K~`l`Od_-?s(Z9O0er_jPT+NANj3DhHQ(BqRe;fqM1_IZ91lEPK$ zAVO9cip|RRhW`M(S=k&N)||Ftd|RHud!e?|06qk+t+x8*;@d?K?tP#?_8~K{(=Or#@hZC!!j^l%+$agv!szSaDWqJ?clU>GYpg-`{@~Z%Px-oZk@8mhl zG{RTy8*1{t?5d5EcOvn}ry=9hJTe4!m3aNF5DAbf*uXkF`1h@`FLUFQ#uSh)y4i%b z60!iV#=re@E8T=Olh$ke`WkVQ$IAzp@R#=eAZvK8|=1A&kv5!)p$UGlYvulsA zPA-}0@Capwhqe$4QaT}bXzwx&SoZ`EqmlK04#r@v0(BM72~2wSZnZlZqblsA4Ruj^ zkwi^z%$I+WFgo>~17jH9ZPPm9I_l|`!zfnHq);>Y_lYm+Lj%_M5=g z9k0hgR+<=v`VQQ-yp7Zd+rG#uQA;Dsnfhy@x4@Hn$rASAImmOE84o^jC&DK-BVQpR zs?06jnqJ?dZ{c!NvkN?xLo+!DSu$i{F~5t)N*PrSN*QoOS9tO$z5@X_KZPvT#J?@# zo$v4c1poi+q}BY^ZpkFG-PbL=-8FRX!8|pp{iN#qOs0HOYTyvT?QKbP3&;M8pSoqV zSpuP^?JWh6&0J&h8Y3-^0F?ao&8$a*^$Y5+_`}~Vw0iO^^JNNJ0d!$6$i>KmnsQ2y*`d;dq`^7h?%rn^}9X+8eWJA-}Yja4sfBGRY^U1F^D>o+Zp z=WRk?gg*H+d;Pq_(`TJ~EIitE!g={l(8AE55rhD_<4-ycyHnDWYa&Xo7W5=-N=qfH z7hnl|oX=35A12KqHgXfIoD*(px)KOFpGxHflvTLm?*VioR4wV8F)$NGOJnbtNi6Rl zj^V!c@nDa0bpL@9P6%G?7&O$t#3y65ArX4ME=!liwy)TgkNZa~NehxxM z1i=)l3q6|XPlgcnBZ#4g>HD??o0hxdS9TXP%u5@8`?lt)qxA-C_M(1W`y75~EuMrD z<^)q2Z*~mu+CEb9oaQxlzD#mDns|x}r%%7OPvE>7&8fN0MzjsTjVK1Sbza09R{&i+mG~2#Z!EM)j=bwmi%w3eP6?T zVbn1Qvc)JnUaN)*s?f=Q6P1ErU|$=vBijWZ`A!MXNnrmVzv^?;^~YTv9bp^j3pT36 zqffhZXT9RI=?=KK9N5ILE;Y8-B3u76zPUTU1ZAR=ww%u71lWpl?e)c@<|34WHJ7c} z^#z$d#=N-+T8$S%0z( z_po!|n+`&wUq_(U*_v50*|yDxJRTor1%>3EG;k^~19~5a(Fg@aykl7Uo?%uPk;Y8d zHZ4|_I_b9a2Ok+VxMG*G^~|5THOCm`qm zybn;23Szf&v@%XPaei6`Y9fph^avy9dw*;`?RvRW7VSbjj2W06d)9J~2=LGEDMZd- z0$ka?Hd4)YsU%9}{TuTsndlkw=|XrsjDmBh8jKsUY8v#Y{muzg^1DngRZE#*FWUFG zN#ltq_ayf=4EAW+)$E^XzsjqW?E-%z*<#)l;y-O0sS38R(cHJW;g! z2#qkuxKhRFli`?cCvP>2`XmoRB|O?M9`ebdQCd1Ou$YJnR@fYW6jnO_a4m24w6RXm zC-GAoX~<`|1uc7@m_^&UtqS?|6esrQXNBdTAnjX@gh>#-*3`z^vMo!bRG}{;R*coP zXY}nWo>paKeNp3Ut)JD#U-0AN;3s%FN#DEj%V)3zHqMMXr&u7`hP*bhm*$U7X#Ik1 zj-SJhPhL&Q(X^_#7kS(nZ!*!lo*mW9AVfsN)XC)dKkX5Vs1EiV79Q)JF@ty(peH{U z*_%{|G2hJ=+9-a_q9^&$__|4hCYlbt@{dCN0+y6P`yD&A+G2oQG9vnk$jTmf(+NFb zBQs~5bt?4*Zxc_M<^89gTC-Vx7((B-H*kML_=%&+Ae8CxdKsqUqnRL~=y)57Cf9RJ2j$Wb)IA$O0ru!qaM^<9L49mdA0Wg_tBr2Bqe&)P8 zY-~BIHK(+O_Rj?;Ll&#;yO9Txonr>=LfFl`2jSi~TsCUR6eP>dzr}4+>XJI^gzdhR zH+^a=RMD;9Z87KA`;@?zf)$tcicS4mfnCv#UVv3G=P*yX{K-c-3ySm60_RvKYmdaAU(l77Y6JZ{n2OO^H z3kwtL($qeeU=>_vJ%Lvuz4dLON?M?Hfm+lPfG0k#O=O6c@Vx zR^U}X$05W7rDa|S$Bp zh{+2omb3^ThKYBLJz5UkSibLZj&Z{+B*I<#QC+dio2*3CP{4c_#h2Y@(vSG^8IG+| zwQ;)BlcRMPLSVsrB&NdLcs~wU0;q=SvD%ok7v2zpt(cc({sRM}2+TRB7PrZ@80Geq z`{trmxmpvr0HmZ?#b=qTeghhENq@N9=3P2%IC)`C{79LfoB)E4k2pn#=){>rb{gl4 zmFe<`gS3#1BE!L?EM1j#XI6v0A?|f59G1$eBstIXtB@<>iG~I&lg*R2uk~CTX=G{E zD5ulONO~qJDyL7>$B8G5GQ(rniwy8oGs^1#}HKSa7+hB_o3 z{t;`xsNFi8@8Amt_)|!ul4SmRUvcdn1O{rpyuVf=c)7+XAS6eBzhuK{#zcn}K$(S# z-U1`_*F8%5(O5lJ^9u(Dx`a;Xl+ULcM=qSDhwAq`CZG5heJLG+_Dob4SAR;32pv7? zU|ljd-yB1ao_q?zFU_{m;kxbb?kw_U{hoCITOaejx`wTe{ay zB*Go+Nsg}9G&P$c>pTSzY^^w-@d16Ig`CsWV90a?qnM*D?4|VWHOI*t{sZ`+^z6ik z4+pp3h$Tp&)7}kD-PX?>oqy-X(n-2E3#s@@kWUE|IU(?}(ZcA=7=yId7;<=LABI%f_Gs9qQBF21Hke|5+f76`Q3V+_LSHsx0=RI+LL46NfQIjERYJh zCH~PWO9quoK!+8XKsk!hGonZaY@aRYnqpvm?)^3qEl%P!@KyBV*Dt8{BBeTxS#HYh zX;{-!oo+_C_XMGPb}%G}-wC9IsR%O_9Fr2S7V)-CAwIRGvV@3=mM1I5){0p5mu5Hn zNCg5+NUd9r$)jajcLKy&Tq&dK|cmf5RuSOhzMcej{I*InzH#{}p_^A-GLA02u- zAl0*yr9|%+-}hv3yWSGxI|lsH|5MxH$j2K+MeI9YHi`GHjUBFltE^j~uC8 zRAGYfQU&l?u#A46k-?>egBWHcd7y+B>?T=56#Gu2S|ENrglT!OLu1mu6JP3aOesX& z2KG#s#8hj)Tge`&1@}0uJM1c)(hdIIOnX;spx8*m(n(E3L)S?ivw2*N`l7-K-1teUD%}JhB~Zz~R%}5rUsq-` zew*e&D&7QkULneT#1UGor5PWXs*bRby$YXnG4*)-KH2tMO({1E1I1I)!_&OkM>A!S zeB3$uaTEghVY#|&)Yt-H_dJSQruj*H@ftk^O@6bma5Y@yDF$~}AyrP7j=i_w1QQoI zff^lZW@3ANZ{NHQOHhnHTtm4Hb@8W{gfayPHE~qYFK-y5<13#Y%F)fyfjF$~rS32M z>2KTB6$H#f%0&V+nZHA;Oz+odbw~#uaekI1yQ1=$OO5eW>|A`7z|m@>0t>s*0(xa8 zpJ!3=FRsl=ADk2iB5Jo!InG?K#DBWj)^GSJi=edSX1kETM>WdsyP@2AZQYy8LPaTy z*=}H+CS}{wY_YTG&D7i|{8lIFsg=sQ65mdE$yj%^g*+f1(4S=R<8*2(wMR5$e=|G0 zu(XA;p=(9xk1x2q`9hu#zKHVW+E{5wO72X9#mS%J948F*-*)5Cht341naEc9IGp*? zwGoD0oC^dIaQgMeiOO4tx@V*~zYi$N@&IyZW}8&RmVv2QjsIH<5H(>B(Mf)JCP$!^ zgr$hF1$ToKxUHeM5{_xunrlY`=Y=$0lTH-XtlzGjoVwj?B9niX9T!vi}a9VUH7ueA^8lO4()3e1 z;MP+ioA9Se%fxuJ+DTIj3o9DVfpyl_qz6v0#Yt~Lp;9o6f$J==@}k zZ8rIDQ2xc7EY4nv%%22GroS5a8b8q|`A9$s4%uY9xX@nn16SN$&YlM&XLJSJIEdJRGiN=s zJ1MqJZ0i`vss>-(ML?;5TBIzjVZzL>Z{Emv8Mu{%7FZX5qm*b6Dl_FpX+jiId9DG-ZE{X%jE9{B>^hU$~8g)wK^x61$jo}$X_7ROEHGPMM5+$8VG_y zTr*C<_y3PIP#F#SJJL1lC@0pc%^whY6YN#NTmMi_r-*WhU4L+B*tw~oqr)C@Na4?= z8OQC34$@)!qA+Xzm1?sseR)xNMrQVDkGCVy`IeuoGR!@l!|n;`tql!l>%KV^vtWgL z{M>5Refu5(^BH?VnTq0L*}!Z5u}!M%Cn7)qOA}`qA@U@|JXcoF%xkHqSxD>|mu9TC-G#=-M-a_YxLk)IC)mSo5?6Oal#r#}{`!x* zDK9q0emJ84ZsVby=^>E+_s$?8@n$Khi8`(S+vbZ$M(mtYEdVZ zVsN<-+6LZu^82c3K4k3AZ`}P;#Q#jJJa8%C7Fv$bkORN|Y+!@-4C%<)SkSC|X$@(@ z7FDlV!knO%e>DF^^JCc}h@>k6zyZi1XCG$XtwlebxBH{9mWbPSbu;7x@ z9$oSdH*T8VKOhI&FK>po88I*6Y#}s>md~|ekqgIglinWy*2(zp1;m-bG@GQ+J~P5e zC4(@I-)bZ8ZI?%#kP8bSg6*tmD4Lu~s?;^{>ASkM)(=(s&8=1zm_7W%I@FfRKMo6Y zmtPi&hh0`JRB8x<6Ee!+J5s--j7~Xy%vaNBPkq6XP9VWWSkZUgTY0w*Qc#jAD+@!a z0F}tDW>LXkI+eFq9^7Sb%mv?^Ekb#|{`+Sd;%*jt{>-sCXq{X}>v?^kQ9K8|pYo`q zpOBj7TKH`(I~T<~{j3BFMWT5BqCWEEz`y)Cg}G_$OdsVi^9|qLH!?<^7d~kspb(Q_ zh$|#8XjV{ZgvnZT91Mm=-m{PY$1OsLx&vZ_c+lKObhB2^5|;9 z0$boRzqDOM@uo2rx@N{Z$CO#M{OqiszxR1W)+atBD^VQkZzrJ8iTxg#U%U0r(;pe>UkHoqDz-m&DpE>1boO+*6Wnovz{S5JHsG>(fOG?V;XWxS@Gv<)z_DsJGR$G zCmYj&efF3T;|Bb`;5es+HvPUy7lKuh>Em3`(+Sf8cUyU|?3-EBc@PK9X}lAE*0cC8 z=i&GD8Jp?khF{F8LpwA{42)euc1-mmp8Xvkeux%s2p{v0p0z>N2!U&~@3x2quWp$q z88enm4C7+OL7k@3MNv= z*epsLZc}qiPxljztKKTBa#-Vgg-Dyne!&XS4Hvo;h!*^#zBjW1zHTYNl@ySN^VCIZ;3t*wKNcTf4t#va)(!pRHLG9`a_6^|s*x zV^#0)hZ^tY#8wclWO1jCHgm7@>FqYQ?dBx_lzYu(>q~}E?p0KeQ^-nwM1HuPzq7_G8KDY zPCvAu`{k8uU|?igsg`B&nHRJ>Drjm16#bHXf5Vtu{Aezo)bkP?Ul9zdEAKzy-;~`N ztvhgWWa@HI-4%P(9qS&EcdRwC-tIvWa!9h1Q}&+aH!y@rvG2H3(VB5@i*>%kE+hLu zU>o7CLdA#Yq}CaMQ&x*g^%{@mru<@TOQiofb*|_!^XN9BJ-Gl3MWuCO#+|1b>Y+^c zrl`$*^?&7dTsKRe3VzcE>z!`gJCB{bMT*D1EU*nDRV4Di)MJqb)W&5cKovUgaK4sG zJpD}MgYMUHQNt+pSKAg^wzW~T0L39lfoQ-i=AHn8zjTK?DmqRP8*H?g1!_SOkOkji zs*I*yj9`J*VNIYZ-hqd3tg-f-YKieyLtK>(izvuNXsQS?f05nU9AS(mn^eVp6;hBp zLk+{juh$pul6f$0)_b~?96zLvfpK>f=V?He0-u7jf5(%|Lckp2B!k^~GvaHAdw zF2Aw|vDCB5X<{)y+Gw6ddvFUs_1+N${Pe-o=d}`-_}*MjtZigQXn_J+Z97&^f~}kk zw9-q{0cv}Xekiys0!=aR%DsfZW*t`Wn=ePpZR(w?YD$p7%5%e^hyIQW)WcKc-H2h` zafK#L3uR(!M5C&~lY$$yjmx|PUZ{yEswf+770B8rn`a(ZsGMBlrwxg`{vLSLDP zL&g@Jl;H351Azj$wApb#xx|x`z-GN(!59A8YBzVqcJsI6^%*Vkbv>F79MS2OmZbjg zbE;AI5@Kcbn73k4B;K*Hu9tx%3WbAhJ9&GwX6auSgxTmyNU=P6$DXk#6A&n5OG&kE z7?57aH#kRYoS2RTOLyO&+q9hAe4i-}MLeMZJC%Ms*x9YK_Nf+2Cq4J?v**tEeRLDo z@aWphzSye9Fe9jP{uD)$(=t(!D5J_7-od_Cei6`T@5RjyqUQjL7XT2Qp zI1|m&aS-46gXnl)LShp#aM`*cW6y2B**LvVWxPH((u8@#oZRO694%M382`@uW1V!f zC3C>#2l?vcnz7ffce&4aJDA9zI>Hlgn9x)Sf{c!z!S0~(Ag|Y>Bjg3gafycC6<5tO z-LUQ!Xda<(sW9wWKHdq2^;rei z_wC}NMS3i9OW?=91LaQA)W6q2$ukTs>U=`tHOZ$79IqA|JtK#OR4fQN);7$CxfiQ? zCT7z6?WA|RZZdfP<@4dlzM3oM?{NuSxV*c0#-flT@BF&9?n*tRU&9{CDozyew!-di z%3buyn^*ctHVYtY$+ibQVFRtcR-jEvLI;yD*P-JoDF(&t*ct|2MoeIDDm>=E5rELj~nbSWNGV zf6ALWGY^UiGl>NUTqzDre3&P^04vpVNtMMECfUsSL#II=J0EDz1#0kG|~d8l{?|D2i2UtZJp z6<4_ZIkJ*$8c)UAKGlYb6VRcFM(~+Z*zijgqlpkUEoH7hv~y!j zXPbF_7kFC9LVYYOoAI~nPxH2b{N+x7C02l!de18vo1GRoP2Uhnm!Kt}^11)_iZH4d ztXqkPw4mK`SQR4ExQXb0bVu3$G*MnKB*1`IPl@R=q^xYH-s|1cC)>yiqQ>_%5K)t6 zCw7achxkB&lCWhXZAJGAFJfetO1S}Y%<6wl!z?39rcwn#7z!B_m-Pql)D5m~B=-mOv-zK0Fa`F`>}n%;kHqOzLG z?$PpvqtlA=1NaBckKRQ8Y#h;zum~YCM#qH@FH#++M}t!S;GAaxLX9H>nQ_Mp87bGq z2rmI!dJMdY-^1?OK#L@EZ;F$sWg4>n^R6bwrZu?7zLZ?c%qQ>9e`E<&8z#GPe_li~ z>$~7t{-R0d1|mC5To<>^8)4B7LGP;bQ&5FMHcHGK4E^dMv(c!+M^i4`0r`q^31-}4 zhb=C=pvWjW(nmt~K{*R|n@-1t_v2rSR5blA`giT}nUfRztrD;T-p=`emt0r&>Q9iX z3Tko=kyaz;g>qK(#TcK0Eif@|zkuDA1|l5o(9h+@*U5pe3}&|Hro*Ii%NrRBK3yr7UCkXMb~MP?GAbN-{<|e_ zW;>iS9G1{FBgS6`t$s>=>HUlTr}H#JYxHEo6Dc;_ehj?wxun*F{o>lfDR3jd%Id94dl0Knya!sCcsxdy;vNA zdC}~1EBj~j+2uA?+c_HxgRL}(VORzq;oW0jS9ZJ>!Z`GRd~75E=xGghzq%_;Z4xxp z5xW;VbVe4Cg^ep%RJg_sU_-fi&RvGxc#5VT##{I~&dyZ0U2|5^gcC&!Nk4KC;=YFx zV%RTYr>C8?Uo0w~N7H^Qot|Z{fH~8x0Ar*KlbXi`cjUl%meKg_?G$WT)3Zpu#UJDK z51_&{n}4^#034llOg7j?Yp>k-gXwEba!v;gx6`h0t|%Qs8D;%~iwbMIoT`%r#IU%E z8YZFK(@sr{r9+0NNaOB{xaW3cV5}iH7rdk|p01wLz`BjLXsllZKb8_}SN0e9beT;7 zd5t%cFT~JB0&e{!v$b^voo^U<(=3Ctpq)Df+5_ikhBeMjnQ+0KPv}Uh;@2MfwnVeG zY@q+n3@iK=z+od#Of_LjY=_#CA`?f~`+Si3mX8&2Bsq=Y2u?Wa>r^lMq>@(xLN=a- zNUsRAbK2+-&F(3Jk(!<6*iVSHXn8B0>@z2jJq$??-FkJu;={tL>g$rhYgHz0|65J} z)w}KHYpY_1q}z^ZA5)qXEjQJy*+)B$?Mardg26W6!)>sj?_3nq^ANZg%E6ajT2xDo zt?{{$>VYxWu}*8g-10>q)oZZB zh!hpml6=2y<4;8lmqNxcCJ(fDSsD>Nj1w0uZv9=VSnb5?xX zN=27&F#FbeljUjCQlQrzAJCcmK+&}oofc$*-H1ACR%MTbMc38F$50oyBTOuvY+Ls} z%K^leTJZR^9RhD*2P)WX;p8!Fn%K@PY@cZtjkU*B*=w7H#JNmo3t|4gT?+w-aN;BH z&2btn0^B(!?eZ&=16x10M2apUm+5i2iZ5ABUq5=(GF|9n6r@x*u5*~BHh5aE?RY4? zcy3<&OVbX0MLs(&uoj46${zR*wiw!0u@(MLgwKb+8}S)S@qr?HbA2i=uD9W+tK3ab z!~&NQi~DE0PhaL6?{L;r6sUvh`XyzKtt!P#qCyqHgM=vyDZrRu07WP=p+^Q;owDYVeP&Z<5y^a#$4 z70M%By&qTx16cda%}x0@KgT5>F;!A=}j znELqu0M6ccniPCrZ#`Gh_1D*9iKllqB&8H^S#Hmxm=y)BwH*M>pcJWe+ii53jTJ3OYiZ7@(riK%<%AL2H z3vJ6Z_T&my1+df(D1wi~sEWI_WD+iU5KJIoI&-L4qfqhs1M9-G92`K<(c{qYP)2IP zh`}3Ykk_D|;H!as$2WgF@DDrxYgd||9RW9Czbn|TH~7Vr#M1D>ZZz#OvbF_xQXH2Y73KwU2zTT< zb!IE%ihDE$<3!-q3PXJ<>){9{@U6bcy(V8)$HKuI#%-7XK!^}6?mDdY<*D}F< zCj`Xv*oR3>kzTHOVP1IIf+%kU*!mFDJ2mWxmD`r%pQvbMU?~)BbTgYxhf~`z9m*3- zS=Vt%)$4mNzkg8Cxe2xz$1ek0v6^ty{q5Q6V4s#sc@oECT&MqI!+-6Xl96v=2CA}~ z-ko)geDL*MWa?##N1&DSJFx@PLXm3Bv z##;i+6}(@DX~@7 z|Mj_HhY*$w1y}mK_B?rno zAFyi)Bi?MsYZz&>`|?F{P8`ai+m)Q8-%JJfX&;q+3-|DkMpELQGpRm7*BiUrrM z^4|q(CfMo)IU95E)^i%}Ls-CTtF`>SfFgc^Vye`Uy>#x?Khw71Dw(Eg}1i33WV;&nC@bLswRf*GFd^ro%D@Jfn z;ej0BCp}=?I6MTh-GG+(fNRteXXUTQ+@vq4CXC|hGYcD9uPInlL9!;SNEszVCU7RO zu=S@zIyZl&^TQUwT)=vf8IHHRQIS8+rg5Ql_*6aW2Vk93V@+Pjd3?PB8$l3YRwGxLD<+?2}XM%IWEzAk13&r=r>0_x5@PTDO9Lg?!e#9F{ zc?hAq4_lp0>vV0{q5=^R&#CCmP@XxgcNwbI@Zrq4g*`KoEkYrRfqs9r!%xoMxZ z&3`9wWEtP83%93%MAW;`I%1N}_+R7ZP8EIzvjmbTI7)D;!qGB(Nv5IVkNdgJk6FMI zzi9dC$1fd2)+?5gVhZ$IZj^h%V4DMs!H=J@#~GSjI4>s){r(BcRPpklP01E7RP!ZU z6-XoTj_+$@*CYozQN5{}6(b!ixx1*F@>Rr7bfNp|omy04V|l5R$J|P|{A^#NEkMIi@;fxI;HmX*D^|CgshkM9|zPR6I z!-0NKlf-wtoC;EA&z>{L9{d_jp&Um*%0B!zua3W4k{>leG>IqrZ%)eUxIhtDiwze( zydsDPsc52MiD6OVgy#8uX&{;+ep1nYtZ8=)$$dj6j}gn%I@iWn7++MV(3&GU{9D9X za|RE5GQVCVCOyd2WZu)qp3Zb6>^C8<38-FqIh3@)SCEYm10{&R(cntx@$KguOKq&# zqlidSr;Yq|SnJ_)qRnb3v(X4_MJ-^nQ>M8V*L=;IUL(_j?0NOVFegF8&&w-NbyC{= zyrl?$FDUN02%#fG=8OPfZ-Vy)Ruy_!s)@r8aX7FC^cgM_L=Xi{5{Q}rg}FCm{s8!?8OP?npCTAA0(#Mym2u#N z=dhVn008$(RzggzMwR~Yp!ws2l;|>|gKFU+RxLby_!UkwQGI&sf52RfMMVZ0YRyDm zgMl8&yZ}-5YS+Ju>W@!Qgfk*Wzq?4bZBq~wC2~PCu*?U2*KFWHDlz1VNO?qyMdB?w zj9_#Q`Rml$o9`ca|1;Q3aDOdV9;r%aNa1wR?@J=*kLmhqWT0fv2O6EE;7Gxi7y0j| zRxFjaT|P4tp&6o*H#sVVnNWSC4KP;p zYJk!Y2BDW;@Fs_4J^9$&7lQ6Bm=qMO79&V813Deb84Wlv1w1Gw(~w|6E~%)v_EG+L zY0X+=)`Cq5x$4%!l#h81G|refA+QYu@2m1kWPnR4y3~SN?;)OgriB9+zK^`%w3O%q z*Q;$iTa))dG+f~mjKoG^9R!MzzvIV8hxt?1I~Y`Q*f0L$_otqDtZ{*Xz?f?j8CEi> z;RyAVbK9MdmM6P>+6M{tc<|qFEUxnEqx(l;Q)Y01U5JB#UIK3c8fr`K&(z25G0~Lt ztM9zrU^8NvSi-A^QY@<;@&*KW_-IGrvM2@v1L!-7wbvXbfdN@&nJ_i5lmYXcVbDF} zVP@=--~nIa3MJqf?4)BZ#+wJa(gM8l^$Q-O6~53g{t1qCUc+dJaYJ z&v^CHjtR1G&Jj@`;*iNbG=1;>cNmjtC2ap&f+BJ9uAP%tgDb)M&B$L|33IF>p^jw$ zj81tX9Kl(@2k``W(qff6bhnBr-hAYh1LgXmIep$(_@&u8wQ#BRMT9f;sd@R}(VQ^z;ghK6rLPfsls`bn@-eT;@iUszEsul* z7Zt7@k`ye}3}znjw@D!QL1KVPD&v=Jnb + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/logo-static.svg b/docs/logo-static.svg new file mode 100644 index 0000000..9839bc2 --- /dev/null +++ b/docs/logo-static.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 93925046acaf48401d7f0d9edfebd05810c5504f Mon Sep 17 00:00:00 2001 From: OpenSteam001 <248184252+OpenSteam001@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:58:41 +0800 Subject: [PATCH 4/4] docs: add injection description --- README.md | 20 +++++++++++++++++--- README_ES.md | 23 +++++++++++++++++++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0ad9914..212e5ef 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ ### 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. @@ -145,6 +149,13 @@ timeout_recv_ms = 10000 [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" @@ -207,18 +218,21 @@ Debug builds write per-module log files under `/opensteamtool/`: | File | Source | Content | |------|--------|---------| -| `main.log` | General | Init, config loading, Lua parsing,Utils | +| `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 | +| `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) | +| `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`. diff --git a/README_ES.md b/README_ES.md index edfaa72..8053280 100644 --- a/README_ES.md +++ b/README_ES.md @@ -42,6 +42,10 @@ ### Recarga rápida - Añadir, modificar, eliminar o sobrescribir archivos `.lua` en cualquier directorio supervisado activa automáticamente una recarga. No se necesita reiniciar ni alternar entre modo desconectado/conectado. +### Inyección +- Añade inyección opcional de bibliotecas en procesos de juego mediante `[inject]` en `opensteamtool.toml`. +- Configura `enabled`, `library_x64` y `library_x86`; la biblioteca inyectada debe coincidir con la arquitectura del proceso de destino.`library_x64` y `library_x86` pueden ser rutas absolutas, o rutas relativas resueltas desde el directorio raíz de Steam. + ### Préstamo familiar y juego en remoto - Omite las restricciones de Steam Family Sharing para los juegos que se hayan añadido a la biblioteca con `addappid` en Lua. Todas las cuentas de la familia de Steam que participen en el préstamo familiar deben usar OpenSteamTool para que esto funcione. @@ -121,6 +125,7 @@ Los nombres de todas las funciones **no distinguen entre mayúsculas y minúscul ### Configuración (opcional) Cambia el nombre de `opensteamtool.example.toml` a `opensteamtool.toml` y colócalo en el directorio raíz de Steam (junto a `steam.exe`). Si no se encuentra ningún archivo de configuración, se utilizarán los valores predeterminados integrados; no se creará ninguno de forma automática. +El archivo se supervisa mientras Steam está en ejecución; los cambios válidos se recargan en caliente sin reiniciar Steam. ```toml [log] @@ -143,6 +148,13 @@ timeout_recv_ms = 10000 [lua] paths = [] +[inject] +# Inyección opcional de bibliotecas en procesos de juego. +# La biblioteca inyectada debe coincidir con la arquitectura del proceso de destino. +enabled = false +# library_x64 = "OpenSteamTool.GameHook.x64.dll" +# library_x86 = "OpenSteamTool.GameHook.x86.dll" + # Espejo (mirror) de metadatos opcional. Consulta "Compatibilidad con versiones de Steam" más abajo. [remote] # url_template = "https://tu.servidor/{channel}/{component}/{sha256}.toml" @@ -202,8 +214,8 @@ url_template = "https://tu.servidor/{channel}/{component}/{sha256}.toml" Las compilaciones de depuración (Debug) escriben archivos de registro independientes por módulo dentro de `/opensteamtool/`: -| File | Source | Content | -|------|--------|---------| +| Archivo | Fuente | Contenido | +|---------|--------|-----------| | `main.log` | General | Inicialización, carga de configuración, análisis de Lua, utilidades | | `ipc.log` | `LOG_IPC_*` | Comandos IPC, despacho de InterfaceCall, suplantación (spoofing) | | `netpacket.log` | `LOG_NETPACKET_*` | Envío/recepción de paquetes de red, despacho de eMsg | @@ -211,13 +223,16 @@ Las compilaciones de depuración (Debug) escriben archivos de registro independi | `decryptionkey.log` | `LOG_DECRYPTIONKEY_*` | Inyección de claves de descifrado de depósitos (depots) | | `keyvalue.log` | `LOG_KEYVALUE_*` | Parcheo de KeyValues (vinculación de manifiestos) | | `misc.log` | `LOG_MISC_*` | Captura de punteros del motor, sugerencias de AppId | -| `winhttp.log` | `LOG_WINHTTP_*` | Solicitudes HTTP | | `achievement.log` | `LOG_ACHIEVEMENT_*` | Solicitudes/respuestas de UserStats, suplantación de steamid | | `pics.log` | `LOG_PICS_*` | Inyección de tokens de acceso PICS | | `package.log` | `LOG_PACKAGE_*` | Inyección de paquetes, eventos de FileWatcher | | `onlinefix.log` | `LOG_ONLINEFIX_*` | Parche en línea (suplantación del AppId 480) | +| `richpresence.log` | `LOG_RICHPRESENCE_*` | Construcción e inyección de paquetes Rich Presence | +| `steamui.log` | `LOG_STEAMUI_*` | Diagnósticos de hooks de SteamUI | +| `pipe.log` | `LOG_PIPE_*` | Handshakes de pipe, inspección de procesos, autorización de Denuvo, inyección de bibliotecas | +| `platform.log` | `LOG_PLATFORM_*` | Diagnósticos de utilidades de plataforma, incluidas operaciones sobre procesos remotos | -El nivel de registro se controla mediante el `parámetro level en la sección [log]` dentro de `opensteamtool.toml`. +El nivel de registro se controla mediante `[log] level` en `opensteamtool.toml`. ## Compilación