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) <noreply@anthropic.com>
This commit is contained in:
commit
d9f5c85430
31 files changed
+1184
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
/build/
|
||||||
|
/.vs/
|
||||||
|
/.idea/
|
||||||
|
/cmake-build-*/
|
||||||
|
*.rea-snapshot
|
||||||
|
sacred_re.ini
|
||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["ms-vscode.cpptools", "ms-vscode.cmake-tools"]
|
||||||
|
}
|
||||||
Vendored
+26
@@ -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/<preset>/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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"cmake.useCMakePresets": "always",
|
||||||
|
"cmake.configureOnOpen": true,
|
||||||
|
"C_Cpp.default.configurationProvider": "ms-vscode.cmake-tools",
|
||||||
|
"files.associations": { "*.def": "plaintext" }
|
||||||
|
}
|
||||||
Vendored
+13
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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$<$<CONFIG:Debug>: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)
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
@@ -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).
|
||||||
@@ -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 <cstring>
|
||||||
|
|
||||||
|
#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<size_t>(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
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace game {
|
||||||
|
|
||||||
|
// sacred.exe 0x00813140
|
||||||
|
bool __cdecl CmdLineHasFlag(const char* cmdline, const char* flag, bool quiet);
|
||||||
|
|
||||||
|
} // namespace game
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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 <cstdarg>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#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<int>(length), message);
|
||||||
|
|
||||||
|
Log_original("%s", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace game
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace game {
|
||||||
|
|
||||||
|
// sacred.exe 0x0066f1c0
|
||||||
|
void __cdecl Log(const char* fmt, ...);
|
||||||
|
|
||||||
|
} // namespace game
|
||||||
@@ -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 <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// sacred_re.exe: starts sacred.exe with sacred_re.dll injected.
|
||||||
|
//
|
||||||
|
// sacred_re.exe [--game-dir <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 <string>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
#include <detours.h>
|
||||||
|
#include <shellapi.h>
|
||||||
|
|
||||||
|
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 \"<Sacred Gold folder>\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
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<int>(exitCode);
|
||||||
|
}
|
||||||
@@ -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 <algorithm>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
#include <windowsx.h>
|
||||||
|
|
||||||
|
#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<long long>(areaWidth) * height > static_cast<long long>(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<long>(areaWidth), static_cast<long>(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<HBRUSH>(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<HDC>(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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
#include "re/config.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
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<int>(GetPrivateProfileIntW(Widen(section).c_str(), Widen(key).c_str(), fallback, g_path.c_str()));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace re
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#pragma once
|
||||||
|
// sacred_re.ini next to sacred_re.dll / sacred_re.exe.
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace re {
|
||||||
|
|
||||||
|
void SetConfigPath(const std::wstring& path);
|
||||||
|
const std::wstring& ConfigPath();
|
||||||
|
int ConfigInt(const char* section, const char* key, int fallback);
|
||||||
|
|
||||||
|
} // namespace re
|
||||||
@@ -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 <string>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
#include <detours.h>
|
||||||
|
|
||||||
|
#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<const BYTE*>(GetModuleHandleW(nullptr));
|
||||||
|
auto dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(base);
|
||||||
|
auto nt = reinterpret_cast<const IMAGE_NT_HEADERS32*>(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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#include "re/hook.h"
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
#include <detours.h>
|
||||||
|
|
||||||
|
#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<HookDef>& Registry() {
|
||||||
|
static std::vector<HookDef> 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
|
||||||
@@ -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 <cstdint>
|
||||||
|
|
||||||
|
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<uint32_t>(reinterpret_cast<uintptr_t>(&api)), \
|
||||||
|
reinterpret_cast<void**>(&replacement##_original), \
|
||||||
|
reinterpret_cast<void*>(&replacement))
|
||||||
|
|
||||||
|
#define RE_HOOK(address, fn) \
|
||||||
|
static decltype(&fn) fn##_original = \
|
||||||
|
reinterpret_cast<decltype(&fn)>(static_cast<uintptr_t>(address)); \
|
||||||
|
static ::re::HookRegistration fn##_registration( \
|
||||||
|
#fn, address, reinterpret_cast<void**>(&fn##_original), \
|
||||||
|
reinterpret_cast<void*>(&fn))
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#include "re/log.h"
|
||||||
|
|
||||||
|
#include <cstdarg>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
LIBRARY sacred_re
|
||||||
|
EXPORTS
|
||||||
|
; Detours requires ordinal 1 on any DLL injected via DetourCreateProcessWithDll.
|
||||||
|
DetourFinishHelperProcess @1 NONAME
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
@@ -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 <exe> <va> [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 <exe> <va> [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();
|
||||||
Generated
+19
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "sacred-re-tools",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"iced-x86": "1.21.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user