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:
khallaandClaude Opus 5.5 committed 2026-10-13 23:55:50 +02:00
commit d9f5c85430
31 files changed
+1184

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
/build/
/.vs/
/.idea/
/cmake-build-*/
*.rea-snapshot
sacred_re.ini
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["ms-vscode.cpptools", "ms-vscode.cmake-tools"]
}
+26
View File
@@ -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"
}
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"cmake.useCMakePresets": "always",
"cmake.configureOnOpen": true,
"C_Cpp.default.configurationProvider": "ms-vscode.cmake-tools",
"files.associations": { "*.def": "plaintext" }
}
+13
View File
@@ -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"]
}
]
}
+43
View File
@@ -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)
+19
View File
@@ -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" }
]
}
+92
View File
@@ -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.
+19
View File
@@ -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 }
+26
View File
@@ -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)
+57
View File
@@ -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).
+49
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
#pragma once
namespace game {
// sacred.exe 0x00813140
bool __cdecl CmdLineHasFlag(const char* cmdline, const char* flag, bool quiet);
} // namespace game
+14
View File
@@ -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
+42
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
#pragma once
namespace game {
// sacred.exe 0x0066f1c0
void __cdecl Log(const char* fmt, ...);
} // namespace game
+58
View File
@@ -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
+102
View File
@@ -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);
}
+215
View File
@@ -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);
}
+33
View File
@@ -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
+12
View File
@@ -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
+75
View File
@@ -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;
}
+62
View File
@@ -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
+51
View File
@@ -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))
+45
View File
@@ -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
+9
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
LIBRARY sacred_re
EXPORTS
; Detours requires ordinal 1 on any DLL injected via DetourCreateProcessWithDll.
DetourFinishHelperProcess @1 NONAME
+1
View File
@@ -0,0 +1 @@
node_modules/
+59
View File
@@ -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();
+19
View File
@@ -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"
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "sacred-re-tools",
"private": true,
"type": "module",
"dependencies": {
"iced-x86": "1.21.0"
}
}