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:
commit
8a7bdfd99f
44 files changed
+2505
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
# Store LF in the repository; MSVC, CMake and PowerShell all read LF fine.
|
||||||
|
* text=auto eol=lf
|
||||||
+14
@@ -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/
|
||||||
@@ -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.
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"version": 6,
|
||||||
|
"configurePresets": [
|
||||||
|
{
|
||||||
|
"name": "x86-base",
|
||||||
|
"hidden": true,
|
||||||
|
"generator": "Ninja",
|
||||||
|
"binaryDir": "${sourceDir}/build/${presetName}",
|
||||||
|
"architecture": { "value": "x86", "strategy": "external" },
|
||||||
|
"cacheVariables": { "CMAKE_C_COMPILER": "cl", "CMAKE_CXX_COMPILER": "cl" }
|
||||||
|
},
|
||||||
|
{ "name": "x86-debug", "inherits": "x86-base", "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } },
|
||||||
|
{ "name": "x86-release", "inherits": "x86-base", "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo" } }
|
||||||
|
],
|
||||||
|
"buildPresets": [
|
||||||
|
{ "name": "x86-debug", "configurePreset": "x86-debug" },
|
||||||
|
{ "name": "x86-release", "configurePreset": "x86-release" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -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 }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Microsoft Detours (MIT) — function hooking and DLL injection.
|
||||||
|
# Upstream ships no CMake build, so we compile its sources into a static lib.
|
||||||
|
include(FetchContent)
|
||||||
|
FetchContent_Declare(detours_src
|
||||||
|
GIT_REPOSITORY https://github.com/microsoft/Detours.git
|
||||||
|
GIT_TAG v4.0.1
|
||||||
|
GIT_SHALLOW TRUE
|
||||||
|
)
|
||||||
|
FetchContent_MakeAvailable(detours_src)
|
||||||
|
|
||||||
|
set(_d ${detours_src_SOURCE_DIR}/src)
|
||||||
|
add_library(detours STATIC
|
||||||
|
${_d}/detours.cpp
|
||||||
|
${_d}/modules.cpp
|
||||||
|
${_d}/disasm.cpp
|
||||||
|
${_d}/image.cpp
|
||||||
|
${_d}/creatwth.cpp
|
||||||
|
${_d}/disolx86.cpp
|
||||||
|
${_d}/disolx64.cpp
|
||||||
|
${_d}/disolia64.cpp
|
||||||
|
${_d}/disolarm.cpp
|
||||||
|
${_d}/disolarm64.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(detours PUBLIC ${_d})
|
||||||
|
# C++20 implies /permissive-, which v4.0.1 does not compile under (goto over initialisations).
|
||||||
|
target_compile_options(detours PRIVATE /W3 /permissive)
|
||||||
@@ -0,0 +1,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.
|
||||||
@@ -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` |
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
#include "re/log.h"
|
||||||
|
|
||||||
|
#include <cstdarg>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <mutex>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
namespace re {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::mutex g_mutex;
|
||||||
|
FILE* g_file = nullptr;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void OpenLog(const wchar_t* path) {
|
||||||
|
std::lock_guard lock(g_mutex);
|
||||||
|
if (!g_file) {
|
||||||
|
g_file = _wfsopen(path, L"w", _SH_DENYWR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Log(const char* fmt, ...) {
|
||||||
|
char message[1024];
|
||||||
|
va_list args;
|
||||||
|
va_start(args, fmt);
|
||||||
|
vsnprintf(message, sizeof(message), fmt, args);
|
||||||
|
va_end(args);
|
||||||
|
|
||||||
|
SYSTEMTIME t;
|
||||||
|
GetLocalTime(&t);
|
||||||
|
char line[1100];
|
||||||
|
snprintf(line, sizeof(line), "%02d:%02d:%02d.%03d %s\n", t.wHour, t.wMinute, t.wSecond, t.wMilliseconds,
|
||||||
|
message);
|
||||||
|
|
||||||
|
OutputDebugStringA(line);
|
||||||
|
std::lock_guard lock(g_mutex);
|
||||||
|
if (g_file) {
|
||||||
|
fputs(line, g_file);
|
||||||
|
fflush(g_file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace re
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#pragma once
|
||||||
|
// 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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(¤t, 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
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
LIBRARY sacred2K
|
||||||
|
EXPORTS
|
||||||
|
; Detours requires ordinal 1 on any DLL injected via DetourCreateProcessWithDll.
|
||||||
|
DetourFinishHelperProcess @1 NONAME
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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
|
||||||
@@ -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();
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Generated
+19
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "sacredk-tools",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"dependencies": {
|
||||||
|
"iced-x86": "1.21.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user