From d9f5c85430bd422ad866c2205c915a632b7a4a79 Mon Sep 17 00:00:00 2001 From: Torhan Bartel Date: Tue, 13 Oct 2026 23:55:50 +0200 Subject: [PATCH] Initial sacred_re scaffold with borderless fullscreen mod - Launcher (sacred_re.exe) starts sacred.exe with sacred_re.dll injected via Detours; the DLL verifies the Sacred Gold 2.28 build before hooking. - Hook framework: RE_HOOK for game addresses, RE_HOOK_API for Windows APIs, per-hook enable/disable in sacred_re.ini, sacred_re.log. - Game code: Log (0x66f1c0) hooked, CmdLineHasFlag (0x813140) rewritten. - Borderless mod: forces windowed mode, covers the monitor, scales the 1024x768 image aspect-correct, remaps mouse input, keeps running in the background with keyboard input hidden while unfocused. - CMake/Ninja/MSVC x86 build, VS Code setup, disasm tool, function map. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 6 + .vscode/extensions.json | 3 + .vscode/launch.json | 26 +++++ .vscode/settings.json | 6 + .vscode/tasks.json | 13 +++ CMakeLists.txt | 43 +++++++ CMakePresets.json | 19 ++++ README.md | 92 +++++++++++++++ build.ps1 | 19 ++++ cmake/Detours.cmake | 26 +++++ docs/function-map.md | 57 ++++++++++ src/game/engine/cmdline.cpp | 49 ++++++++ src/game/engine/cmdline.h | 8 ++ src/game/engine/input.h | 14 +++ src/game/engine/log.cpp | 42 +++++++ src/game/engine/log.h | 8 ++ src/game/render/dx_driver7.h | 58 ++++++++++ src/launcher/main.cpp | 102 +++++++++++++++++ src/mods/borderless.cpp | 215 +++++++++++++++++++++++++++++++++++ src/re/config.cpp | 33 ++++++ src/re/config.h | 12 ++ src/re/dllmain.cpp | 75 ++++++++++++ src/re/hook.cpp | 62 ++++++++++ src/re/hook.h | 51 +++++++++ src/re/log.cpp | 45 ++++++++ src/re/log.h | 9 ++ src/re/sacred_re.def | 4 + tools/.gitignore | 1 + tools/disasm.mjs | 59 ++++++++++ tools/package-lock.json | 19 ++++ tools/package.json | 8 ++ 31 files changed, 1184 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/extensions.json create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json create mode 100644 .vscode/tasks.json create mode 100644 CMakeLists.txt create mode 100644 CMakePresets.json create mode 100644 README.md create mode 100644 build.ps1 create mode 100644 cmake/Detours.cmake create mode 100644 docs/function-map.md create mode 100644 src/game/engine/cmdline.cpp create mode 100644 src/game/engine/cmdline.h create mode 100644 src/game/engine/input.h create mode 100644 src/game/engine/log.cpp create mode 100644 src/game/engine/log.h create mode 100644 src/game/render/dx_driver7.h create mode 100644 src/launcher/main.cpp create mode 100644 src/mods/borderless.cpp create mode 100644 src/re/config.cpp create mode 100644 src/re/config.h create mode 100644 src/re/dllmain.cpp create mode 100644 src/re/hook.cpp create mode 100644 src/re/hook.h create mode 100644 src/re/log.cpp create mode 100644 src/re/log.h create mode 100644 src/re/sacred_re.def create mode 100644 tools/.gitignore create mode 100644 tools/disasm.mjs create mode 100644 tools/package-lock.json create mode 100644 tools/package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e90e442 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/build/ +/.vs/ +/.idea/ +/cmake-build-*/ +*.rea-snapshot +sacred_re.ini diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..20d2dc6 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["ms-vscode.cpptools", "ms-vscode.cmake-tools"] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ae94e9c --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,26 @@ +{ + "version": "0.2.0", + "configurations": [ + { + // Builds, then starts the game through the launcher (no debugger in the game). + "name": "Run Sacred (sacred_re)", + "type": "cppvsdbg", + "request": "launch", + "program": "${workspaceFolder}/build/x86-debug/sacred_re.exe", + "args": ["--game-dir", "D:\\Gaming\\Sacred Gold", "log"], + "cwd": "${workspaceFolder}/build/x86-debug", + "preLaunchTask": "CMake: build", + "console": "internalConsole" + }, + { + // Debug our DLL inside the game: set [debug] WaitForDebugger=1 in + // build//sacred_re.ini, start "Run Sacred", then run this and + // pick sacred.exe. The DLL waits (up to 60 s) before installing hooks. + "name": "Attach to sacred.exe", + "type": "cppvsdbg", + "request": "attach", + "processId": "${command:pickProcess}", + "symbolSearchPath": "${workspaceFolder}/build/x86-debug" + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c9dd912 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "cmake.useCMakePresets": "always", + "cmake.configureOnOpen": true, + "C_Cpp.default.configurationProvider": "ms-vscode.cmake-tools", + "files.associations": { "*.def": "plaintext" } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..fb18848 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "CMake: build", + "type": "cmake", + "command": "build", + "targets": ["all"], + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": ["$msCompile"] + } + ] +} diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..cc05574 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,43 @@ +cmake_minimum_required(VERSION 3.24) +project(sacred_re LANGUAGES C CXX) + +# sacred.exe is a 32-bit process; everything we inject into it must be 32-bit too. +if(NOT CMAKE_SIZEOF_VOID_P EQUAL 4) + message(FATAL_ERROR "sacred_re must be built for 32-bit x86 (use the 'x86-*' presets / an x86 MSVC environment).") +endif() +if(NOT MSVC) + message(FATAL_ERROR "sacred_re requires MSVC (the game's C++ ABI is MSVC's).") +endif() + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +# Static CRT: no VC++ redistributable needed next to the game. +set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + +add_compile_definitions(WIN32_LEAN_AND_MEAN NOMINMAX _CRT_SECURE_NO_WARNINGS) + +include(cmake/Detours.cmake) + +set(SACRED_RE_CXX_FLAGS /W4 /permissive- /utf-8) + +# --- sacred_re.dll: injected into sacred.exe, installs the hooks ------------- +add_library(sacred_re SHARED + src/re/dllmain.cpp + src/re/hook.cpp + src/re/log.cpp + src/re/config.cpp + src/re/sacred_re.def + src/game/engine/log.cpp + src/game/engine/cmdline.cpp + src/mods/borderless.cpp +) +target_include_directories(sacred_re PRIVATE src) +target_compile_options(sacred_re PRIVATE ${SACRED_RE_CXX_FLAGS}) +target_link_libraries(sacred_re PRIVATE detours) + +# --- sacred_re.exe: launches sacred.exe with sacred_re.dll injected ---------- +add_executable(sacred_re_launcher WIN32 src/launcher/main.cpp) +set_target_properties(sacred_re_launcher PROPERTIES OUTPUT_NAME sacred_re) +target_compile_options(sacred_re_launcher PRIVATE ${SACRED_RE_CXX_FLAGS}) +target_link_libraries(sacred_re_launcher PRIVATE detours) +add_dependencies(sacred_re_launcher sacred_re) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..9cb5cf3 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,19 @@ +{ + "version": 6, + "configurePresets": [ + { + "name": "x86-base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "architecture": { "value": "x86", "strategy": "external" }, + "cacheVariables": { "CMAKE_C_COMPILER": "cl", "CMAKE_CXX_COMPILER": "cl" } + }, + { "name": "x86-debug", "inherits": "x86-base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } }, + { "name": "x86-release", "inherits": "x86-base", "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo" } } + ], + "buildPresets": [ + { "name": "x86-debug", "configurePreset": "x86-debug" }, + { "name": "x86-release", "configurePreset": "x86-release" } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b13448 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# sacred_re + +An incremental reimplementation of **Sacred Gold 2.28** (`sacred.exe`, 2006). + +`sacred_re.dll` is injected into the original game at startup and replaces game +functions with rewritten C++ one by one. Everything not yet rewritten keeps +running as original machine code, so the game stays playable at every step and +each rewrite can be tested in-game immediately. Once enough is rewritten, the +original executable can be dropped. + +No game assets or original code are included; you need your own installed copy +of Sacred Gold. + +## Supported build + +Only this executable is supported (all hook addresses are specific to it): + +| | | +|---|---| +| File | `sacred.exe`, 12,103,680 bytes | +| SHA-256 | `fe774c6f985b02c9ce03103c2e55e5d853258c0cc3c737ccd211219c74460cb9` | +| PE timestamp | `0x451BBE74` (2006-09-28) | + +The DLL checks the timestamp and image size at startup and leaves any other +build unpatched. + +## Building + +Requirements: Visual Studio 2022 Build Tools ("Desktop development with C++", +MSVC x86 + Windows SDK), CMake ≥ 3.24, Ninja, git (Detours is fetched at +configure time). + +```powershell +.\build.ps1 # x86-debug -> build\x86-debug\ +.\build.ps1 x86-release # x86-release -> build\x86-release\ +``` + +Everything is 32-bit x86: the code runs inside the 32-bit game process. In an +IDE (CLion, VS Code), select an MSVC toolchain with **x86** architecture and use +the CMake presets. + +## Running + +```powershell +build\x86-debug\sacred_re.exe --game-dir "D:\Gaming\Sacred Gold" # first run; remembered in sacred_re.ini +build\x86-debug\sacred_re.exe log # later runs; extra args go to the game +``` + +`sacred_re.log` (next to the DLL) records what was hooked plus every message the +game sends to its internal logger, even when the game's own logging is off. + +Hooks can be switched off individually in `sacred_re.ini` (next to the DLL) +to compare a rewrite against the original: + +```ini +[hooks] +CmdLineHasFlag=0 +``` + +## Layout + +``` +src/re/ hook framework, injection-side startup, logging, config +src/launcher/ sacred_re.exe — starts sacred.exe with the DLL injected +src/game/ rewritten game code and original-layout structs, by subsystem +src/mods/ behaviour changes on top of the original (e.g. borderless.cpp) +docs/ reverse-engineering notes; function-map.md tracks progress +``` + +## Adding a rewritten function + +```cpp +namespace game { +bool __cdecl CmdLineHasFlag(const char* cmdline, const char* flag, bool quiet); +RE_HOOK(0x00813140, CmdLineHasFlag); // also defines CmdLineHasFlag_original +bool __cdecl CmdLineHasFlag(const char* cmdline, const char* flag, bool quiet) { ... } +} +``` + +The signature and calling convention must match the original exactly. Add the +file to `CMakeLists.txt` and a row to `docs/function-map.md`. + +## Portability rules (for an eventual 64-bit build) + +The game is 32-bit and must stay so while we hook into it, but new code should +not make that permanent: + +- use fixed-width types (`uint32_t`, not `long`/`DWORD`) for anything with a + fixed layout; +- never store pointers in, or cast pointers to, 32-bit integers; +- keep file and network formats behind explicit serialization code instead of + `fwrite`-ing structs. diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..975e366 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,19 @@ +# Configure + build from any shell: enters the x86 MSVC environment first. +# .\build.ps1 # x86-debug +# .\build.ps1 x86-release +param([string]$Preset = "x86-debug") +$ErrorActionPreference = "Stop" + +$vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +$vs = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath +if (-not $vs) { throw "MSVC x86 tools not found (install VS Build Tools with 'Desktop development with C++')." } +$vcvars = Join-Path $vs "VC\Auxiliary\Build\vcvarsall.bat" +# vcvarsall itself calls vswhere and expects it on PATH. +$env:PATH = "$(Split-Path $vswhere);$env:PATH" + +# Configure only once: the build re-runs CMake itself when needed, and +# re-configuring here would clash with VS Code's CMake Tools, which caches +# the full path of cl.exe instead of the preset's plain "cl". +$configure = if (Test-Path "build\$Preset\CMakeCache.txt") { "" } else { "cmake --preset $Preset && " } +cmd /c "`"$vcvars`" x86 >nul && $configure cmake --build --preset $Preset" +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/cmake/Detours.cmake b/cmake/Detours.cmake new file mode 100644 index 0000000..db488df --- /dev/null +++ b/cmake/Detours.cmake @@ -0,0 +1,26 @@ +# Microsoft Detours (MIT) — function hooking and DLL injection. +# Upstream ships no CMake build, so we compile its sources into a static lib. +include(FetchContent) +FetchContent_Declare(detours_src + GIT_REPOSITORY https://github.com/microsoft/Detours.git + GIT_TAG v4.0.1 + GIT_SHALLOW TRUE +) +FetchContent_MakeAvailable(detours_src) + +set(_d ${detours_src_SOURCE_DIR}/src) +add_library(detours STATIC + ${_d}/detours.cpp + ${_d}/modules.cpp + ${_d}/disasm.cpp + ${_d}/image.cpp + ${_d}/creatwth.cpp + ${_d}/disolx86.cpp + ${_d}/disolx64.cpp + ${_d}/disolia64.cpp + ${_d}/disolarm.cpp + ${_d}/disolarm64.cpp +) +target_include_directories(detours PUBLIC ${_d}) +# C++20 implies /permissive-, which v4.0.1 does not compile under (goto over initialisations). +target_compile_options(detours PRIVATE /W3 /permissive) diff --git a/docs/function-map.md b/docs/function-map.md new file mode 100644 index 0000000..64678ba --- /dev/null +++ b/docs/function-map.md @@ -0,0 +1,57 @@ +# Function map + +Addresses are `sacred.exe` virtual addresses (image base `0x400000`). + +Status: **rewritten** (our code replaces the original), **hooked** (our code +wraps the original), **identified** (purpose known, not touched yet). + +## Startup + +| Address | Name | Conv. | Status | Notes | +|---|---|---|---|---| +| `0x0084c704` | `entry` | — | identified | MSVC 6 CRT startup (`WinMainCRTStartup`) | +| `0x00816bd0` | `WinMain` | stdcall | identified | single-instance mutex `SACRED_INSTANCE`, OS check, logging setup, `Settings.cfg`, language selection, main window, message loop, shutdown | +| `0x00813240` | `CreateMainWindow`? | | identified | "WinMain : unable to create main window" on failure | +| `0x00815620` | `InitGame`? | | identified | "WinMain : initialization failed" on failure | + +## Engine + +| Address | Name | Conv. | Status | Notes | +|---|---|---|---|---| +| `0x0066f1c0` | `Log` | cdecl, varargs | hooked | printf-style; writes `DEBUG.LOG` when enabled; see `src/game/engine/log.cpp` | +| `0x0066ed80` | `SetLogEnabled` | cdecl | identified | sets byte `0x017e5ee0` | +| `0x0066ef60` | `OpenLogFiles` | cdecl | identified | (re)opens `DEBUG.LOG`, `ExpLog.LOG` | +| `0x00813140` | `CmdLineHasFlag` | cdecl | rewritten | `src/game/engine/cmdline.cpp` | +| `0x00813240` | `CreateMainWindow` | cdecl | identified | registers class "Sacred" (wndproc `0x008122a0`), `WS_POPUP\|WS_VISIBLE` screen-sized window unless `win` flag | +| `0x008122a0` | `MainWindowProc` | stdcall | hooked | borderless mod remaps mouse message coordinates | +| `0x0066e500` | `GetMouseClientPos` | cdecl | hooked | `GetCursorPos` relative to client area; borderless mod maps to game pixels | +| `0x00610650` | ? | thiscall | identified | player input; only `SetCursorPos` caller (cursor nudging) | + +## Renderer (`dxDriver7`, instance pointer `0x00cda99c`) + +Layout: `src/game/render/dx_driver7.h`. + +| Address | Name | Conv. | Status | Notes | +|---|---|---|---|---| +| `0x00645390` | `DxDriver7::SetDisplayMode` | thiscall, `ret 0xc` | hooked | args (deviceCaps, `DxDisplayMode*`, ?); borderless mod forces windowed + resizes window | +| `0x00645f70` | `DxDriver7::Flip` | thiscall | hooked | windowed: `primary->Blt(+0x1ec, back, +0x1dc)`; borderless mod sets both rects | +| `0x00645000` | `DxDriver7::CreateZBuffer`? | | identified | | +| `0x00644be0` | `DxDriver7::CreateSurface`? | | identified | | + +## Globals + +| Address | Type | Name | Notes | +|---|---|---|---| +| `0x017e5ee0` | `bool` | `g_logEnabled` | set by `log` flag | +| `0x017e5ed4` | `FILE*` | `g_debugLog` | `DEBUG.LOG` | +| `0x017e5ed8` | `FILE*` | `g_expLog` | `ExpLog.LOG` | +| `0x017e5ee4` | `void(*)(const char*)` | `g_logCallback` | | +| `0x017e5cb4` | `int` | `g_language` | index into code table `0x00897394` (US DE FR SP IT PL HU JP VC RU CZ, 16-byte stride) | +| `0x0182cd38` | `HANDLE` | `g_instanceMutex` | | +| `0x00cda99c` | `DxDriver7*` | `g_dxDriver` | | +| `0x0182cdb8` | `uint32_t` | `g_appFlags` | bit `0x20000` = app inactive (set/cleared on `WM_ACTIVATEAPP` at `0x00812421`) | + +## Command-line flags seen so far + +`log` (enable logging), `tabblocker`, `win`, language codes `us`, `de`, `fr`, +`sp`, `it`, `pl`, `hu`, `jp`, `vc`, `ru`, `cz` (lowercased before matching). diff --git a/src/game/engine/cmdline.cpp b/src/game/engine/cmdline.cpp new file mode 100644 index 0000000..3e55c72 --- /dev/null +++ b/src/game/engine/cmdline.cpp @@ -0,0 +1,49 @@ +// Command-line switches. +// +// Original (0x00813140), called from WinMain with lpCmdLine, e.g. for "log", +// "tabblocker", "win" and the language codes "US", "DE", ... (table 0x00897394). +// +// Status: rewritten. Behaviour matches the original, including its quirks: +// - only the flag is lowercased, not the command line: "-LOG" does not enable +// logging, and the language code "DE" is matched as "de"; +// - nothing before the match is checked, so "-xlog" also counts as "log"; +// - a match must be followed by a space or the end of the command line. +// Deviation: the original copies the flag into a 256-byte stack buffer +// unchecked; we truncate instead of overflowing. + +#include "game/engine/cmdline.h" + +#include + +#include "game/engine/log.h" +#include "re/hook.h" + +namespace game { + +RE_HOOK(0x00813140, CmdLineHasFlag); + +bool __cdecl CmdLineHasFlag(const char* cmdline, const char* flag, bool quiet) { + char needle[256]; + strncpy_s(needle, flag, _TRUNCATE); + for (char* c = needle; *c; ++c) { + if (*c >= 'A' && *c <= 'Z') { + *c += 'a' - 'A'; + } + } + const size_t length = strlen(needle); + + for (const char* match = strstr(cmdline, needle); match; match = strstr(match + 1, needle)) { + const char* space = strchr(match, ' '); + const bool endsAtSpace = space && static_cast(space - match) == length; + const bool endsAtEnd = strlen(match) == length; + if (endsAtSpace || endsAtEnd) { + if (!quiet) { + Log("cmd: %s\n", flag); + } + return true; + } + } + return false; +} + +} // namespace game diff --git a/src/game/engine/cmdline.h b/src/game/engine/cmdline.h new file mode 100644 index 0000000..75907a3 --- /dev/null +++ b/src/game/engine/cmdline.h @@ -0,0 +1,8 @@ +#pragma once + +namespace game { + +// sacred.exe 0x00813140 +bool __cdecl CmdLineHasFlag(const char* cmdline, const char* flag, bool quiet); + +} // namespace game diff --git a/src/game/engine/input.h b/src/game/engine/input.h new file mode 100644 index 0000000..0e47a43 --- /dev/null +++ b/src/game/engine/input.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace game { + +// sacred.exe 0x0066e500. GetCursorPos(), made relative to `window`'s client +// area when `window` is non-null. The game's main source of mouse positions. +void __cdecl GetMouseClientPos(HWND window, POINT* position); + +// sacred.exe 0x008122a0. Window procedure of the main "Sacred" window class. +LRESULT CALLBACK MainWindowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam); + +} // namespace game diff --git a/src/game/engine/log.cpp b/src/game/engine/log.cpp new file mode 100644 index 0000000..69ad760 --- /dev/null +++ b/src/game/engine/log.cpp @@ -0,0 +1,42 @@ +// Game debug log. +// +// Original (0x0066f1c0): printf-style. Does nothing unless logging is enabled +// (byte 0x017e5ee0, set by the "log" command-line flag in WinMain). When +// enabled it lazily opens DEBUG.LOG and ExpLog.LOG, writes the message to +// DEBUG.LOG, then formats it into a 1024-byte buffer and passes it to an +// optional callback (0x017e5ee4) and to either OutputDebugString or the +// in-game console object (0x017e5edc). +// +// Status: hooked, not rewritten. Every message is mirrored into sacred_re.log +// whether or not the game's own logging is enabled, then forwarded. + +#include "game/engine/log.h" + +#include +#include +#include + +#include "re/hook.h" +#include "re/log.h" + +namespace game { + +RE_HOOK(0x0066f1c0, Log); + +void __cdecl Log(const char* fmt, ...) { + char message[1024]; + va_list args; + va_start(args, fmt); + vsnprintf(message, sizeof(message), fmt, args); + va_end(args); + + size_t length = strlen(message); + while (length > 0 && (message[length - 1] == '\n' || message[length - 1] == '\r')) { + --length; + } + re::Log("[game] %.*s", static_cast(length), message); + + Log_original("%s", message); +} + +} // namespace game diff --git a/src/game/engine/log.h b/src/game/engine/log.h new file mode 100644 index 0000000..a59577a --- /dev/null +++ b/src/game/engine/log.h @@ -0,0 +1,8 @@ +#pragma once + +namespace game { + +// sacred.exe 0x0066f1c0 +void __cdecl Log(const char* fmt, ...); + +} // namespace game diff --git a/src/game/render/dx_driver7.h b/src/game/render/dx_driver7.h new file mode 100644 index 0000000..4d88770 --- /dev/null +++ b/src/game/render/dx_driver7.h @@ -0,0 +1,58 @@ +#pragma once +// The engine's DirectDraw 7 / Direct3D 7 renderer ("dxDriver7" in its log +// messages). Single instance, pointer at 0x00cda99c. +// +// These structs mirror the original 32-bit memory layout field by field and +// are only valid inside the game process; only the fields we use are named. + +#include +#include + +#include + +namespace game { + +// Display mode request, passed to DxDriver7::SetDisplayMode and copied to +// DxDriver7::mode. +struct DxDisplayMode { + uint8_t unknown00[0x08]; + uint16_t height; // +0x08 + uint8_t unknown0a[0x02]; + uint16_t width; // +0x0c + uint8_t unknown0e[0x6e]; + int32_t fullscreen; // +0x7c 1 = exclusive fullscreen, 0 = windowed + uint32_t bitsPerPixel; // +0x80 +}; +static_assert(sizeof(DxDisplayMode) == 0x84); +static_assert(offsetof(DxDisplayMode, fullscreen) == 0x7c); + +struct DxDriver7 { + uint8_t unknown000[0x14]; + DxDisplayMode mode; // +0x14 + uint8_t unknown098[0x18]; + HWND window; // +0xb0 + void* directDraw; // +0xb4 IDirectDraw7* + void* primarySurface; // +0xb8 IDirectDrawSurface7* + void* backBuffer; // +0xbc IDirectDrawSurface7* + uint8_t unknown0c0[0x11c]; + RECT blitSource; // +0x1dc back-buffer rect copied by Flip (windowed) + RECT blitDest; // +0x1ec screen-space destination of that copy +}; +static_assert(offsetof(DxDriver7, window) == 0xb0); +static_assert(offsetof(DxDriver7, blitSource) == 0x1dc); +static_assert(offsetof(DxDriver7, blitDest) == 0x1ec); + +// Member functions are __thiscall; we declare them __fastcall with an unused +// second parameter (EDX), which has the same register/stack contract. + +// sacred.exe 0x00645390. Creates DirectDraw, the surfaces and the D3D device for +// `mode`. Windowed: resizes the popup window to mode.width x mode.height, +// centred on the desktop. Returns non-zero on success. +int __fastcall DxDriver7_SetDisplayMode(DxDriver7* self, void* edx, void* deviceCaps, const DxDisplayMode* mode, + uint32_t unknown); + +// sacred.exe 0x00645f70. Presents the back buffer: Flip() in fullscreen, +// primary->Blt(blitDest, backBuffer, blitSource) in windowed mode. +int __fastcall DxDriver7_Flip(DxDriver7* self, void* edx); + +} // namespace game diff --git a/src/launcher/main.cpp b/src/launcher/main.cpp new file mode 100644 index 0000000..d026249 --- /dev/null +++ b/src/launcher/main.cpp @@ -0,0 +1,102 @@ +// sacred_re.exe: starts sacred.exe with sacred_re.dll injected. +// +// sacred_re.exe [--game-dir ] [game arguments...] +// +// The game directory comes from --game-dir (which is then remembered in +// sacred_re.ini), else from [paths] GameDir in sacred_re.ini, else the +// launcher's own directory. Remaining arguments are passed to the game +// unchanged, e.g. `sacred_re.exe log` enables the game's DEBUG.LOG. + +#include + +#include +#include +#include + +namespace { + +std::wstring ExeDir() { + wchar_t path[MAX_PATH]; + DWORD length = GetModuleFileNameW(nullptr, path, MAX_PATH); + std::wstring dir(path, length); + return dir.substr(0, dir.find_last_of(L"\\/") + 1); +} + +bool FileExists(const std::wstring& path) { + DWORD attributes = GetFileAttributesW(path.c_str()); + return attributes != INVALID_FILE_ATTRIBUTES && !(attributes & FILE_ATTRIBUTE_DIRECTORY); +} + +std::wstring WithTrailingSlash(std::wstring dir) { + if (!dir.empty() && dir.back() != L'\\' && dir.back() != L'/') { + dir += L'\\'; + } + return dir; +} + +int Fail(const std::wstring& message) { + MessageBoxW(nullptr, message.c_str(), L"sacred_re", MB_OK | MB_ICONERROR); + return 1; +} + +} // namespace + +int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int) { + const std::wstring launcherDir = ExeDir(); + const std::wstring ini = launcherDir + L"sacred_re.ini"; + + int argc = 0; + wchar_t** argv = CommandLineToArgvW(GetCommandLineW(), &argc); + + std::wstring gameDir; + std::wstring gameArgs; + for (int i = 1; i < argc; ++i) { + std::wstring arg = argv[i]; + if (arg == L"--game-dir" && i + 1 < argc) { + gameDir = WithTrailingSlash(argv[++i]); + WritePrivateProfileStringW(L"paths", L"GameDir", gameDir.c_str(), ini.c_str()); + } else { + gameArgs += L' '; + gameArgs += arg; + } + } + LocalFree(argv); + + if (gameDir.empty()) { + wchar_t configured[MAX_PATH] = {}; + GetPrivateProfileStringW(L"paths", L"GameDir", L"", configured, MAX_PATH, ini.c_str()); + gameDir = configured[0] ? WithTrailingSlash(configured) : launcherDir; + } + + const std::wstring gameExe = gameDir + L"sacred.exe"; + if (!FileExists(gameExe)) { + return Fail(L"sacred.exe not found in\n" + gameDir + + L"\n\nRun once with: sacred_re.exe --game-dir \"\""); + } + + const std::wstring dllPath = launcherDir + L"sacred_re.dll"; + if (!FileExists(dllPath)) { + return Fail(L"sacred_re.dll not found next to the launcher:\n" + dllPath); + } + // Detours takes the DLL path as ANSI. + char dllPathA[MAX_PATH]; + if (!WideCharToMultiByte(CP_ACP, 0, dllPath.c_str(), -1, dllPathA, MAX_PATH, nullptr, nullptr)) { + return Fail(L"DLL path is not representable in the ANSI code page:\n" + dllPath); + } + + std::wstring commandLine = L"\"" + gameExe + L"\"" + gameArgs; + STARTUPINFOW startup = {sizeof(startup)}; + PROCESS_INFORMATION process = {}; + if (!DetourCreateProcessWithDllExW(gameExe.c_str(), commandLine.data(), nullptr, nullptr, FALSE, + CREATE_DEFAULT_ERROR_MODE, nullptr, gameDir.c_str(), &startup, &process, + dllPathA, nullptr)) { + return Fail(L"Failed to start sacred.exe (error " + std::to_wstring(GetLastError()) + L")."); + } + + WaitForSingleObject(process.hProcess, INFINITE); + DWORD exitCode = 0; + GetExitCodeProcess(process.hProcess, &exitCode); + CloseHandle(process.hThread); + CloseHandle(process.hProcess); + return static_cast(exitCode); +} diff --git a/src/mods/borderless.cpp b/src/mods/borderless.cpp new file mode 100644 index 0000000..931a9f7 --- /dev/null +++ b/src/mods/borderless.cpp @@ -0,0 +1,215 @@ +// Borderless fullscreen. +// +// The game renders at a fixed resolution (1024x768). Unmodified, windowed mode +// shows that as a 1024x768 popup centred on the desktop; fullscreen mode +// switches the display mode. With this mod the game always runs windowed, the +// popup covers the whole monitor, and each frame is scaled up into the largest +// centred rectangle with the game's aspect ratio (black bars elsewhere). +// +// Mouse positions are mapped back from that rectangle to game pixels, both in +// GetMouseClientPos (polled by the game) and in mouse window messages. +// +// sacred_re.ini: +// [borderless] +// Enabled=1 ; 0 = original behaviour +// IntegerScale=0 ; 1 = scale by whole multiples only (sharper, larger bars) +// RunInBackground=1 ; keep the game running when it loses focus +// +// Vanilla pauses when the window loses focus (WM_ACTIVATEAPP sets bit 0x20000 in +// 0x0182cdb8 and pauses sound) and stops presenting; a monitor-sized window then +// goes black. With RunInBackground the deactivation is never passed to the +// game. Because the game polls the keyboard with GetAsyncKeyState, which sees +// keys pressed in any application, key state is hidden from it while another +// window has the focus. +// +// Known gap: one code path (0x00610650, cursor nudging) moves the cursor with +// SetCursorPos by a game-pixel delta, which is not scaled to screen pixels. + +#include + +#include +#include + +#include "game/engine/input.h" +#include "game/render/dx_driver7.h" +#include "re/config.h" +#include "re/hook.h" +#include "re/log.h" + +namespace { + +struct Viewport { + bool active = false; + HWND window = nullptr; + bool runInBackground = false; + int gameWidth = 0; + int gameHeight = 0; + RECT client = {}; // scaled game image, window client coordinates + RECT screen = {}; // same rectangle, screen coordinates +}; + +Viewport g_viewport; + +bool Enabled() { + return re::ConfigInt("borderless", "Enabled", 1) != 0; +} + +// Largest rectangle with the game's aspect ratio that fits the monitor. +RECT FitRect(int areaWidth, int areaHeight, int width, int height, bool integerScale) { + int scaledWidth; + int scaledHeight; + if (integerScale) { + int scale = std::max(1, std::min(areaWidth / width, areaHeight / height)); + scaledWidth = width * scale; + scaledHeight = height * scale; + } else if (static_cast(areaWidth) * height > static_cast(areaHeight) * width) { + scaledHeight = areaHeight; + scaledWidth = MulDiv(width, areaHeight, height); + } else { + scaledWidth = areaWidth; + scaledHeight = MulDiv(height, areaWidth, width); + } + int left = (areaWidth - scaledWidth) / 2; + int top = (areaHeight - scaledHeight) / 2; + return {left, top, left + scaledWidth, top + scaledHeight}; +} + +void ApplyBorderless(game::DxDriver7* driver) { + HWND window = driver->window; + MONITORINFO monitor = {sizeof(monitor)}; + GetMonitorInfoW(MonitorFromWindow(window, MONITOR_DEFAULTTONEAREST), &monitor); + const RECT& area = monitor.rcMonitor; + const int areaWidth = area.right - area.left; + const int areaHeight = area.bottom - area.top; + + SetWindowLongW(window, GWL_STYLE, WS_POPUP | WS_VISIBLE); + SetWindowPos(window, HWND_TOP, area.left, area.top, areaWidth, areaHeight, SWP_FRAMECHANGED | SWP_SHOWWINDOW); + + Viewport viewport; + viewport.window = window; + viewport.runInBackground = re::ConfigInt("borderless", "RunInBackground", 1) != 0; + viewport.gameWidth = driver->mode.width; + viewport.gameHeight = driver->mode.height; + viewport.client = FitRect(areaWidth, areaHeight, viewport.gameWidth, viewport.gameHeight, + re::ConfigInt("borderless", "IntegerScale", 0) != 0); + viewport.screen = viewport.client; + OffsetRect(&viewport.screen, area.left, area.top); + viewport.active = true; + g_viewport = viewport; + + // Paint the bars: the window class background brush is black. + InvalidateRect(window, nullptr, TRUE); + + re::Log("borderless: %dx%d game -> %ldx%ld monitor, image at (%ld,%ld) %ldx%ld", viewport.gameWidth, + viewport.gameHeight, static_cast(areaWidth), static_cast(areaHeight), viewport.client.left, + viewport.client.top, viewport.client.right - viewport.client.left, + viewport.client.bottom - viewport.client.top); +} + +// Window client coordinates -> game pixels. +POINT ToGame(POINT p) { + const Viewport& v = g_viewport; + const int width = v.client.right - v.client.left; + const int height = v.client.bottom - v.client.top; + POINT result; + result.x = std::clamp(MulDiv(p.x - v.client.left, v.gameWidth, width), 0, v.gameWidth - 1); + result.y = std::clamp(MulDiv(p.y - v.client.top, v.gameHeight, height), 0, v.gameHeight - 1); + return result; +} + +// Fills everything except the game image with black. +void PaintBars(HDC dc) { + RECT window; + GetClientRect(g_viewport.window, &window); + const int saved = SaveDC(dc); + ExcludeClipRect(dc, g_viewport.client.left, g_viewport.client.top, g_viewport.client.right, + g_viewport.client.bottom); + FillRect(dc, &window, static_cast(GetStockObject(BLACK_BRUSH))); + RestoreDC(dc, saved); +} + +bool IsMouseMessageWithClientCoords(UINT message) { + // WM_MOUSEWHEEL and WM_MOUSEHWHEEL carry screen coordinates; leave them alone. + return message >= WM_MOUSEFIRST && message <= WM_MOUSELAST && message != WM_MOUSEWHEEL && + message != WM_MOUSEHWHEEL; +} + +} // namespace + +namespace game { + +RE_HOOK(0x00645390, DxDriver7_SetDisplayMode); +RE_HOOK(0x00645f70, DxDriver7_Flip); +RE_HOOK(0x0066e500, GetMouseClientPos); +RE_HOOK(0x008122a0, MainWindowProc); + +int __fastcall DxDriver7_SetDisplayMode(DxDriver7* self, void* edx, void* deviceCaps, const DxDisplayMode* mode, + uint32_t unknown) { + if (!Enabled()) { + return DxDriver7_SetDisplayMode_original(self, edx, deviceCaps, mode, unknown); + } + DxDisplayMode windowed = *mode; + windowed.fullscreen = 0; + g_viewport.active = false; + const int result = DxDriver7_SetDisplayMode_original(self, edx, deviceCaps, &windowed, unknown); + if (result) { + ApplyBorderless(self); + } + return result; +} + +int __fastcall DxDriver7_Flip(DxDriver7* self, void* edx) { + // The original computes the blit rectangles once, for a centred window of + // exactly the game's size; override them on every present. + if (g_viewport.active && self->window == g_viewport.window) { + self->blitSource = {0, 0, g_viewport.gameWidth, g_viewport.gameHeight}; + self->blitDest = g_viewport.screen; + } + return DxDriver7_Flip_original(self, edx); +} + +void __cdecl GetMouseClientPos(HWND window, POINT* position) { + GetMouseClientPos_original(window, position); + if (window && g_viewport.active && window == g_viewport.window) { + *position = ToGame(*position); + } +} + +LRESULT CALLBACK MainWindowProc(HWND window, UINT message, WPARAM wParam, LPARAM lParam) { + if (!g_viewport.active || window != g_viewport.window) { + return MainWindowProc_original(window, message, wParam, lParam); + } + + if (IsMouseMessageWithClientCoords(message)) { + POINT game = ToGame({GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}); + lParam = MAKELPARAM(game.x, game.y); + } + + switch (message) { + case WM_ERASEBKGND: + PaintBars(reinterpret_cast(wParam)); + return 1; + + case WM_ACTIVATEAPP: + if (!wParam && g_viewport.runInBackground) { + re::Log("borderless: focus lost, game keeps running"); + return 0; + } + break; + } + return MainWindowProc_original(window, message, wParam, lParam); +} + +} // namespace game + +// The game polls keys with GetAsyncKeyState, which reports keys pressed in any +// application. While it runs in the background, hide them from it. +SHORT WINAPI GetAsyncKeyStateWhenFocused(int key); +RE_HOOK_API(GetAsyncKeyState, GetAsyncKeyStateWhenFocused); + +SHORT WINAPI GetAsyncKeyStateWhenFocused(int key) { + if (g_viewport.active && g_viewport.runInBackground && GetForegroundWindow() != g_viewport.window) { + return 0; + } + return GetAsyncKeyStateWhenFocused_original(key); +} diff --git a/src/re/config.cpp b/src/re/config.cpp new file mode 100644 index 0000000..fbf0625 --- /dev/null +++ b/src/re/config.cpp @@ -0,0 +1,33 @@ +#include "re/config.h" + +#include + +#include + +namespace re { +namespace { + +std::wstring g_path; + +std::wstring Widen(const char* s) { + return std::wstring(s, s + strlen(s)); +} + +} // namespace + +void SetConfigPath(const std::wstring& path) { + g_path = path; +} + +const std::wstring& ConfigPath() { + return g_path; +} + +int ConfigInt(const char* section, const char* key, int fallback) { + if (g_path.empty()) { + return fallback; + } + return static_cast(GetPrivateProfileIntW(Widen(section).c_str(), Widen(key).c_str(), fallback, g_path.c_str())); +} + +} // namespace re diff --git a/src/re/config.h b/src/re/config.h new file mode 100644 index 0000000..5072e29 --- /dev/null +++ b/src/re/config.h @@ -0,0 +1,12 @@ +#pragma once +// sacred_re.ini next to sacred_re.dll / sacred_re.exe. + +#include + +namespace re { + +void SetConfigPath(const std::wstring& path); +const std::wstring& ConfigPath(); +int ConfigInt(const char* section, const char* key, int fallback); + +} // namespace re diff --git a/src/re/dllmain.cpp b/src/re/dllmain.cpp new file mode 100644 index 0000000..18e6248 --- /dev/null +++ b/src/re/dllmain.cpp @@ -0,0 +1,75 @@ +// sacred_re.dll entry point. Loaded into sacred.exe by the launcher before the +// game's own entry point runs, so every hook is in place from the first +// instruction of game code. + +#include + +#include +#include + +#include "re/config.h" +#include "re/hook.h" +#include "re/log.h" + +namespace { + +// The only build we support: Sacred Gold 2.28, sacred.exe +// sha256 fe774c6f985b02c9ce03103c2e55e5d853258c0cc3c737ccd211219c74460cb9. +// Every hook address in this project is specific to it. +constexpr DWORD kExpectedTimestamp = 0x451BBE74; +constexpr DWORD kExpectedSizeOfImage = 0x01999000; + +std::wstring ModuleDir(HMODULE module) { + wchar_t path[MAX_PATH]; + DWORD length = GetModuleFileNameW(module, path, MAX_PATH); + std::wstring dir(path, length); + return dir.substr(0, dir.find_last_of(L"\\/") + 1); +} + +bool IsSupportedGameBuild() { + auto base = reinterpret_cast(GetModuleHandleW(nullptr)); + auto dos = reinterpret_cast(base); + auto nt = reinterpret_cast(base + dos->e_lfanew); + re::Log("game image: timestamp 0x%08lx, size 0x%08lx", nt->FileHeader.TimeDateStamp, + nt->OptionalHeader.SizeOfImage); + return nt->FileHeader.TimeDateStamp == kExpectedTimestamp && + nt->OptionalHeader.SizeOfImage == kExpectedSizeOfImage; +} + +void Startup(HMODULE module) { + std::wstring dir = ModuleDir(module); + re::OpenLog((dir + L"sacred_re.log").c_str()); + re::SetConfigPath(dir + L"sacred_re.ini"); + re::Log("sacred_re loaded"); + + if (!IsSupportedGameBuild()) { + re::Log("unsupported sacred.exe build, no hooks installed"); + MessageBoxW(nullptr, + L"This sacred.exe is not the supported Sacred Gold 2.28 build.\n" + L"sacred_re will not patch it; the game runs unmodified.", + L"sacred_re", MB_OK | MB_ICONWARNING); + return; + } + if (re::ConfigInt("debug", "WaitForDebugger", 0)) { + re::Log("waiting up to 60 s for a debugger to attach"); + for (int i = 0; i < 600 && !IsDebuggerPresent(); ++i) { + Sleep(100); + } + re::Log(IsDebuggerPresent() ? "debugger attached" : "no debugger, continuing"); + } + re::InstallHooks(); +} + +} // namespace + +BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID) { + if (DetourIsHelperProcess()) { + return TRUE; + } + if (reason == DLL_PROCESS_ATTACH) { + DisableThreadLibraryCalls(module); + DetourRestoreAfterWith(); + Startup(module); + } + return TRUE; +} diff --git a/src/re/hook.cpp b/src/re/hook.cpp new file mode 100644 index 0000000..34ffa81 --- /dev/null +++ b/src/re/hook.cpp @@ -0,0 +1,62 @@ +#include "re/hook.h" + +#include + +#include +#include + +#include "re/config.h" +#include "re/log.h" + +namespace re { +namespace { + +struct HookDef { + const char* name; + uint32_t address; + void** original; + void* replacement; +}; + +// Function-local static: registrations run during static initialisation, in +// unspecified order across translation units. +std::vector& Registry() { + static std::vector hooks; + return hooks; +} + +} // namespace + +HookRegistration::HookRegistration(const char* name, uint32_t address, void** original, void* replacement) { + Registry().push_back({name, address, original, replacement}); +} + +int InstallHooks() { + int failed = 0; + int installed = 0; + for (const HookDef& hook : Registry()) { + if (!ConfigInt("hooks", hook.name, 1)) { + Log("hook %-24s 0x%08x disabled by config", hook.name, hook.address); + continue; + } + // One transaction per hook, so a bad hook does not take the others down. + DetourTransactionBegin(); + DetourUpdateThread(GetCurrentThread()); + LONG error = DetourAttach(hook.original, hook.replacement); + if (error == NO_ERROR) { + error = DetourTransactionCommit(); + } else { + DetourTransactionAbort(); + } + if (error == NO_ERROR) { + ++installed; + } else { + ++failed; + Log("hook %-24s 0x%08x FAILED (error %ld)", hook.name, hook.address, error); + } + } + Log("%d hook(s) installed, %d failed", installed, failed); + return failed; +} + +} // namespace re diff --git a/src/re/hook.h b/src/re/hook.h new file mode 100644 index 0000000..3b2a37b --- /dev/null +++ b/src/re/hook.h @@ -0,0 +1,51 @@ +#pragma once +// Hook registry: replaces functions at fixed sacred.exe addresses with our code. +// +// Usage (in a game/*.cpp file): +// +// namespace game { +// void __cdecl Log(const char* fmt, ...); // declare our version first +// RE_HOOK(0x0066f1c0, Log); // defines Log_original +// void __cdecl Log(const char* fmt, ...) { // then implement it +// ... // may call Log_original(...) +// } +// } +// +// The signature, including the calling convention, must match the original +// exactly (`__cdecl`, `__stdcall`, `__thiscall` / `__fastcall` stand-in, ...). +// Hooks can be disabled individually at runtime in sacred_re.ini: +// +// [hooks] +// Log=0 + +#include + +namespace re { + +class HookRegistration { +public: + HookRegistration(const char* name, uint32_t address, void** original, void* replacement); +}; + +// Attaches every registered hook that is enabled in the config. Returns the +// number of hooks that failed to attach. +int InstallHooks(); + +} // namespace re + +// Hooks a Windows API function for the whole process (game and DLLs alike). +// `replacement` must have the API's exact signature; defines +// `replacement##_original`. Disabled in sacred_re.ini under the API's name. +#define RE_HOOK_API(api, replacement) \ + static decltype(&replacement) replacement##_original = &api; \ + static ::re::HookRegistration replacement##_registration( \ + #api, static_cast(reinterpret_cast(&api)), \ + reinterpret_cast(&replacement##_original), \ + reinterpret_cast(&replacement)) + +#define RE_HOOK(address, fn) \ + static decltype(&fn) fn##_original = \ + reinterpret_cast(static_cast(address)); \ + static ::re::HookRegistration fn##_registration( \ + #fn, address, reinterpret_cast(&fn##_original), \ + reinterpret_cast(&fn)) diff --git a/src/re/log.cpp b/src/re/log.cpp new file mode 100644 index 0000000..19ce401 --- /dev/null +++ b/src/re/log.cpp @@ -0,0 +1,45 @@ +#include "re/log.h" + +#include +#include +#include + +#include + +namespace re { +namespace { + +std::mutex g_mutex; +FILE* g_file = nullptr; + +} // namespace + +void OpenLog(const wchar_t* path) { + std::lock_guard lock(g_mutex); + if (!g_file) { + g_file = _wfsopen(path, L"w", _SH_DENYWR); + } +} + +void Log(const char* fmt, ...) { + char message[1024]; + va_list args; + va_start(args, fmt); + vsnprintf(message, sizeof(message), fmt, args); + va_end(args); + + SYSTEMTIME t; + GetLocalTime(&t); + char line[1100]; + snprintf(line, sizeof(line), "%02d:%02d:%02d.%03d %s\n", t.wHour, t.wMinute, t.wSecond, t.wMilliseconds, + message); + + OutputDebugStringA(line); + std::lock_guard lock(g_mutex); + if (g_file) { + fputs(line, g_file); + fflush(g_file); + } +} + +} // namespace re diff --git a/src/re/log.h b/src/re/log.h new file mode 100644 index 0000000..4fbc7b2 --- /dev/null +++ b/src/re/log.h @@ -0,0 +1,9 @@ +#pragma once +// sacred_re's own log: sacred_re.log next to sacred_re.dll, plus OutputDebugString. + +namespace re { + +void OpenLog(const wchar_t* path); +void Log(const char* fmt, ...); + +} // namespace re diff --git a/src/re/sacred_re.def b/src/re/sacred_re.def new file mode 100644 index 0000000..a9c1b62 --- /dev/null +++ b/src/re/sacred_re.def @@ -0,0 +1,4 @@ +LIBRARY sacred_re +EXPORTS + ; Detours requires ordinal 1 on any DLL injected via DetourCreateProcessWithDll. + DetourFinishHelperProcess @1 NONAME diff --git a/tools/.gitignore b/tools/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/tools/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/tools/disasm.mjs b/tools/disasm.mjs new file mode 100644 index 0000000..7cdad2f --- /dev/null +++ b/tools/disasm.mjs @@ -0,0 +1,59 @@ +// Disassemble sacred.exe at a virtual address, independent of Ghidra's +// function boundaries (useful for code only reached via function pointers). +// +// node tools/disasm.mjs [count=60] +// +// Stops after `count` instructions or at the first RET/INT3 run, whichever +// comes later than 8 instructions. + +import { readFileSync } from "node:fs"; +import { Decoder, DecoderOptions, Formatter, FormatterSyntax, Mnemonic } from "iced-x86"; + +const [exePath, vaText, countText = "60"] = process.argv.slice(2); +if (!exePath || !vaText) { + console.error("usage: node disasm.mjs [count]"); + process.exit(1); +} +const va = Number.parseInt(vaText, 16); +const count = Number.parseInt(countText, 10); + +const file = readFileSync(exePath); +const peOffset = file.readUInt32LE(0x3c); +const sectionCount = file.readUInt16LE(peOffset + 6); +const optionalSize = file.readUInt16LE(peOffset + 20); +const imageBase = file.readUInt32LE(peOffset + 24 + 28); +const sectionTable = peOffset + 24 + optionalSize; + +function fileOffset(address) { + const rva = address - imageBase; + for (let i = 0; i < sectionCount; i++) { + const s = sectionTable + 40 * i; + const virtualSize = file.readUInt32LE(s + 8); + const virtualAddress = file.readUInt32LE(s + 12); + const rawOffset = file.readUInt32LE(s + 20); + if (rva >= virtualAddress && rva < virtualAddress + virtualSize) { + return rawOffset + rva - virtualAddress; + } + } + throw new Error(`address 0x${address.toString(16)} is not in a section`); +} + +const start = fileOffset(va); +const bytes = file.subarray(start, start + count * 15); +const decoder = new Decoder(32, bytes, DecoderOptions.None); +decoder.ip = BigInt(va); +const formatter = new Formatter(FormatterSyntax.Intel); +formatter.firstOperandCharIndex = 8; + +for (let n = 0; n < count && decoder.canDecode; n++) { + const instruction = decoder.decode(); + const address = Number(instruction.ip).toString(16).padStart(8, "0"); + const offset = Number(instruction.ip) - va; + const raw = [...bytes.subarray(offset, offset + instruction.length)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(" "); + console.log(`${address} ${raw.padEnd(30)} ${formatter.format(instruction)}`); + instruction.free(); +} +decoder.free(); +formatter.free(); diff --git a/tools/package-lock.json b/tools/package-lock.json new file mode 100644 index 0000000..e4ad02b --- /dev/null +++ b/tools/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "sacred-re-tools", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "sacred-re-tools", + "dependencies": { + "iced-x86": "1.21.0" + } + }, + "node_modules/iced-x86": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/iced-x86/-/iced-x86-1.21.0.tgz", + "integrity": "sha512-zHK5/RREJbD7N2BfbeiHJciHbZKCSwI5c34wASV9/B8vNe+mjOzqK8RIabhMLmqHv3L6UvuFDlpdS/dqleQyeA==", + "license": "MIT" + } + } +} diff --git a/tools/package.json b/tools/package.json new file mode 100644 index 0000000..ab14351 --- /dev/null +++ b/tools/package.json @@ -0,0 +1,8 @@ +{ + "name": "sacred-re-tools", + "private": true, + "type": "module", + "dependencies": { + "iced-x86": "1.21.0" + } +}