Framework for Sacred 2 Gold, game log mirror, borderless fullscreen

Ported from sacredK with module-relative addresses, hooks by exported
name and a SHA-256 check of the six game modules (Community Patch
2.65.1). Borderless windowed at monitor size replaces exclusive
fullscreen, whose focus loss during loading made the game quit and
crash on exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khallaandClaude Opus 5.5 committed 2026-10-31 01:39:44 +01:00
commit 8a7bdfd99f
44 files changed
+2505

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
# Store LF in the repository; MSVC, CMake and PowerShell all read LF fine.
* text=auto eol=lf
+14
View File
@@ -0,0 +1,14 @@
/build/
/.vs/
/.vscode/
/.idea/
/cmake-build-*/
*.rea-snapshot
sacred2K.ini
/ghidra/
/screenshots/
*.stackdump
/out/
CMakeUserPresets.json
/.claude/settings.local.json
node_modules/
+96
View File
@@ -0,0 +1,96 @@
# CLAUDE.md
Guidance for agents working in this repository. Read `README.md` (what the
project is, build, run, layout), `docs/modules.md` (the game's modules and
address convention) and `tools/README.md` (RE tools, Ghidra project) first;
this file adds how we work and what we learned.
## What this is
`sacred2K.dll` is injected into **Sacred 2 Gold** with Community Patch 2.65.1
(`system\sacred2.exe` plus `orcSystem`, `s2core`, `s2logic`, `s2logicai`,
`s2render` DLLs; 2009, 32-bit, MSVC 8 + STLport) by the launcher
`sacred2K.exe`. It hooks and patches the running game: functions are
rewritten and mods change behaviour on top of the original. The game stays
playable at every step; the user plays it to test. Sister project: sacredK
(Sacred Gold, `../sacred_k`), whose framework and working rules this one
follows.
- Target files: the user's local installation (see `README.md`, Supported
build). The game folder is given to the launcher once (`--game-dir`) and
kept in `build\<preset>\sacred2K.ini`, `[paths] GameDir`. Never hard-code a
local path in code, docs or configs.
- Game texts are German (`locale\de_DE`); keep the game's names when talking
about features.
- The user's monitor is 3440x1440.
## How the user works (important)
- **The user tests in-game** and reports back ("1: works, 2: ..."). After a
change, say exactly what to test, numbered, in player terms. Don't claim
something works until the user (or a test you ran) confirmed it.
- **Their game start builds the DLL.** A link error `LNK1168` (DLL locked)
means the game is running; that's expected, not a failure to fix.
- **Ask before anything that takes over the desktop** (starting the game,
autotests).
- **Commit only what the user confirmed**, or when asked. End commit
messages with the attribution line given by the harness.
## Principles
- **Measure before imitating.** Log the original's runtime behaviour instead
of guessing from decompiles. Temporary diagnostics: log lines start with
`diag`, code is marked `TEMPORARY`, both removed before committing.
- **No vanilla preservation.** Once a mod works, delete the old path, toggles
and fallbacks. Keep only migrations (e.g. old save formats).
- **Refactor duplicates while writing** into shared headers or tables.
- **Name as you go.** Exported functions are named already (decorated names;
prefer hooking them with `RE_HOOK_EXPORT`). Name what we identify
ourselves in `tools/names.txt` (`module 0xADDR Name`). No bare hex in logic:
named `re::Address` constants with a comment saying where they come from.
- **Data-driven over id lists.** Prefer the game's own data tables and
scripts over hard-coded ids.
- Document each mod in `docs/<topic>.md`: the request (with date), what was
measured, addresses, how the mod works. `docs/todo.md` for deferred items.
## Build and run
```powershell
.\build.ps1 # x86-debug -> build\x86-debug\
build\x86-debug\sacred2K.exe # start the game with the DLL
```
New source files must be added to `CMakeLists.txt`. Output to check:
`build\x86-debug\sacred2K.log` (written anew each start: module check with
running bases, hooks, the game's `Orc::Log` messages as `[game:<level>]`).
Crashes: `sacred2K_crash.txt` / `.dmp` next to the DLL (module+offset; the
offset is from the running base, add the preferred base for Ghidra).
## Code map
- `src/re/` — framework: `module.h` (`re::Module`, `re::Address{module, va}`,
`GameFunction<Fn>`, build check by SHA-256), `RE_HOOK(address, fn)` /
`RE_HOOK_EXPORT(module, "?decorated@@", fn)` / `RE_HOOK_API` (Detours;
define `fn_original`, null until installed), `RE_STARTUP(name, fn)`, patch
helpers in `patch.h` (all take `re::Address` and verify the original bytes
first), config, log, crash handler.
- `src/game/<module or subsystem>/` — the game's structures and functions:
`orc/string.h` (`Orc::String`), `orc/log.cpp` (logger hook),
`render/dx9base.h` (`cDX9Base`, `sInitParams`).
- `src/mods/` — one file per mod; the top comment explains the request, the
measured behaviour and the approach. `borderless.cpp`: always borderless
windowed at monitor size (`docs/borderless.md`).
## Reverse-engineering workflow
- Find by name first: `node tools/exports.mjs <module> "<regex>"`; most
classes of the DLLs are exported (`?method@Class@@...`).
- Decompile: `.\tools\decompile.ps1 <module> 0xADDR|symbol -ReadOnly`.
- Disassemble: `node tools/disasm.mjs <module> 0xVA [n]`, or
`objdump -d -M intel --start-address=.. --stop-address=.. <module>`.
- **Verify calling conventions before hooking or calling:** decorated names
tell them (`QAE`/`UAE` = `__thiscall`, `QAA`/`UAA` = `__cdecl` with `this`
on the stack, `SA` = static `__cdecl`, `SG` = static `__stdcall`); still
check `ret N`. Use `__fastcall(self, edx, ...)` for `__thiscall`.
- Absolute addresses inside code were relocated with their module: compare
and patch them as `re::Address`, never as raw Ghidra VAs.
+45
View File
@@ -0,0 +1,45 @@
cmake_minimum_required(VERSION 3.24)
project(sacred2K LANGUAGES C CXX)
# sacred2.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 "sacred2K must be built for 32-bit x86 (use the 'x86-*' presets / an x86 MSVC environment).")
endif()
if(NOT MSVC)
message(FATAL_ERROR "sacred2K 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(SACRED2K_CXX_FLAGS /W4 /permissive- /utf-8)
# --- sacred2K.dll: injected into sacred2.exe, installs the hooks -----------
add_library(sacred2K SHARED
src/re/dllmain.cpp
src/re/hook.cpp
src/re/module.cpp
src/re/log.cpp
src/re/config.cpp
src/re/patch.cpp
src/re/crash.cpp
src/re/sacred2K.def
src/game/orc/log.cpp
src/mods/borderless.cpp
)
target_include_directories(sacred2K PRIVATE src)
target_compile_options(sacred2K PRIVATE ${SACRED2K_CXX_FLAGS})
target_link_libraries(sacred2K PRIVATE detours dbghelp bcrypt)
# --- sacred2K.exe: launches sacred2.exe with sacred2K.dll injected ---------
add_executable(sacred2K_launcher WIN32 src/launcher/main.cpp)
set_target_properties(sacred2K_launcher PROPERTIES OUTPUT_NAME sacred2K)
target_compile_options(sacred2K_launcher PRIVATE ${SACRED2K_CXX_FLAGS})
target_link_libraries(sacred2K_launcher PRIVATE detours)
add_dependencies(sacred2K_launcher sacred2K)
+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" }
]
}
+113
View File
@@ -0,0 +1,113 @@
# sacred2K
An incremental reimplementation of **Sacred 2 Gold** (Sacred 2: Fallen Angel +
Ice & Blood, 2009) with Community Patch 2.65.1. Sister project of sacredK
(Sacred Gold).
`sacred2K.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.
No game assets or original code are included; you need your own installed copy
of Sacred 2 Gold.
## Supported build
Sacred 2 Gold (Steam, app 225640) with Community Patch 2.65.1. The game is
split into an executable and several DLLs; hook addresses are specific to
these files:
| File (in `system\`) | Size | SHA-256 |
|---|---|---|
| `sacred2.exe` | 2,825,728 | `37b6d077ce9ff7c1b539e9e17db5351cb3a22c02827b36cc327b1f5fc0900090` |
| `orcSystem.dll` | 935,560 | `43b34c8c5a825f6f485f8cf8122054faff4075a33f44554901d8de167c7389b3` |
| `s2core.dll` | 1,406,088 | `2b9b8e05746f7a6f439746c3e26919a983ec794bae6bbcfe25f54cf0d05c13ec` |
| `s2logic.dll` | 3,754,120 | `9370aa81d3b1ca10f603a0758d2520bf42c20f8f5605966aab046fd236d5ed1b` |
| `s2logicai.dll` | 1,918,600 | `26b41eaa9e306ba9e9b51a1d33d011d6df7934e14bf21c289876516b393ebe6e` |
| `s2render.dll` | 3,711,624 | `09bd4975a2b01816f9a20db4a9365ee944611a78612e72a6abfae125c2723d75` |
The Community Patch kept the original PE timestamps, so the DLL hashes every
module 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.
## Running
Tell the launcher the Sacred 2 Gold folder (the one containing `system\`) once:
```powershell
build\x86-debug\sacred2K.exe --game-dir "<Sacred 2 Gold folder>" # first run; remembered in sacred2K.ini
build\x86-debug\sacred2K.exe # later runs; extra args go to the game
```
The folder is stored as `[paths] GameDir` in `sacred2K.ini` next to the
launcher (one per build preset, not tracked by git). The game is started from
its `system` folder, as Steam does.
`sacred2K.log` (next to the DLL) records the module check, what was hooked,
and every message the game passes to its logger (`Orc::Log`).
Hooks can be switched off individually in `sacred2K.ini`:
```ini
[hooks]
LogMessage=0
```
## Layout
```
src/re/ hook framework, module addresses, startup, logging, config, crash reports
src/launcher/ sacred2K.exe — starts sacred2.exe with sacred2K.dll injected
src/game/ rewritten game code and original-layout structs, by module/subsystem
src/mods/ behaviour changes on top of the original
docs/ reverse-engineering notes; function-map.md tracks progress
tools/ disassembler, export index, RTTI, Ghidra scripts
```
## Addresses
The game's DLLs all prefer image base 0x10000000 and are relocated when
loaded, so an address is always a module plus a VA at the module's preferred
base, exactly as Ghidra shows it (`docs/modules.md`):
```cpp
constexpr re::Address kSomething{re::Module::S2Logic, 0x10123450};
```
Exported functions (the DLLs export most of their classes) are hooked by
decorated name instead:
```cpp
namespace orc {
void __fastcall LogMessage(Log* self, void* edx, const String& message, int level);
RE_HOOK_EXPORT(re::Module::OrcSystem, "?logMessage@Log@Orc@@UAEXABVString@2@W4LogMessageLevel@2@@Z", LogMessage);
void __fastcall LogMessage(Log* self, void* edx, const String& message, int level) { ... }
}
```
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.
+21
View File
@@ -0,0 +1,21 @@
# 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 && " }
# 2>&1 inside cmd: Windows PowerShell turns any native stderr line (e.g. CMake's
# download progress) into a terminating error under ErrorActionPreference=Stop.
cmd /c "`"$vcvars`" x86 >nul && $configure cmake --build --preset $Preset 2>&1"
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)
+52
View File
@@ -0,0 +1,52 @@
# Borderless fullscreen
Request (2026-10-31): make the game start without crashing, then borderless
fullscreen as the first feature.
## The start-up crash
Measured 2026-10-31 (3440x1440, options `video.fullscreen = 1`):
- Started through the launcher from a background process, Windows doesn't
hand the game window the foreground. In exclusive fullscreen Direct3D then
minimises it (style gains `WS_MINIMIZE` after ~8 s, during "SETUP AI..." on
the loading screen), and the game quits ~20 s later. On the way out
`s2logicai.dll`'s static destructors call a pure virtual function (a global
allocator object at `0x101BF774` is already destroyed), the game's
`s2_purecall()` handler (`sacred2.exe` 0x489ada) breaks into the debugger:
exception 0x80000003 in KERNELBASE.
- Started with focus (or directly, unmodified), it reaches the main menu.
- `cDX9Base::flip` minimises the window and sets device flag 4 when Present
returns `D3DERR_DEVICELOST`.
So the crash was exclusive fullscreen losing the focus; it is gone with
borderless.
## How the mod works (`src/mods/borderless.cpp`)
`cDX9Base::_initDX(const sInitParams&)` (s2render 0x1029a736, exported, the
only device set-up, reached through `initDX` / `cDX9Advance::initAdvance`)
has a windowed path: style `0x12c00000`, size clamped to
`GetSystemMetrics(SM_CXSCREEN/SM_CYSCREEN)` minus the frame, window centred,
back buffer of that size, presentation interval default. See
`src/game/render/dx9base.h` for `sInitParams` and the addresses.
- The hook passes `fullscreen = 0` and the primary monitor's size.
- At startup the four uses of the style constant (0x1029ab49, ab56, ab5b,
ab62) become `WS_POPUP | WS_VISIBLE`, so the game's own code makes a
frameless window covering the monitor at (0,0).
- The launcher passes its foreground right to the game
(`AllowSetForegroundWindow`).
Verified 2026-10-31 by automated runs: started without focus the game keeps
loading in the background; with focus it reaches the main menu, survives
switching to another window for 6 s and back, and quits cleanly on a close
request (no crash report, `MemLeaks_sacred2.xml` written).
## Consequences
- The resolution and fullscreen settings in Optionen no longer apply; the
game always renders at the monitor's resolution.
- Always the primary monitor.
- In windowed mode the game presents with vsync (interval default); the
options' vsync flag only applies to exclusive fullscreen.
+11
View File
@@ -0,0 +1,11 @@
# Function map
Game functions we hook, call or have rewritten. Address = module + VA at the
module's preferred base (as in Ghidra).
| Module | Address | Function | Status | Source |
|---|---|---|---|---|
| orcSystem.dll | 0x10018c60 | `Orc::Log::logMessage(const String&, LogMessageLevel)` (virtual, `__thiscall`, ret 8) | hooked: mirrored into sacred2K.log | `src/game/orc/log.cpp` |
| s2render.dll | 0x1029a736 | `cDX9Base::_initDX(const sInitParams&)` (`__thiscall`, ret 4) | hooked: always windowed at monitor size; window style patched to borderless | `src/mods/borderless.cpp` |
| s2render.dll | 0x1029d81e | `cDX9Base::flip()` | known: minimises on `D3DERR_DEVICELOST` | `docs/borderless.md` |
| sacred2.exe | 0x00489ada | `s2_purecall()` handler: logs, then `DebugBreak` | known | `docs/borderless.md` |
+39
View File
@@ -0,0 +1,39 @@
# Game modules
Surveyed 2026-10-31 on Sacred 2 Gold (Steam) with Community Patch 2.65.1.
`sacred2.exe` (client, UI) statically imports the game DLLs, so all of them are
mapped before our DLL's `DllMain` runs. All DLLs prefer image base 0x10000000
and none is ASLR-enabled; the loader relocates all but the first, so their
running bases differ from Ghidra's. `re::Address{module, va}` holds the
Ghidra VA and resolves it at runtime; `sacred2K.log` lists the running bases.
| Module | Role | Exports | Notes |
|---|---|---|---|
| `sacred2.exe` | client: main loop, UI, input | 1 | image base 0x400000, sections `.text .rdata .rdata .data .data .rdata .common .rsrc`; "linker 4.0", PE date 2010 (modified by the Community Patch) |
| `orcSystem.dll` | "Orc" engine base: `Orc::String`, `Log`, `LogManager`, files, timers, singletons | 3231 | 2009-07 |
| `s2core.dll` | shared types: kernel events (`cEvent*`), world/sector positions, waypoints, type manager | 1966 | |
| `s2logic.dll` | game logic: creatures, items, quests, dialogs, factions, drops, balance (`cLogicMgr`, `c*Mgr` singletons) | 5398 | |
| `s2logicai.dll` | AI | 176 | |
| `s2render.dll` | renderer (D3D9, Granny, PhysX) | 2258 | |
Other modules, not in the build check yet: `s2logicdll.dll`, `s2editor.dll`,
`s2vista.dll`, the `plugin_*.dll` set from `plugins.cfg` (file system, zip,
particle FX, Bink, Miles, OpenAL; loaded at runtime), third-party libraries
(`granny2`, `binkw32`, `mss32`, PhysX, `stlport.5.0`, MSVC 8 runtime). `s2gs.exe`
is the dedicated server.
Toolchain: MSVC 8 (VS 2005), STLport 5.0 strings and containers, C++ classes
exported with decorated names (`?method@Class@@...`), so most functions of the
DLLs are named. `tools/exports.mjs` lists them; Ghidra demangles them.
## Orc::String
STLport string with a 16-byte inline buffer, 0x18 bytes; see
`src/game/orc/string.h`.
## Data
`pak\*.zip` (graphics, sectors, sound), `scripts\` (client/server/shared Lua-like
text configs), `locale\de_DE` (German texts `global.res`, item name CSVs,
speech). User options: `%LOCALAPPDATA%\Ascaron Entertainment\Sacred 2\options.txt`.
+11
View File
@@ -0,0 +1,11 @@
# Todo
- Engine tick hook calling `re::EngineHeartbeat`, so the hang watchdog
(`src/re/crash.cpp`) works.
- Hooks in modules loaded at runtime (`plugin_*.dll`, `s2logicdll.dll`):
resolve them when the module loads (LoadLibrary hook) and add them to the
build check.
- Log levels: find `LogMessageLevel` names and the threshold, so filtered
messages can be shown too.
- Autotest (start, menu clicks, screenshots) as in sacredK, once the menus are
known.
+34
View File
@@ -0,0 +1,34 @@
// Orc::Log, the engine's logger (orcSystem.dll).
//
// Every message ends in the virtual Log::logMessage(const String&, level)
// (vtable +0x0c, orcSystem 0x10018c60, __thiscall, ret 8): the printf-style
// overloads of Log and LogManager format into a String and call it. It drops
// messages below the log's threshold (vtable +0x14, isWithinThreshold), then
// writes the file and runs the log's callback. The printf-style overloads
// check the threshold before formatting, so their filtered messages never
// arrive here.
//
// Status: hooked, not rewritten. Every message that arrives is mirrored into
// sacred2K.log as "[game:<level>] ...", then forwarded.
#include "game/orc/string.h"
#include "re/hook.h"
#include "re/log.h"
namespace orc {
struct Log;
void __fastcall LogMessage(Log* self, void* edx, const String& message, int level);
RE_HOOK_EXPORT(re::Module::OrcSystem, "?logMessage@Log@Orc@@UAEXABVString@2@W4LogMessageLevel@2@@Z", LogMessage);
void __fastcall LogMessage(Log* self, void* edx, const String& message, int level) {
std::string_view text = message.View();
while (!text.empty() && (text.back() == '\n' || text.back() == '\r')) {
text.remove_suffix(1);
}
re::Log("[game:%d] %.*s", level, static_cast<int>(text.size()), text.data());
LogMessage_original(self, edx, message, level);
}
} // namespace orc
+32
View File
@@ -0,0 +1,32 @@
#pragma once
// Orc::String (orcSystem.dll): the engine's narrow string, an STLport string
// with a 16-byte inline buffer. 0x18 bytes.
//
// Layout as read by Orc::Log::logMessage (orcSystem 0x10018c60) and written
// by the range initialiser (0x10002510):
// +0x00 char[16] inline text, or char* to the heap text when it is longer
// +0x10 char* end of the text
// +0x14 char* end of storage; points at +0x10 while the inline buffer is used
#include <cstddef>
#include <cstdint>
#include <string_view>
namespace orc {
struct String {
union {
char inlineText[16];
char* heapText;
};
char* end;
char* endOfStorage;
bool IsInline() const { return endOfStorage == reinterpret_cast<const char*>(&end); }
const char* Data() const { return IsInline() ? inlineText : heapText; }
size_t Size() const { return static_cast<size_t>(end - Data()); }
std::string_view View() const { return {Data(), Size()}; }
};
static_assert(sizeof(String) == 0x18);
} // namespace orc
+50
View File
@@ -0,0 +1,50 @@
#pragma once
// cDX9Base (s2render.dll): the Direct3D 9 device wrapper.
//
// _initDX(const sInitParams&) (__thiscall, ret 4) copies the parameters to
// this+0x90 and creates the device. Windowed (fullscreen byte 0): the window
// gets style 0x12c00000 (caption, frame, system menu), the size is clamped to
// GetSystemMetrics(SM_CXSCREEN/SM_CYSCREEN) minus the frame, and the window
// centred on the primary monitor; the back buffer has the clamped size,
// presentation interval default (vsync). Fullscreen: the closest display mode
// (cAdapterInfo::selectMode), window topmost, CreateDevice, SW_MAXIMIZE.
//
// flip() minimises the window and sets device flag 4 when Present returns
// D3DERR_DEVICELOST (exclusive fullscreen losing the focus).
#include <cstdint>
#include <windows.h>
#include "re/module.h"
namespace game {
// cDX9Base::sInitParams, 0x14 bytes, copied to cDX9Base+0x90.
struct DxInitParams {
HWND window; // +0x00 (cDX9Base+0x90)
uint32_t width; // +0x04 (+0x94)
uint32_t height; // +0x08 (+0x98)
uint32_t multisample; // +0x0c (+0x9c) samples, 0..2
uint8_t fullscreen; // +0x10 (+0xa0)
uint8_t vsync; // +0x11 (+0xa1) fullscreen presentation interval: default, else immediate
uint8_t unknown12[2];
};
static_assert(sizeof(DxInitParams) == 0x14);
struct DxBase;
namespace addr {
// ?_initDX@cDX9Base@@IAE_NABUsInitParams@1@@Z
inline constexpr const char* kDxBase_InitDX = "?_initDX@cDX9Base@@IAE_NABUsInitParams@1@@Z";
// Window style of windowed mode inside _initDX: mov [ebp-63Ch], and eax, cmp eax, mov [ebp-30h].
inline constexpr re::Address kInitDX_WindowStyle[] = {
{re::Module::S2Render, 0x1029ab49},
{re::Module::S2Render, 0x1029ab56},
{re::Module::S2Render, 0x1029ab5b},
{re::Module::S2Render, 0x1029ab62},
};
inline constexpr uint32_t kInitDX_WindowedStyle = 0x12c00000; // WS_CAPTION | WS_SYSMENU | WS_VISIBLE | ...
} // namespace addr
} // namespace game
+110
View File
@@ -0,0 +1,110 @@
// sacred2K.exe: starts sacred2.exe with sacred2K.dll injected.
//
// sacred2K.exe [--game-dir <dir>] [game arguments...]
//
// The game directory (the Sacred 2 Gold folder, which holds system\sacred2.exe)
// comes from --game-dir (which is then remembered in sacred2K.ini), else
// from [paths] GameDir in sacred2K.ini. The game runs in its system folder,
// as Steam starts it. Remaining arguments are passed to the game unchanged.
#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"sacred2K", 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"sacred2K.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 = WithTrailingSlash(configured);
}
if (gameDir.empty()) {
return Fail(L"No game folder configured.\n\n"
L"Run once with: sacred2K.exe --game-dir \"<Sacred 2 Gold folder>\"");
}
const std::wstring systemDir = gameDir + L"system\\";
const std::wstring gameExe = systemDir + L"sacred2.exe";
if (!FileExists(gameExe)) {
return Fail(L"system\\sacred2.exe not found in\n" + gameDir +
L"\n\nRun once with: sacred2K.exe --game-dir \"<Sacred 2 Gold folder>\"");
}
const std::wstring dllPath = launcherDir + L"sacred2K.dll";
if (!FileExists(dllPath)) {
return Fail(L"sacred2K.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, systemDir.c_str(), &startup, &process,
dllPathA, nullptr)) {
return Fail(L"Failed to start sacred2.exe (error " + std::to_wstring(GetLastError()) + L").");
}
// Hand our right to take the foreground on to the game (Windows grants it
// to the process the user just started, not to its children).
AllowSetForegroundWindow(process.dwProcessId);
WaitForSingleObject(process.hProcess, INFINITE);
DWORD exitCode = 0;
GetExitCodeProcess(process.hProcess, &exitCode);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
return static_cast<int>(exitCode);
}
+59
View File
@@ -0,0 +1,59 @@
// Borderless fullscreen (request of 2026-10-31).
//
// The game always runs in its own windowed mode, as a borderless popup the
// size of the primary monitor at the monitor's resolution, whatever the
// options say (video.fullscreen, width, height).
//
// Why: in exclusive fullscreen, the game window losing the focus while
// loading (e.g. started from a window that stays in front) makes Direct3D
// minimise it; the game then quits during start-up and crashes on exit
// (pure virtual call in s2logicai.dll's static destructors). Measured
// 2026-10-31: started through the launcher without focus, the window was
// minimised after ~8 s and the process gone after ~29 s; with focus it
// reached the main menu.
//
// How: cDX9Base::_initDX (game/render/dx9base.h) gets the parameters with the
// fullscreen flag cleared and the monitor size; its windowed-mode window
// style 0x12c00000 is patched to WS_POPUP | WS_VISIBLE, so the game's own
// windowed code sizes the window to the screen without a frame and puts it
// at (0,0).
#include <windows.h>
#include "game/render/dx9base.h"
#include "re/hook.h"
#include "re/log.h"
#include "re/patch.h"
namespace game {
bool __fastcall DxBase_InitDX(DxBase* self, void* edx, const DxInitParams& params);
RE_HOOK_EXPORT(re::Module::S2Render, addr::kDxBase_InitDX, DxBase_InitDX);
bool __fastcall DxBase_InitDX(DxBase* self, void* edx, const DxInitParams& params) {
DxInitParams borderless = params;
borderless.fullscreen = 0;
borderless.width = static_cast<uint32_t>(GetSystemMetrics(SM_CXSCREEN));
borderless.height = static_cast<uint32_t>(GetSystemMetrics(SM_CYSCREEN));
re::Log("borderless: game asks %ux%u fullscreen %u vsync %u multisample %u, using %ux%u windowed",
params.width, params.height, params.fullscreen, params.vsync, params.multisample, borderless.width,
borderless.height);
return DxBase_InitDX_original(self, edx, borderless);
}
} // namespace game
namespace {
void PatchWindowStyle() {
constexpr uint32_t kBorderlessStyle = WS_POPUP | WS_VISIBLE;
const int failed = re::PatchAll(game::addr::kInitDX_WindowStyle, game::addr::kInitDX_WindowedStyle,
kBorderlessStyle);
if (failed) {
re::Log("borderless: %d window style patch(es) failed", failed);
}
}
} // namespace
RE_STARTUP(Borderless, PatchWindowStyle);
+59
View File
@@ -0,0 +1,59 @@
#include "re/config.h"
#include <cstring>
#include <iterator>
#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;
}
std::wstring ModuleDirectory() {
return g_path.substr(0, g_path.find_last_of(L"\\/") + 1);
}
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()));
}
std::string ConfigString(const char* section, const char* key, const char* fallback) {
if (g_path.empty()) {
return fallback;
}
wchar_t value[256];
GetPrivateProfileStringW(Widen(section).c_str(), Widen(key).c_str(), Widen(fallback).c_str(), value,
static_cast<DWORD>(std::size(value)), g_path.c_str());
char narrow[256];
WideCharToMultiByte(CP_UTF8, 0, value, -1, narrow, sizeof(narrow), nullptr, nullptr);
return narrow;
}
void SetConfigString(const char* section, const char* key, const char* value) {
if (g_path.empty()) {
return;
}
wchar_t wide[256];
MultiByteToWideChar(CP_UTF8, 0, value, -1, wide, static_cast<int>(std::size(wide)));
WritePrivateProfileStringW(Widen(section).c_str(), Widen(key).c_str(), wide, g_path.c_str());
}
} // namespace re
+17
View File
@@ -0,0 +1,17 @@
#pragma once
// sacred2K.ini next to sacred2K.dll / sacred2K.exe.
#include <string>
namespace re {
void SetConfigPath(const std::wstring& path);
const std::wstring& ConfigPath();
// Folder of sacred2K.dll (with trailing separator), derived from the config path.
std::wstring ModuleDirectory();
int ConfigInt(const char* section, const char* key, int fallback);
std::string ConfigString(const char* section, const char* key, const char* fallback);
// Writes `key=value` into the ini (value as UTF-8).
void SetConfigString(const char* section, const char* key, const char* value);
} // namespace re
+185
View File
@@ -0,0 +1,185 @@
// Crash report: on an unhandled exception, writes sacred2K_crash.txt (code,
// address as module + offset, registers, return addresses found on the stack)
// and sacred2K_crash.dmp next to the DLL. The game may install its own
// filter later; SetUnhandledExceptionFilter is hooked so ours stays first and
// passes the crash on to the game's afterwards.
//
// Hangs: a watchdog thread writes sacred2K_hang.dmp (all threads) when the
// engine tick (re::EngineHeartbeat) has stopped for kHangMs while the game
// window is in front; once per hang. Inactive until a game tick calls
// EngineHeartbeat (none hooked yet).
#include "re/crash.h"
#include <cstdio>
#include <string>
#include <windows.h>
#include <dbghelp.h>
#include "re/hook.h"
#include "re/log.h"
namespace re {
namespace {
std::wstring g_dir;
LPTOP_LEVEL_EXCEPTION_FILTER g_gameFilter = nullptr;
volatile LONG g_reported = 0;
// "module+offset" for a code address.
void DescribeAddress(uintptr_t address, char* out, size_t size) {
HMODULE module = nullptr;
char path[MAX_PATH] = "?";
if (GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCSTR>(address), &module)) {
GetModuleFileNameA(module, path, MAX_PATH);
const char* name = strrchr(path, '\\');
snprintf(out, size, "%08x %s+%x", static_cast<unsigned>(address), name ? name + 1 : path,
static_cast<unsigned>(address - reinterpret_cast<uintptr_t>(module)));
} else {
snprintf(out, size, "%08x", static_cast<unsigned>(address));
}
}
bool IsCode(uintptr_t address) {
MEMORY_BASIC_INFORMATION info;
if (!VirtualQuery(reinterpret_cast<void*>(address), &info, sizeof(info)) || info.State != MEM_COMMIT) {
return false;
}
return (info.Protect & (PAGE_EXECUTE | PAGE_EXECUTE_READ | PAGE_EXECUTE_READWRITE)) != 0;
}
void WriteReport(EXCEPTION_POINTERS* info) {
FILE* file = _wfopen((g_dir + L"sacred2K_crash.txt").c_str(), L"w");
if (!file) {
return;
}
const EXCEPTION_RECORD& record = *info->ExceptionRecord;
const CONTEXT& c = *info->ContextRecord;
char where[MAX_PATH + 32];
DescribeAddress(reinterpret_cast<uintptr_t>(record.ExceptionAddress), where, sizeof(where));
fprintf(file, "exception %08lx at %s, thread %lu\n", record.ExceptionCode, where, GetCurrentThreadId());
if (record.ExceptionCode == EXCEPTION_ACCESS_VIOLATION && record.NumberParameters >= 2) {
fprintf(file, "%s address %08x\n", record.ExceptionInformation[0] ? "writing" : "reading",
static_cast<unsigned>(record.ExceptionInformation[1]));
}
fprintf(file, "eax %08lx ebx %08lx ecx %08lx edx %08lx esi %08lx edi %08lx ebp %08lx esp %08lx\n", c.Eax, c.Ebx,
c.Ecx, c.Edx, c.Esi, c.Edi, c.Ebp, c.Esp);
// Frame pointer chain (our debug build and most game code keep ebp).
fprintf(file, "frames (ebp chain):\n");
uintptr_t frame = c.Ebp;
for (int i = 0; i < 64 && frame; ++i) {
MEMORY_BASIC_INFORMATION mem;
if (!VirtualQuery(reinterpret_cast<void*>(frame), &mem, sizeof(mem)) || mem.State != MEM_COMMIT) {
break;
}
const auto* slots = reinterpret_cast<const uintptr_t*>(frame);
DescribeAddress(slots[1], where, sizeof(where));
fprintf(file, " %s\n", where);
if (slots[0] <= frame) {
break;
}
frame = slots[0];
}
fprintf(file, "code addresses on the stack (innermost first; may include data):\n");
const auto* stack = reinterpret_cast<const uintptr_t*>(c.Esp);
int found = 0;
for (int i = 0; i < 4096 && found < 48; ++i) {
MEMORY_BASIC_INFORMATION mem;
if (!VirtualQuery(stack + i, &mem, sizeof(mem)) || mem.State != MEM_COMMIT) {
break;
}
const uintptr_t value = stack[i];
if (IsCode(value)) {
DescribeAddress(value, where, sizeof(where));
fprintf(file, " [esp+%04x] %s\n", i * 4, where);
++found;
}
}
fclose(file);
HANDLE dump = CreateFileW((g_dir + L"sacred2K_crash.dmp").c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL, nullptr);
if (dump != INVALID_HANDLE_VALUE) {
MINIDUMP_EXCEPTION_INFORMATION exception = {GetCurrentThreadId(), info, FALSE};
MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), dump, MiniDumpWithIndirectlyReferencedMemory,
&exception, nullptr, nullptr);
CloseHandle(dump);
}
}
constexpr DWORD kHangMs = 10000;
volatile LONG g_lastBeat = 0; // GetTickCount of the last engine tick, 0: no game running
bool GameInFront() {
DWORD process = 0;
GetWindowThreadProcessId(GetForegroundWindow(), &process);
return process == GetCurrentProcessId();
}
void WriteHangDump() {
HANDLE dump = CreateFileW((g_dir + L"sacred2K_hang.dmp").c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL, nullptr);
if (dump != INVALID_HANDLE_VALUE) {
MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), dump,
static_cast<MINIDUMP_TYPE>(MiniDumpWithIndirectlyReferencedMemory | MiniDumpWithThreadInfo),
nullptr, nullptr, nullptr);
CloseHandle(dump);
}
}
DWORD WINAPI Watchdog(void*) {
bool reported = false;
for (;;) {
Sleep(1000);
const DWORD beat = static_cast<DWORD>(g_lastBeat);
// Signed: a tick may land between reading the beat and the clock.
if (beat == 0 || static_cast<LONG>(GetTickCount() - beat) < static_cast<LONG>(kHangMs)) {
reported = false;
continue;
}
if (!reported && GameInFront()) {
reported = true;
Log("hang: no engine tick for %lu ms, writing sacred2K_hang.dmp", GetTickCount() - beat);
WriteHangDump();
Log("hang: dump written");
}
}
}
LONG WINAPI Filter(EXCEPTION_POINTERS* info) {
if (InterlockedExchange(&g_reported, 1) == 0) {
WriteReport(info);
}
return g_gameFilter ? g_gameFilter(info) : EXCEPTION_CONTINUE_SEARCH;
}
} // namespace
void InstallCrashHandler(const std::wstring& dir) {
g_dir = dir;
g_gameFilter = SetUnhandledExceptionFilter(&Filter);
if (HANDLE thread = CreateThread(nullptr, 0, &Watchdog, nullptr, 0, nullptr)) {
CloseHandle(thread);
}
}
void EngineHeartbeat(bool running) {
InterlockedExchange(&g_lastBeat, running ? static_cast<LONG>(GetTickCount() | 1) : 0);
}
} // namespace re
// The game's own filter goes behind ours.
LPTOP_LEVEL_EXCEPTION_FILTER WINAPI Hooked_SetUnhandledExceptionFilter(LPTOP_LEVEL_EXCEPTION_FILTER filter);
RE_HOOK_API(SetUnhandledExceptionFilter, Hooked_SetUnhandledExceptionFilter);
LPTOP_LEVEL_EXCEPTION_FILTER WINAPI Hooked_SetUnhandledExceptionFilter(LPTOP_LEVEL_EXCEPTION_FILTER filter) {
if (filter == &re::Filter) {
return Hooked_SetUnhandledExceptionFilter_original(filter);
}
LPTOP_LEVEL_EXCEPTION_FILTER previous = re::g_gameFilter;
re::g_gameFilter = filter;
return previous;
}
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include <string>
namespace re {
// Writes sacred2K_crash.txt / .dmp into `dir` when the game crashes, and
// sacred2K_hang.dmp when the engine stops ticking (see EngineHeartbeat).
void InstallCrashHandler(const std::wstring& dir);
// The engine ticked (running), or the game ended (not running).
void EngineHeartbeat(bool running);
} // namespace re
+66
View File
@@ -0,0 +1,66 @@
// sacred2K.dll entry point. Loaded into sacred2.exe by the launcher before
// the game's own entry point runs. The game's DLLs (orcSystem, s2core,
// s2logic, ...) are static imports of sacred2.exe, so they are mapped and
// relocated by then and every hook is in place before any game code runs.
#include <string>
#include <windows.h>
#include <detours.h>
#include "re/config.h"
#include "re/crash.h"
#include "re/hook.h"
#include "re/log.h"
#include "re/module.h"
#include "re/patch.h"
namespace {
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);
}
void Startup(HMODULE module) {
std::wstring dir = ModuleDir(module);
re::OpenLog((dir + L"sacred2K.log").c_str());
re::SetConfigPath(dir + L"sacred2K.ini");
re::Log("sacred2K loaded");
re::InstallCrashHandler(dir);
if (!re::IsSupportedGameBuild()) {
re::Log("unsupported game build, no hooks installed");
MessageBoxW(nullptr,
L"These Sacred 2 files are not the supported build\n"
L"(Sacred 2 Gold with Community Patch 2.65.1).\n"
L"sacred2K will not patch them; the game runs unmodified.",
L"sacred2K", 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();
re::RunStartupFunctions();
}
} // 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;
}
+121
View File
@@ -0,0 +1,121 @@
#include "re/hook.h"
#include <cstdio>
#include <vector>
#include <windows.h>
#include <detours.h>
#include "re/config.h"
#include "re/log.h"
namespace re {
namespace {
struct HookDef {
const char* name;
// Where: a module address, a module export, or (module Count) a running
// address already in *original.
Module module;
uint32_t va;
const char* exportName;
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;
}
// "s2logic.dll 0x10123450" / "orcSystem.dll ?name@..." for the log.
void Describe(const HookDef& hook, char* out, size_t size) {
if (hook.module == Module::Count) {
snprintf(out, size, "api %p", *hook.original);
} else if (hook.exportName) {
snprintf(out, size, "%s %s", ModuleName(hook.module), hook.exportName);
} else {
snprintf(out, size, "%s 0x%08x", ModuleName(hook.module), hook.va);
}
}
uintptr_t Target(const HookDef& hook) {
if (hook.module == Module::Count) {
return reinterpret_cast<uintptr_t>(*hook.original);
}
return hook.exportName ? ModuleExport(hook.module, hook.exportName) : Address{hook.module, hook.va}.Resolve();
}
} // namespace
HookRegistration::HookRegistration(const char* name, Address address, void** original, void* replacement) {
Registry().push_back({name, address.module, address.va, nullptr, original, replacement});
}
HookRegistration::HookRegistration(const char* name, Module module, const char* exportName, void** original,
void* replacement) {
Registry().push_back({name, module, 0, exportName, original, replacement});
}
HookRegistration::HookRegistration(const char* name, void** original, void* replacement) {
Registry().push_back({name, Module::Count, 0, nullptr, original, replacement});
}
int InstallHooks() {
int failed = 0;
int installed = 0;
for (const HookDef& hook : Registry()) {
char where[256];
Describe(hook, where, sizeof(where));
if (!ConfigInt("hooks", hook.name, 1)) {
Log("hook %-24s %s disabled by config", hook.name, where);
continue;
}
const uintptr_t target = Target(hook);
if (!target) {
++failed;
Log("hook %-24s %s FAILED (not found)", hook.name, where);
continue;
}
*hook.original = reinterpret_cast<void*>(target);
// 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 %s FAILED (error %ld)", hook.name, where, error);
}
}
Log("%d hook(s) installed, %d failed", installed, failed);
return failed;
}
bool AttachRuntimeHook(const char* name, void** original, void* replacement) {
const void* target = *original;
DetourTransactionBegin();
DetourUpdateThread(GetCurrentThread());
LONG error = DetourAttach(original, replacement);
if (error == NO_ERROR) {
error = DetourTransactionCommit();
} else {
DetourTransactionAbort();
}
if (error != NO_ERROR) {
Log("hook %-24s %p FAILED (error %ld)", name, target, error);
return false;
}
Log("hook %-24s %p attached at runtime", name, target);
return true;
}
} // namespace re
+73
View File
@@ -0,0 +1,73 @@
#pragma once
// Hook registry: replaces game functions with our code.
//
// Usage (in a game/*.cpp file):
//
// namespace game {
// void __fastcall CreatureTick(Creature* self, void* edx, float dt); // declare ours first
// RE_HOOK((re::Address{re::Module::S2Logic, 0x10123450}), CreatureTick); // defines CreatureTick_original
// void __fastcall CreatureTick(Creature* self, void* edx, float dt) {
// ... // may call CreatureTick_original(...)
// }
// }
//
// Exported functions are better hooked by their decorated name, which stays
// valid across builds:
//
// RE_HOOK_EXPORT(re::Module::OrcSystem, "?logMessage@Log@Orc@@UAEXABVString@2@W4LogMessageLevel@2@@Z", LogMessage);
//
// The signature, including the calling convention, must match the original
// exactly (`__cdecl`, `__stdcall`, `__thiscall` via `__fastcall(self, edx, ...)`).
// `fn_original` is null until the hooks are installed. Hooks can be disabled
// individually at runtime in sacred2K.ini:
//
// [hooks]
// LogMessage=0
#include <cstdint>
#include "re/module.h"
namespace re {
class HookRegistration {
public:
// Hook at a module address.
HookRegistration(const char* name, Address address, void** original, void* replacement);
// Hook at an export of a module.
HookRegistration(const char* name, Module module, const char* exportName, void** original, void* replacement);
// Hook at a running address known at static initialisation (Windows APIs).
HookRegistration(const char* name, 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();
// Hooks a function only known at runtime (e.g. a COM method read from a
// vtable). `*original` must hold the function's address; it receives the
// trampoline. Returns false (and logs) on failure.
bool AttachRuntimeHook(const char* name, void** original, void* replacement);
} // 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 sacred2K.ini under the API's name.
#define RE_HOOK_API(api, replacement) \
static decltype(&replacement) replacement##_original = &api; \
static ::re::HookRegistration replacement##_registration( \
#api, reinterpret_cast<void**>(&replacement##_original), \
reinterpret_cast<void*>(&replacement))
#define RE_HOOK(address, fn) \
static decltype(&fn) fn##_original = nullptr; \
static ::re::HookRegistration fn##_registration( \
#fn, address, reinterpret_cast<void**>(&fn##_original), \
reinterpret_cast<void*>(&fn))
#define RE_HOOK_EXPORT(module, exportName, fn) \
static decltype(&fn) fn##_original = nullptr; \
static ::re::HookRegistration fn##_registration( \
#fn, module, exportName, 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
// sacred2K's own log: sacred2K.log next to sacred2K.dll, plus OutputDebugString.
namespace re {
void OpenLog(const wchar_t* path);
void Log(const char* fmt, ...);
} // namespace re
+112
View File
@@ -0,0 +1,112 @@
#include "re/module.h"
#include <cstdio>
#include <cstring>
#include <iterator>
#include <vector>
#include <windows.h>
#include <bcrypt.h>
#include "re/log.h"
namespace re {
namespace {
struct ModuleInfo {
const wchar_t* file;
const char* name;
uint32_t preferredBase;
// SHA-256 of the supported file: Sacred 2 Gold (Steam) with Community
// Patch 2.65.1. The patch kept the original PE timestamps, so the
// headers alone don't tell builds apart.
const char* sha256;
};
constexpr ModuleInfo kModules[] = {
{L"sacred2.exe", "sacred2.exe", 0x00400000, "37b6d077ce9ff7c1b539e9e17db5351cb3a22c02827b36cc327b1f5fc0900090"},
{L"orcSystem.dll", "orcSystem.dll", 0x10000000, "43b34c8c5a825f6f485f8cf8122054faff4075a33f44554901d8de167c7389b3"},
{L"s2core.dll", "s2core.dll", 0x10000000, "2b9b8e05746f7a6f439746c3e26919a983ec794bae6bbcfe25f54cf0d05c13ec"},
{L"s2logic.dll", "s2logic.dll", 0x10000000, "9370aa81d3b1ca10f603a0758d2520bf42c20f8f5605966aab046fd236d5ed1b"},
{L"s2logicai.dll", "s2logicai.dll", 0x10000000, "26b41eaa9e306ba9e9b51a1d33d011d6df7934e14bf21c289876516b393ebe6e"},
{L"s2render.dll", "s2render.dll", 0x10000000, "09bd4975a2b01816f9a20db4a9365ee944611a78612e72a6abfae125c2723d75"},
};
static_assert(std::size(kModules) == static_cast<size_t>(Module::Count));
const ModuleInfo& Info(Module module) {
return kModules[static_cast<size_t>(module)];
}
HMODULE Handle(Module module) {
return module == Module::Sacred2 ? GetModuleHandleW(nullptr) : GetModuleHandleW(Info(module).file);
}
// Hex SHA-256 of a file; empty on error. bcrypt is a static import, so this
// is safe to call from DllMain.
bool FileSha256(const wchar_t* path, char (&hex)[65]) {
HANDLE file = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
if (file == INVALID_HANDLE_VALUE) {
return false;
}
BCRYPT_HASH_HANDLE hash = nullptr;
bool ok = BCryptCreateHash(BCRYPT_SHA256_ALG_HANDLE, &hash, nullptr, 0, nullptr, 0, 0) == 0;
std::vector<uint8_t> buffer(1 << 20);
DWORD read = 0;
while (ok && ReadFile(file, buffer.data(), static_cast<DWORD>(buffer.size()), &read, nullptr) && read > 0) {
ok = BCryptHashData(hash, buffer.data(), read, 0) == 0;
}
uint8_t digest[32];
ok = ok && BCryptFinishHash(hash, digest, sizeof(digest), 0) == 0;
if (hash) {
BCryptDestroyHash(hash);
}
CloseHandle(file);
for (size_t i = 0; ok && i < sizeof(digest); ++i) {
snprintf(hex + 2 * i, 3, "%02x", digest[i]);
}
return ok;
}
} // namespace
const char* ModuleName(Module module) {
return Info(module).name;
}
uintptr_t ModuleBase(Module module) {
return reinterpret_cast<uintptr_t>(Handle(module));
}
uintptr_t Address::Resolve() const {
const uintptr_t base = ModuleBase(module);
return base ? base + (va - Info(module).preferredBase) : 0;
}
uintptr_t ModuleExport(Module module, const char* name) {
HMODULE handle = Handle(module);
return handle ? reinterpret_cast<uintptr_t>(GetProcAddress(handle, name)) : 0;
}
bool IsSupportedGameBuild() {
bool supported = true;
for (size_t i = 0; i < std::size(kModules); ++i) {
const auto module = static_cast<Module>(i);
HMODULE handle = Handle(module);
if (!handle) {
Log("module %-14s not loaded", Info(module).name);
supported = false;
continue;
}
wchar_t path[MAX_PATH];
GetModuleFileNameW(handle, path, MAX_PATH);
char hex[65] = {};
const bool hashed = FileSha256(path, hex);
const bool match = hashed && strcmp(hex, Info(module).sha256) == 0;
Log("module %-14s at 0x%08x %s", Info(module).name, static_cast<unsigned>(ModuleBase(module)),
match ? "ok" : hashed ? hex : "file not readable");
supported = supported && match;
}
return supported;
}
} // namespace re
+54
View File
@@ -0,0 +1,54 @@
#pragma once
// The game's modules and addresses inside them.
//
// Sacred 2 is split into sacred2.exe and several DLLs that all prefer image
// base 0x10000000, so the loader relocates all but one of them. Addresses in
// this project are written as in Ghidra, i.e. relative to the module's
// preferred base, together with the module:
//
// constexpr re::Address kLogMessage{re::Module::OrcSystem, 0x10018c60};
//
// and resolved to the running address once the module is loaded.
#include <cstddef>
#include <cstdint>
namespace re {
enum class Module : uint8_t {
Sacred2, // sacred2.exe: client, UI
OrcSystem, // orcSystem.dll: Ascaron's engine base (Orc::String, Log, files, ...)
S2Core, // s2core.dll: shared types, kernel events, world positions
S2Logic, // s2logic.dll: game logic (creatures, items, quests, ...)
S2LogicAI, // s2logicai.dll: AI
S2Render, // s2render.dll: renderer
Count
};
struct Address {
Module module;
uint32_t va; // relative to the module's preferred image base (as in Ghidra)
// Running address; 0 if the module is not loaded.
uintptr_t Resolve() const;
};
const char* ModuleName(Module module);
// Running base of a module; 0 if not loaded.
uintptr_t ModuleBase(Module module);
// Address of an export of a module by its (decorated) name; 0 if not found.
uintptr_t ModuleExport(Module module, const char* name);
// Checks every module against the supported build (file SHA-256) and logs
// the result. False if any differs: then no hook may be installed.
bool IsSupportedGameBuild();
// Typed pointer to a game function, for calling it.
template <typename Fn>
Fn GameFunction(Address address) {
return reinterpret_cast<Fn>(address.Resolve());
}
} // namespace re
+203
View File
@@ -0,0 +1,203 @@
#include "re/patch.h"
#include <cstring>
#include <vector>
#include <windows.h>
#include "re/log.h"
namespace re {
namespace {
struct Startup {
const char* name;
void (*function)();
};
std::vector<Startup>& Startups() {
static std::vector<Startup> startups;
return startups;
}
// Longest x86 instruction is 15 bytes; an absolute operand starts within it.
constexpr size_t kOperandSearch = 11;
// Log prefix for a patch location.
#define PATCH_FMT "patch %s 0x%08x: "
#define PATCH_ARGS(a) ModuleName((a).module), (a).va
// Running code at `address`; null (logged) if its module is not loaded.
const uint8_t* Code(Address address) {
const uintptr_t running = address.Resolve();
if (!running) {
Log(PATCH_FMT "module not loaded, skipped", PATCH_ARGS(address));
}
return reinterpret_cast<const uint8_t*>(running);
}
uint32_t Running32(const void* pointer) {
return static_cast<uint32_t>(reinterpret_cast<uintptr_t>(pointer));
}
} // namespace
bool WriteCode(uintptr_t address, const void* bytes, size_t size) {
void* target = reinterpret_cast<void*>(address);
DWORD oldProtect;
if (!VirtualProtect(target, size, PAGE_EXECUTE_READWRITE, &oldProtect)) {
Log("patch %p: VirtualProtect failed (%lu)", target, GetLastError());
return false;
}
std::memcpy(target, bytes, size);
VirtualProtect(target, size, oldProtect, &oldProtect);
FlushInstructionCache(GetCurrentProcess(), target, size);
return true;
}
bool PatchInstructionDword(Address instruction, uint32_t from, uint32_t to) {
const uint8_t* code = Code(instruction);
if (!code) {
return false;
}
for (size_t offset = 1; offset + 4 <= kOperandSearch; ++offset) {
uint32_t operand;
std::memcpy(&operand, code + offset, 4);
if (operand == from) {
return WriteCode(reinterpret_cast<uintptr_t>(code + offset), &to, 4);
}
}
Log(PATCH_FMT "operand 0x%08x not found, skipped", PATCH_ARGS(instruction), from);
return false;
}
bool PatchInstructionByte(Address instruction, size_t offset, uint8_t from, uint8_t to) {
const uint8_t* code = Code(instruction);
if (!code) {
return false;
}
if (code[offset] != from) {
Log(PATCH_FMT "expected byte 0x%02x at +%u, found 0x%02x, skipped", PATCH_ARGS(instruction), from,
static_cast<unsigned>(offset), code[offset]);
return false;
}
return WriteCode(reinterpret_cast<uintptr_t>(code + offset), &to, 1);
}
bool ReplaceCode(Address address, const void* from, const void* to, size_t size) {
const uint8_t* code = Code(address);
if (!code) {
return false;
}
if (std::memcmp(code, from, size) != 0) {
Log(PATCH_FMT "expected %u other bytes (found %02x %02x ...), skipped", PATCH_ARGS(address),
static_cast<unsigned>(size), code[0], size > 1 ? code[1] : 0);
return false;
}
return WriteCode(reinterpret_cast<uintptr_t>(code), to, size);
}
namespace {
// Redirects the rel32 operand at instruction + opcodeLength (instruction
// length opcodeLength + 4) from `from` to `to`.
bool RedirectRel32(Address instruction, const uint8_t* code, size_t opcodeLength, Address from, const void* to) {
const uint32_t end = Running32(code) + static_cast<uint32_t>(opcodeLength) + 4;
int32_t rel;
std::memcpy(&rel, code + opcodeLength, 4);
if (end + static_cast<uint32_t>(rel) != static_cast<uint32_t>(from.Resolve())) {
Log(PATCH_FMT "expected a branch to %s 0x%08x, skipped", PATCH_ARGS(instruction), ModuleName(from.module),
from.va);
return false;
}
const int32_t newRel = static_cast<int32_t>(Running32(to) - end);
return WriteCode(reinterpret_cast<uintptr_t>(code + opcodeLength), &newRel, 4);
}
} // namespace
bool RedirectCall(Address instruction, Address from, const void* to) {
const uint8_t* code = Code(instruction);
if (!code) {
return false;
}
if (code[0] != 0xe8) {
Log(PATCH_FMT "expected a call, skipped", PATCH_ARGS(instruction));
return false;
}
return RedirectRel32(instruction, code, 1, from, to);
}
bool RedirectJcc(Address instruction, Address from, const void* to) {
const uint8_t* code = Code(instruction);
if (!code) {
return false;
}
if (code[0] != 0x0f || (code[1] & 0xf0) != 0x80) {
Log(PATCH_FMT "expected a conditional jump, skipped", PATCH_ARGS(instruction));
return false;
}
return RedirectRel32(instruction, code, 2, from, to);
}
int ReplaceAll(const CodeReplacement* replacements, size_t count) {
int failed = 0;
for (size_t i = 0; i < count; ++i) {
const CodeReplacement& r = replacements[i];
failed += !ReplaceCode(r.address, r.from, r.to, r.size);
}
return failed;
}
int PatchAll(const BytePatch* patches, size_t count, uint8_t to) {
int failed = 0;
for (size_t i = 0; i < count; ++i) {
failed += !PatchInstructionByte(patches[i].instruction, patches[i].offset, patches[i].from, to);
}
return failed;
}
int PatchAll(const Address* instructions, size_t count, uint32_t from, uint32_t to) {
int failed = 0;
for (size_t i = 0; i < count; ++i) {
failed += !PatchInstructionDword(instructions[i], from, to);
}
return failed;
}
bool RedirectOperand(Address instruction, Address from, const void* to) {
const uintptr_t running = from.Resolve();
if (!running) {
Log(PATCH_FMT "operand module %s not loaded, skipped", PATCH_ARGS(instruction), ModuleName(from.module));
return false;
}
return PatchInstructionDword(instruction, static_cast<uint32_t>(running), Running32(to));
}
bool PatchPushImm32(Address instruction, uint32_t expected, uint32_t value) {
const uint8_t* code = Code(instruction);
if (!code) {
return false;
}
uint32_t current;
std::memcpy(&current, code + 1, 4);
if (code[0] != 0x68 || current != expected) {
Log(PATCH_FMT "expected push 0x%08x, found %02x %08x, skipped", PATCH_ARGS(instruction), expected, code[0],
current);
return false;
}
return WriteCode(reinterpret_cast<uintptr_t>(code + 1), &value, 4);
}
StartupRegistration::StartupRegistration(const char* name, void (*function)()) {
Startups().push_back({name, function});
}
void RunStartupFunctions() {
for (const Startup& startup : Startups()) {
Log("startup: %s", startup.name);
startup.function();
}
}
} // namespace re
+88
View File
@@ -0,0 +1,88 @@
#pragma once
// Verified in-place patches of game code. Every function checks that the
// bytes it expects are present before writing, logs and refuses otherwise.
//
// Locations are module addresses (re::Address, as in Ghidra). Absolute
// addresses inside the code (operands, call targets) were relocated with
// their module, so they are given as re::Address too; plain immediates and
// struct offsets stay uint32_t.
#include <cstddef>
#include <cstdint>
#include "re/module.h"
namespace re {
// Writes to a running address (e.g. one already resolved).
bool WriteCode(uintptr_t address, const void* bytes, size_t size);
// The instruction at `instruction` has the absolute memory operand `from`
// (e.g. `fmul st, dword ptr [from]`); make it read `to` instead.
bool RedirectOperand(Address instruction, Address from, const void* to);
// Replace a 32-bit displacement/immediate `from` inside the instruction at
// `instruction` by `to` (e.g. a struct field offset in `[ebp+ecx*8+3FF2Ch]`).
bool PatchInstructionDword(Address instruction, uint32_t from, uint32_t to);
// Replace the byte `from` at `instruction + offset` (an imm8 or disp8) by `to`.
bool PatchInstructionByte(Address instruction, size_t offset, uint8_t from, uint8_t to);
// Replace the code bytes `from` at `address` by `to` (same length), e.g. a
// whole short instruction. The bytes must not contain relocated addresses.
bool ReplaceCode(Address address, const void* from, const void* to, size_t size);
// The `call rel32` at `instruction` (E8) calls `from`; make it call `to`.
bool RedirectCall(Address instruction, Address from, const void* to);
// The near conditional jump at `instruction` (0F 8x rel32) goes to `from`;
// make it go to `to`.
bool RedirectJcc(Address instruction, Address from, const void* to);
// A code replacement, for tables.
struct CodeReplacement {
Address address;
uint8_t from[8];
uint8_t to[8];
uint8_t size;
};
int ReplaceAll(const CodeReplacement* replacements, size_t count);
template <size_t N>
int ReplaceAll(const CodeReplacement (&replacements)[N]) {
return ReplaceAll(replacements, N);
}
// One byte operand of an instruction, for tables of byte patches.
struct BytePatch {
Address instruction;
uint8_t offset; // of the byte within the instruction
uint8_t from;
};
// Apply the same change to a table of instructions; return the number that
// failed (each failure is logged).
int PatchAll(const BytePatch* patches, size_t count, uint8_t to);
int PatchAll(const Address* instructions, size_t count, uint32_t from, uint32_t to);
template <size_t N>
int PatchAll(const BytePatch (&patches)[N], uint8_t to) {
return PatchAll(patches, N, to);
}
template <size_t N>
int PatchAll(const Address (&instructions)[N], uint32_t from, uint32_t to) {
return PatchAll(instructions, N, from, to);
}
// `push imm32` (68 xx xx xx xx) at `instruction`: replace `expected` by `value`.
bool PatchPushImm32(Address instruction, uint32_t expected, uint32_t value);
// Code that must run once at startup, after the hooks are installed.
class StartupRegistration {
public:
StartupRegistration(const char* name, void (*function)());
};
void RunStartupFunctions();
} // namespace re
#define RE_STARTUP(name, function) \
static ::re::StartupRegistration name##_startup(#name, &function)
+4
View File
@@ -0,0 +1,4 @@
LIBRARY sacred2K
EXPORTS
; Detours requires ordinal 1 on any DLL injected via DetourCreateProcessWithDll.
DetourFinishHelperProcess @1 NONAME
+45
View File
@@ -0,0 +1,45 @@
# tools
Node tools need `npm install` here once (iced-x86). Module paths are passed
explicitly (`<Sacred 2 Gold>\system\s2logic.dll`, ...); VAs are at the
module's preferred base, as in Ghidra. In Git Bash, arguments starting with
`/` are rewritten as paths: set `MSYS_NO_PATHCONV=1` for `/regex/` and pass
module paths as `D:/...`.
| Tool | Purpose |
|---|---|
| `exports.mjs <module> [regex]` | exported functions/data with VAs, by decorated name (`exports.mjs s2logic.dll "@cQuestMgr@@"`) |
| `exports.mjs <module> 0xVA` | the nearest export at or before a VA |
| `disasm.mjs <module> <va> [n]` | disassemble at an address, ignoring Ghidra's function boundaries |
| `find-imm.mjs <module> <value...>` | every instruction using an immediate value (e.g. `1024 768`) |
| `find-imm.mjs <module> /regex/` | every instruction whose text matches |
| `rtti.mjs <module> <Class>` | vtables of an MSVC class via RTTI |
| `decompile.ps1 <module> <addr\|symbol...>` | decompile from the persistent Ghidra project `ghidra/` |
| `rename.ps1` | apply `names.txt` (`module 0xADDR Name`) to the Ghidra project |
| `game-input.ps1` | dot-source: `Start-Sacred2`, `Focus-Sacred2`, `Get-GameWindows`, clicks, keys, `Save-Screen`, `Stop-Sacred2` |
## Driving the game
Started from a background process, the game window doesn't get the
foreground; `Focus-Sacred2` (synthetic Alt + `SetForegroundWindow`) brings it
up. The intro videos run ~50 s before the main menu. Synthetic clicks reach
the menu only sometimes (2026-10-31: "Beenden" opened its dialog once, the
dialog's "Ja" never reacted); `$game.CloseMainWindow()` quits the game
cleanly. Don't touch mouse/keyboard while a script drives the game.
## Persistent Ghidra project (`ghidra/`, not in git)
One project `sacred2` with a program per module, created once (Ghidra's own
analysis; exports give most functions their names and MSVC 8 RTTI is
recognised):
```powershell
$env:GHIDRA_HEADLESS_MAXMEM = "12G"
$sys = "<Sacred 2 Gold>\system"
& "$env:GHIDRA_INSTALL_DIR\support\analyzeHeadless.bat" ghidra sacred2 -import "$sys\sacred2.exe" "$sys\orcSystem.dll" "$sys\s2core.dll" "$sys\s2logic.dll" "$sys\s2logicai.dll" "$sys\s2render.dll" -analysisTimeoutPerFile 7200
```
```powershell
.\tools\decompile.ps1 orcSystem.dll 0x10018c60 -ReadOnly
.\tools\decompile.ps1 s2logic.dll "?advanceTime@cQuestMgr@@QAEXM@Z" -ReadOnly
```
+31
View File
@@ -0,0 +1,31 @@
# Decompile from the persistent Ghidra project (ghidra/sacred2.gpr).
# .\tools\decompile.ps1 s2logic.dll 0x10123450 0x10123a80 [-Out file.c] [-ReadOnly]
# .\tools\decompile.ps1 orcSystem.dll "?logMessage@Log@Orc@@UAEXABVString@2@W4LogMessageLevel@2@@Z"
# Addresses are VAs at the module's preferred base (as Ghidra shows them);
# an argument that is not a number is looked up as a (decorated) symbol name.
# Prints the C to stdout (and keeps it in -Out, default ghidra\last.c).
# Without -ReadOnly, functions created for vtable-only methods are saved.
[CmdletBinding(PositionalBinding = $false)]
param(
[Parameter(Mandatory, Position = 0)][string]$Module,
[Parameter(Mandatory, ValueFromRemainingArguments)][string[]]$Addresses,
[string]$Out = "",
[switch]$ReadOnly
)
$ErrorActionPreference = "Stop"
$root = Split-Path $PSScriptRoot
if (-not $Out) { $Out = Join-Path $root "ghidra\last.c" }
$ghidra = if ($env:GHIDRA_INSTALL_DIR) { $env:GHIDRA_INSTALL_DIR } else { "E:\Coding\Ghidra\ghidra_12.1.4_PUBLIC" }
if (-not $env:JAVA_HOME) { $env:JAVA_HOME = "C:\Program Files\Java\jdk-21" }
$arguments = @(
(Join-Path $root "ghidra"), "sacred2",
"-process", $Module, "-noanalysis",
"-scriptPath", (Join-Path $PSScriptRoot "ghidra"),
"-postScript", "Decompile.java", $Out
) + $Addresses
if ($ReadOnly) { $arguments += "-readOnly" }
& (Join-Path $ghidra "support\analyzeHeadless.bat") @arguments *> (Join-Path $root "ghidra\decompile.out")
if ($LASTEXITCODE -ne 0) { Get-Content (Join-Path $root "ghidra\decompile.out") -Tail 30; exit $LASTEXITCODE }
Get-Content $Out
+60
View File
@@ -0,0 +1,60 @@
// Disassemble a game module (sacred2.exe, s2logic.dll, ...) at a virtual
// address (as in Ghidra: the module's preferred image base), independent of
// Ghidra's function boundaries (useful for code only reached via pointers).
//
// node tools/disasm.mjs <module> <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();
+73
View File
@@ -0,0 +1,73 @@
// List the exports of a game module with their VAs (at the module's
// preferred base, as in Ghidra). The game DLLs export most of their C++
// classes with decorated names, so this is a quick index of what is where.
//
// node tools/exports.mjs <module> [regex] exports whose name matches
// node tools/exports.mjs <module> 0x10018c60 the export at (or before) a VA
//
// Output: "va name", sorted by name (by address for a VA lookup).
import { readFileSync } from "node:fs";
const [modulePath, query] = process.argv.slice(2);
if (!modulePath) {
console.error("usage: node exports.mjs <module> [regex | 0xVA]");
process.exit(1);
}
const file = readFileSync(modulePath);
const peOffset = file.readUInt32LE(0x3c);
const sectionCount = file.readUInt16LE(peOffset + 6);
const optionalSize = file.readUInt16LE(peOffset + 20);
const optional = peOffset + 24;
const imageBase = file.readUInt32LE(optional + 28);
const sectionTable = optional + optionalSize;
function fileOffset(rva) {
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 + Math.max(virtualSize, file.readUInt32LE(s + 16))) {
return rawOffset + rva - virtualAddress;
}
}
throw new Error(`rva 0x${rva.toString(16)} is not in a section`);
}
const exportRva = file.readUInt32LE(optional + 96);
if (!exportRva) {
console.error("no export table");
process.exit(1);
}
const dir = fileOffset(exportRva);
const functionCount = file.readUInt32LE(dir + 20);
const nameCount = file.readUInt32LE(dir + 24);
const functions = fileOffset(file.readUInt32LE(dir + 28));
const names = fileOffset(file.readUInt32LE(dir + 32));
const ordinals = fileOffset(file.readUInt32LE(dir + 36));
const exports = [];
for (let i = 0; i < nameCount && functionCount; i++) {
const nameOffset = fileOffset(file.readUInt32LE(names + 4 * i));
const end = file.indexOf(0, nameOffset);
const name = file.toString("latin1", nameOffset, end);
const ordinal = file.readUInt16LE(ordinals + 2 * i);
const va = imageBase + file.readUInt32LE(functions + 4 * ordinal);
exports.push({ va, name });
}
const hex = (va) => va.toString(16).padStart(8, "0");
let shown;
if (query && /^0x[0-9a-f]+$/i.test(query)) {
const va = Number.parseInt(query, 16);
const before = exports.filter((e) => e.va <= va).sort((a, b) => b.va - a.va);
shown = before.length ? exports.filter((e) => e.va === before[0].va) : [];
} else {
const pattern = query ? new RegExp(query.replace(/^\/|\/$/g, ""), "i") : null;
shown = exports.filter((e) => !pattern || pattern.test(e.name)).sort((a, b) => a.name.localeCompare(b.name));
}
for (const e of shown) {
console.log(`${hex(e.va)} ${e.name}`);
}
+73
View File
@@ -0,0 +1,73 @@
// List every instruction in a game module's .text that uses one of the given
// immediate values (decimal or 0x-hex), e.g. to find hardcoded resolutions.
//
// node tools/find-imm.mjs <exe> 1024 768 [...]
// node tools/find-imm.mjs <exe> /96AB0h\]/ (regex on instruction text)
//
// Output: one line per hit, "address instruction".
import { readFileSync } from "node:fs";
import { Decoder, DecoderOptions, Formatter, FormatterSyntax, OpKind } from "iced-x86";
const [exePath, ...valueTexts] = process.argv.slice(2);
if (!exePath || valueTexts.length === 0) {
console.error("usage: node find-imm.mjs <exe> <value> [value...]");
process.exit(1);
}
const textPattern = valueTexts.length === 1 && /^\/.*\/$/.test(valueTexts[0])
? new RegExp(valueTexts[0].slice(1, -1), "i")
: null;
const wanted = textPattern ? new Set() : new Set(valueTexts.map((t) => BigInt(Number(t))));
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;
let text = null;
for (let i = 0; i < sectionCount; i++) {
const s = sectionTable + 40 * i;
const name = file.toString("latin1", s, s + 8).replace(/\0+$/, "");
if (name === ".text") {
text = {
virtualAddress: file.readUInt32LE(s + 12),
rawSize: file.readUInt32LE(s + 16),
rawOffset: file.readUInt32LE(s + 20),
};
}
}
if (!text) throw new Error("no .text section");
const bytes = file.subarray(text.rawOffset, text.rawOffset + text.rawSize);
const decoder = new Decoder(32, bytes, DecoderOptions.None);
decoder.ip = BigInt(imageBase + text.virtualAddress);
const formatter = new Formatter(FormatterSyntax.Intel);
const immediateKinds = new Set([
OpKind.Immediate8, OpKind.Immediate16, OpKind.Immediate32,
OpKind.Immediate8to16, OpKind.Immediate8to32,
]);
// Linear sweep: data embedded in .text can produce a few bogus hits.
while (decoder.canDecode) {
const instruction = decoder.decode();
if (textPattern) {
const text = formatter.format(instruction);
if (textPattern.test(text)) {
console.log(`${Number(instruction.ip).toString(16).padStart(8, "0")} ${text}`);
}
instruction.free();
continue;
}
for (let op = 0; op < instruction.opCount; op++) {
if (immediateKinds.has(instruction.opKind(op)) && wanted.has(instruction.immediate(op) & 0xffffffffn)) {
console.log(`${Number(instruction.ip).toString(16).padStart(8, "0")} ${formatter.format(instruction)}`);
break;
}
}
instruction.free();
}
decoder.free();
formatter.free();
+137
View File
@@ -0,0 +1,137 @@
# Drive the game for automated tests: start it, click, press keys, take
# screenshots. Dot-source it: . .\tools\game-input.ps1
#
# $game = Start-Sacred2 # launcher, returns the sacred2.exe process
# Wait-Log "Orc initialized" -Game $game
# Click-At 1720 779 # screen pixels (left click)
# Press-Key 0x1B # virtual key (ESC)
# Save-Screen screenshots\x.png # primary monitor
# Get-GameWindows $game # visible top-level windows of the process
# Stop-Sacred2 $game # the game and its local server s2gs.exe
#
# Input goes to the foreground window: don't touch mouse/keyboard meanwhile.
Add-Type -AssemblyName System.Drawing
Add-Type -Namespace Sacred2Re -Name Input -MemberDefinition @'
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, System.UIntPtr extra);
[DllImport("user32.dll")] public static extern void keybd_event(byte vk, byte scan, uint flags, System.UIntPtr extra);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(System.IntPtr window);
public delegate bool EnumProc(System.IntPtr window, System.IntPtr param);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc proc, System.IntPtr param);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(System.IntPtr window, out uint process);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(System.IntPtr window);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(System.IntPtr window, System.Text.StringBuilder text, int size);
[DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassName(System.IntPtr window, System.Text.StringBuilder text, int size);
[StructLayout(LayoutKind.Sequential)] public struct Rect { public int Left, Top, Right, Bottom; }
[DllImport("user32.dll")] public static extern bool GetWindowRect(System.IntPtr window, out Rect rect);
[DllImport("user32.dll")] public static extern int GetWindowLong(System.IntPtr window, int index);
'@
$script:Root = Split-Path $PSScriptRoot
$script:LogPath = Join-Path $script:Root "build\x86-debug\sacred2K.log"
# Visible top-level windows of a process: handle, class, title, rect, style.
function Get-GameWindows($Game) {
$found = New-Object System.Collections.ArrayList
$callback = [Sacred2Re.Input+EnumProc] {
param($window, $param)
[uint32]$process = 0
[Sacred2Re.Input]::GetWindowThreadProcessId($window, [ref]$process) | Out-Null
if ($process -eq $Game.Id -and [Sacred2Re.Input]::IsWindowVisible($window)) {
$title = New-Object System.Text.StringBuilder 256
$class = New-Object System.Text.StringBuilder 256
[Sacred2Re.Input]::GetWindowText($window, $title, 256) | Out-Null
[Sacred2Re.Input]::GetClassName($window, $class, 256) | Out-Null
$rect = New-Object Sacred2Re.Input+Rect
[Sacred2Re.Input]::GetWindowRect($window, [ref]$rect) | Out-Null
$style = [Sacred2Re.Input]::GetWindowLong($window, -16)
[void]$found.Add([pscustomobject]@{
Handle = $window; Class = $class.ToString(); Title = $title.ToString()
Rect = "$($rect.Left),$($rect.Top)-$($rect.Right),$($rect.Bottom)"; Style = ('0x{0:x8}' -f $style)
})
}
$true
}
[Sacred2Re.Input]::EnumWindows($callback, [IntPtr]::Zero) | Out-Null
$found
}
# Windows does not hand the foreground to a window started from a background
# process; a synthetic Alt press lifts that restriction for SetForegroundWindow.
function Focus-Sacred2($Game) {
$Game.Refresh()
if (-not $Game.MainWindowHandle -or $Game.MainWindowHandle -eq [IntPtr]::Zero) { return $false }
[Sacred2Re.Input]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
[Sacred2Re.Input]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
[Sacred2Re.Input]::SetForegroundWindow($Game.MainWindowHandle)
}
# Waits until sacred2K.log (written anew at every start) has at least $Count
# lines containing $Pattern; then waits $AfterMs.
function Wait-Log([string]$Pattern, [int]$Count = 1, [int]$TimeoutSeconds = 60, [int]$AfterMs = 500, $Game = $null) {
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if ($Game) {
if ($Game.HasExited) { throw "sacred2.exe exited (code $($Game.ExitCode)) waiting for '$Pattern'" }
Focus-Sacred2 $Game | Out-Null
}
$found = 0
if (Test-Path $script:LogPath) {
$found = @(Select-String -Path $script:LogPath -Pattern $Pattern -SimpleMatch).Count
}
if ($found -ge $Count) { Start-Sleep -Milliseconds $AfterMs; return }
Start-Sleep -Milliseconds 200
}
throw "timeout waiting for '$Pattern' in sacred2K.log"
}
# Starts the game through the launcher; returns the sacred2.exe process.
function Start-Sacred2([string]$Arguments = "") {
if (Get-Process sacred2 -ErrorAction SilentlyContinue) { throw "sacred2.exe is already running" }
if (Test-Path $script:LogPath) { Remove-Item $script:LogPath -ErrorAction SilentlyContinue }
$launcher = Join-Path $script:Root "build\x86-debug\sacred2K.exe"
if ($Arguments) { $null = Start-Process $launcher -ArgumentList $Arguments } else { $null = Start-Process $launcher }
$game = $null
for ($i = 0; $i -lt 80 -and -not $game; $i++) {
Start-Sleep -Milliseconds 250
$game = Get-Process sacred2 -ErrorAction SilentlyContinue | Select-Object -First 1
}
if (-not $game) { throw "sacred2.exe did not start" }
$game
}
function Stop-Sacred2($Game) {
if ($Game -and -not $Game.HasExited) {
Stop-Process -Id $Game.Id -Force
$Game.WaitForExit(10000) | Out-Null
}
Get-Process s2gs -ErrorAction SilentlyContinue | Stop-Process -Force
}
function Click-At([int]$X, [int]$Y, [int]$AfterMs = 800) {
[Sacred2Re.Input]::SetCursorPos($X, $Y) | Out-Null
Start-Sleep -Milliseconds 150
[Sacred2Re.Input]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero) # left down
Start-Sleep -Milliseconds 80
[Sacred2Re.Input]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero) # left up
Start-Sleep -Milliseconds $AfterMs
}
function Press-Key([byte]$VirtualKey, [int]$AfterMs = 800) {
[Sacred2Re.Input]::keybd_event($VirtualKey, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 80
[Sacred2Re.Input]::keybd_event($VirtualKey, 0, 2, [UIntPtr]::Zero) # key up
Start-Sleep -Milliseconds $AfterMs
}
function Save-Screen([string]$Path, [int]$Width = 3440, [int]$Height = 1440) {
$bitmap = New-Object System.Drawing.Bitmap $Width, $Height
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen(0, 0, 0, 0, $bitmap.Size)
$full = if ([IO.Path]::IsPathRooted($Path)) { $Path } else { Join-Path $script:Root $Path }
New-Item -ItemType Directory -Force (Split-Path $full) | Out-Null
$bitmap.Save($full)
$graphics.Dispose(); $bitmap.Dispose()
$full
}
+83
View File
@@ -0,0 +1,83 @@
// Decompile functions of one program of the persistent Ghidra project.
//
// Headless usage (see tools/decompile.ps1):
// analyzeHeadless <projectDir> sacred2 -process <module> -noanalysis
// -scriptPath tools/ghidra -postScript Decompile.java <outFile> <addr|symbol> [...]
//
// An argument that is not a number is looked up as a symbol name (e.g. a
// decorated export). Addresses without a function (e.g. methods only
// reachable via a vtable) get one created first; run without -readOnly to
// keep those in the project.
// @category sacred2K
import java.io.File;
import java.io.PrintWriter;
import ghidra.app.cmd.disassemble.DisassembleCommand;
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileOptions;
import ghidra.app.decompiler.DecompileResults;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.SymbolIterator;
public class Decompile extends GhidraScript {
// A hex address (0x-prefixed or 8 digits), or the address of a symbol with
// that name.
private Address resolve(String text) {
if (text.matches("0x[0-9a-fA-F]+|[0-9a-fA-F]{8}")) {
return toAddr(text);
}
SymbolIterator symbols = currentProgram.getSymbolTable().getSymbols(text);
return symbols.hasNext() ? symbols.next().getAddress() : null;
}
@Override
protected void run() throws Exception {
String[] args = getScriptArgs();
if (args.length < 2) {
printerr("usage: Decompile.java <outFile> <address> [address...]");
return;
}
DecompInterface decompiler = new DecompInterface();
decompiler.setOptions(new DecompileOptions());
decompiler.openProgram(currentProgram);
try (PrintWriter out = new PrintWriter(new File(args[0]), "UTF-8")) {
for (int i = 1; i < args.length; i++) {
Address address = resolve(args[i]);
if (address == null) {
out.println("// ==== " + args[i] + " ====");
out.println("// no such address or symbol");
continue;
}
Function function = getFunctionAt(address);
if (function == null) {
function = getFunctionContaining(address);
}
if (function == null) {
new DisassembleCommand(address, null, true).applyTo(currentProgram, monitor);
function = createFunction(address, null);
}
out.println("// ==== " + args[i] + " ====");
if (function == null) {
out.println("// could not create a function here");
continue;
}
out.println("// " + function.getName(true) + " @ " + function.getEntryPoint()
+ " (" + function.getCallingConventionName() + ")");
DecompileResults result = decompiler.decompileFunction(function, 120, monitor);
if (result.decompileCompleted()) {
out.println(result.getDecompiledFunction().getC());
} else {
out.println("// decompile failed: " + result.getErrorMessage());
}
}
} finally {
decompiler.dispose();
}
}
}
+86
View File
@@ -0,0 +1,86 @@
// Apply function names from tools/names.txt to the persistent Ghidra project
// (run on every program; each line names its module).
//
// Headless usage (see tools/rename.ps1):
// analyzeHeadless <projectDir> sacred2 -process -noanalysis
// -scriptPath tools/ghidra -postScript Rename.java <namesFile>
//
// names.txt: one "module 0xADDRESS Name" per line (module as the program
// name, e.g. s2logic.dll; the address at its preferred base), "Class::Method" puts the function
// into a class namespace; "#" starts a comment. Functions are created where
// none starts at the address yet. Names starting with "g_" label data
// (globals, tables) instead of functions.
// @category sacred2K
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import ghidra.app.cmd.disassemble.DisassembleCommand;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.Namespace;
import ghidra.program.model.symbol.SourceType;
import ghidra.program.model.symbol.SymbolTable;
public class Rename extends GhidraScript {
@Override
protected void run() throws Exception {
String[] args = getScriptArgs();
if (args.length != 1) {
printerr("usage: Rename.java <namesFile>");
return;
}
SymbolTable symbols = currentProgram.getSymbolTable();
int renamed = 0;
int labelled = 0;
for (String raw : Files.readAllLines(Paths.get(args[0]), StandardCharsets.UTF_8)) {
String line = raw.replaceAll("#.*", "").trim();
if (line.isEmpty()) {
continue;
}
String[] parts = line.split("\\s+");
if (parts.length != 3) {
printerr("bad line: " + raw);
continue;
}
if (!parts[0].equalsIgnoreCase(currentProgram.getName())) {
continue;
}
parts = new String[] {parts[1], parts[2]};
Address address = toAddr(parts[0]);
if (parts[1].startsWith("g_")) {
createLabel(address, parts[1], true, SourceType.USER_DEFINED);
labelled++;
continue;
}
Function function = getFunctionAt(address);
if (function == null) {
new DisassembleCommand(address, null, true).applyTo(currentProgram, monitor);
function = createFunction(address, null);
}
if (function == null) {
printerr("no function at " + parts[0]);
continue;
}
String name = parts[1];
Namespace namespace = currentProgram.getGlobalNamespace();
int separator = name.lastIndexOf("::");
if (separator >= 0) {
String className = name.substring(0, separator);
name = name.substring(separator + 2);
namespace = symbols.getNamespace(className, currentProgram.getGlobalNamespace());
if (namespace == null) {
namespace = symbols.createClass(currentProgram.getGlobalNamespace(), className,
SourceType.USER_DEFINED);
}
}
function.setParentNamespace(namespace);
function.setName(name, SourceType.USER_DEFINED);
renamed++;
}
println(currentProgram.getName() + ": renamed " + renamed + " functions, labelled " + labelled + " data addresses");
}
}
+5
View File
@@ -0,0 +1,5 @@
# Names for the Ghidra project, applied with tools/rename.ps1.
# One "module 0xADDRESS Name" per line (address at the module's preferred
# base); "Class::Method" puts a function into a class namespace, "g_" names
# label data. Exported functions already carry their names from the export
# table; list only what we identify ourselves.
+19
View File
@@ -0,0 +1,19 @@
{
"name": "sacredk-tools",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sacredk-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": "sacredk-tools",
"private": true,
"type": "module",
"dependencies": {
"iced-x86": "1.21.0"
}
}
+17
View File
@@ -0,0 +1,17 @@
# Apply tools/names.txt to every program of the persistent Ghidra project
# (ghidra/sacred2.gpr); each line names its module.
# .\tools\rename.ps1
$ErrorActionPreference = "Stop"
$root = Split-Path $PSScriptRoot
$ghidra = if ($env:GHIDRA_INSTALL_DIR) { $env:GHIDRA_INSTALL_DIR } else { "E:\Coding\Ghidra\ghidra_12.1.4_PUBLIC" }
if (-not $env:JAVA_HOME) { $env:JAVA_HOME = "C:\Program Files\Java\jdk-21" }
$arguments = @(
(Join-Path $root "ghidra"), "sacred2",
"-process", "-noanalysis",
"-scriptPath", (Join-Path $PSScriptRoot "ghidra"),
"-postScript", "Rename.java", (Join-Path $PSScriptRoot "names.txt")
)
& (Join-Path $ghidra "support\analyzeHeadless.bat") @arguments *> (Join-Path $root "ghidra\rename.out")
if ($LASTEXITCODE -ne 0) { Get-Content (Join-Path $root "ghidra\rename.out") -Tail 30; exit $LASTEXITCODE }
Select-String -Path (Join-Path $root "ghidra\rename.out") -Pattern "renamed|bad line|no function"
+84
View File
@@ -0,0 +1,84 @@
// Find the vtables of an MSVC C++ class in a game module via RTTI and list their
// entries.
//
// node tools/rtti.mjs <exe> <ClassName> [entries=40]
//
// MSVC 32-bit RTTI: TypeDescriptor { pVFTable, spare, char name[] } with the
// name ".?AV<Class>@@"; CompleteObjectLocator { signature=0, offset, cdOffset,
// pTypeDescriptor, pClassHierarchyDescriptor }; vtable[-1] = &COL.
// A class has one vtable per (non-virtual) base subobject; `offset` tells which.
import { readFileSync } from "node:fs";
const [exePath, className, entriesText = "40"] = process.argv.slice(2);
if (!exePath || !className) {
console.error("usage: node rtti.mjs <exe> <ClassName> [entries]");
process.exit(1);
}
const maxEntries = Number.parseInt(entriesText, 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;
const sections = [];
for (let i = 0; i < sectionCount; i++) {
const s = sectionTable + 40 * i;
sections.push({
name: file.toString("latin1", s, s + 8).replace(/\0+$/, ""),
va: imageBase + file.readUInt32LE(s + 12),
virtualSize: file.readUInt32LE(s + 8),
rawOffset: file.readUInt32LE(s + 20),
rawSize: file.readUInt32LE(s + 16),
});
}
const section = (name) => sections.find((s) => s.name === name);
const toOffset = (va) => {
for (const s of sections) {
if (va >= s.va && va < s.va + Math.min(s.virtualSize, s.rawSize)) return s.rawOffset + va - s.va;
}
return -1;
};
const inSection = (va, s) => va >= s.va && va < s.va + s.virtualSize;
const hex = (v) => "0x" + v.toString(16).padStart(8, "0");
// 1. TypeDescriptor: name string lives 8 bytes into it (.data).
const mangled = Buffer.from(`.?AV${className}@@\0`, "latin1");
const typeDescriptors = [];
for (const s of sections) {
const raw = file.subarray(s.rawOffset, s.rawOffset + s.rawSize);
for (let i = raw.indexOf(mangled); i !== -1; i = raw.indexOf(mangled, i + 1)) {
typeDescriptors.push(s.va + i - 8);
}
}
if (typeDescriptors.length === 0) {
console.error(`no RTTI type descriptor for ${className}`);
process.exit(2);
}
// 2. CompleteObjectLocators referencing it, 3. vtables whose [-1] is a COL.
const rdata = section(".rdata");
const text = section(".text");
const rdataRaw = file.subarray(rdata.rawOffset, rdata.rawOffset + rdata.rawSize);
for (const td of typeDescriptors) {
console.log(`${className}: TypeDescriptor ${hex(td)}`);
for (let i = 0; i + 20 <= rdataRaw.length; i += 4) {
if (rdataRaw.readUInt32LE(i) !== 0 || rdataRaw.readUInt32LE(i + 12) !== td) continue;
const col = rdata.va + i;
const subobjectOffset = rdataRaw.readUInt32LE(i + 4);
for (let j = 0; j + 4 <= rdataRaw.length; j += 4) {
if (rdataRaw.readUInt32LE(j) !== col) continue;
const vtable = rdata.va + j + 4;
console.log(` vtable ${hex(vtable)} (subobject offset 0x${subobjectOffset.toString(16)}, COL ${hex(col)})`);
for (let k = 0; k < maxEntries; k++) {
const o = toOffset(vtable + 4 * k);
const target = file.readUInt32LE(o);
if (!inSection(target, text)) break;
console.log(` [+0x${(4 * k).toString(16).padStart(2, "0")}] ${hex(target)}`);
}
}
}
}