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..212e5ef 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,33 @@
-# OpenSteamTool
-
-
-
-
-
-[](https://deepwiki.com/OpenSteam001/OpenSteamTool)
-
-OpenSteamTool is a Windows DLL project built with CMake.
+
## Feature
@@ -21,15 +42,20 @@ OpenSteamTool is a Windows DLL project built with CMake.
### Hot Reload
- Adding, modifying, deleting, or overwriting `.lua` files in any watched directory automatically triggers a reload. No restart, no offline/online toggle needed.
+### Injection
+- Add optional game-process library injection through `[inject]` in `opensteamtool.toml`.
+- Configure `enabled`, `library_x64`, and `library_x86`; the injected library must match the target process architecture.`library_x64` and `library_x86` may be absolute paths, or relative paths resolved from the Steam root directory.
+
### Family Sharing and Remote Play
- Bypass Steam Family Sharing restrictions for games that have been added to the library with `addappid` in Lua. All accounts in the Steam Family that participate in sharing must use OpenSteamTool for this to work.
### Compatible with games protected by Denuvo and SteamStub
- SteamStub-only games do not require configuring `AppTicket`. OpenSteamTool can reuse Steam's local ConfigStore ticket and forge the requested AppId through a SteamDRMP off-by-four ticket parsing vulnerability, without injecting into the game process.
-- Denuvo-protected games still require explicit ticket data. 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`
@@ -67,10 +93,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.
@@ -89,9 +112,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
+setAppTicket(1361510,"0100000000000000...") -- write AppTicket to the credential store; on Windows: HKCU\Software\Valve\Steam\Apps\1361510\AppTicket
-setETicket(1361510,"0100000000000000...") -- write ETicket (REG_BINARY) to HKCU\Software\Valve\Steam\Apps\1361510\ETicket
+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.
@@ -103,6 +126,7 @@ All function names are **case-insensitive**. `setAppTicket`, `setappticket`, `Se
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]
@@ -125,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"
@@ -187,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
new file mode 100644
index 0000000..8053280
--- /dev/null
+++ b/README_ES.md
@@ -0,0 +1,258 @@
+
+
+## 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.
+
+### 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.
+
+### 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
+- Soporte para la sincronización con Steam Cloud (este es un proyecto enorme).
+
+## Uso
+1. Ejecuta `build.bat` desde la raíz del proyecto para compilarlo.
+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.
+El archivo se supervisa mientras Steam está en ejecución; los cambios válidos se recargan en caliente sin reiniciar Steam.
+
+```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 = []
+
+[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"
+```
+### 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/`:
+
+| 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 |
+| `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 |
+| `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 `[log] level` en `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/docs/logo-1024.png b/docs/logo-1024.png
new file mode 100644
index 0000000..0b4784b
Binary files /dev/null and b/docs/logo-1024.png differ
diff --git a/docs/logo-128.png b/docs/logo-128.png
new file mode 100644
index 0000000..9ffdbac
Binary files /dev/null and b/docs/logo-128.png differ
diff --git a/docs/logo-512.png b/docs/logo-512.png
new file mode 100644
index 0000000..a806cc5
Binary files /dev/null and b/docs/logo-512.png differ
diff --git a/docs/logo-animated.svg b/docs/logo-animated.svg
new file mode 100644
index 0000000..650f819
--- /dev/null
+++ b/docs/logo-animated.svg
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
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..f7956ac
--- /dev/null
+++ b/src/OSTPlatform/Windows/Http.cpp
@@ -0,0 +1,193 @@
+#include "include/Http.h"
+
+#include "include/Encoding.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 : "",
+ Encoding::WideToUtf8(pu.host),
+ 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