-
-
Notifications
You must be signed in to change notification settings - Fork 3
Reverse Engineering
How SacredSDK works under the hood, for contributors. This page covers the
SDK's mechanism — how it gets into the process, how mods reach the game, and
how the game's encrypted code was made analyzable. Deeper per-subsystem RE notes
live in the repo under sdk/.claude/knowledge/re/.
Verified on Sacred Gold Steam, build 2.0.2.28 (2006-10-13). All virtual
addresses assume the fixed image base 0x00400000 (the 2006 MSVC binary has no
/DYNAMICBASE, so VAs are stable across runs). The Steam install on disk is
never modified — every mod lives under <game>/custom/.
- Getting a foothold: the ijl15.dll proxy
- The .text decryption gate
- The Lua bake
- fs_override: the CreateFileA hook
- Runtime trampolines
- Where to look next
The SDK runs inside Sacred.exe with no EXE patching, using a proxy DLL in
a slot the game already imports.
Sacred imports four functions from ijl15.dll (the Intel JPEG library) by
ordinal:
Sacred.exe imports from ijl15.dll:
by_ord: ordinal=2 (= ijlInit)
by_ord: ordinal=3 (= ijlFree)
by_ord: ordinal=4 (= ijlRead)
by_ord: ordinal=5 (= ijlWrite)
ijl15.dll was chosen over d3d9.dll (Sacred renders via DirectDraw, not D3D9,
so a d3d9.dll is never loaded) and over ddraw.dll (a system DLL — mistakes
risk crashes). ijl15.dll is third-party, sits next to the EXE, has a plain
C API, and is loaded automatically by the PE loader.
The SDK builds a replacement ijl15.dll whose six exports are forwarders to
the renamed original ijl15_real.dll:
+------------------+
Sacred.exe | ijl15.dll | <- our proxy
+-----------------+ | @1 -> forward |
| IAT(ijl15.dll) | load | @2 -> forward |
| ord 2..5 -----+ --------> | ... |
+-----------------+ | DllMain (boots) |
| +--------+---------+
| PE loader walks forwarder chain
v v
+------------------+
| ijl15_real.dll | <- renamed original
| (Intel JPEG) |
+------------------+
The Windows PE loader resolves each forwarder string transparently and patches
Sacred's IAT straight to the real function, so after load no JPEG call passes
through our DLL. The only SDK code that runs is DllMain on
DLL_PROCESS_ATTACH, which fires once before Sacred's entry point — a clean
bootstrap with zero behavioral change. (The forwarders are declared with
#pragma comment(linker, "/EXPORT:foo=ijl15_real.foo,@N") rather than a .def
file, because the modern MSVC linker rejects the .def forwarder syntax with
LNK2001.)
From DllMain the SDK spawns a worker thread (so it doesn't block PE-load),
which waits for the game to finish initializing, then installs the bake, the
file hook, the overlay, and the runtime trampolines.
Sacred.exe's .text section is encrypted on disk (whole-section Shannon
entropy 8.000 — uniform random), and the PE entry point is 0x196c3db, inside
a .bind section, not .text. This is a SafeDisc/SecuROM-style runtime
decrypter:
- PE loader maps the EXE and runs every imported DLL's
DllMain(including our proxy). - Control transfers to the
.bindstub at the entry point. - The stub decrypts
.textin memory and jumps to the real Sacred entry.
Two consequences shape the SDK:
-
Static analysis is blocked until step 3 finishes. Ghidra of the on-disk
EXE sees a random blob: every string xref returns zero. The SDK unblocks this
with
sdk/dump_text.cpp: a worker thread polls a 16 KB sample at.text + 0x100000every 200 ms, computing entropy; the moment it drops below 7.0 (the.bindstub has finished) it dumps0x48F000bytes from0x401000totext_dump.bin.sdk/re/py/splice_decrypted.pythen splices those bytes into a copy of the EXE at the.textraw offset (0x1000) to produceSacred_decrypted.exe, which Ghidra analyzes normally (real xrefs, real function names). This is how every VA in the SDK was recovered. -
Any SDK code that reads or hooks
.textmust run after decryption. This is why the runtime trampolines and engine-VA resolution happen on the worker thread / in-game, not inDllMain.
Static-RE results that the SDK depends on include the FunkCode interpreter
FUN_00472bc0 (one giant tag-switch — the type 0x1d case is the :res:%d
resource resolver), the per-class script loader FUN_0046f9b0
(%s\FunkCode.bin), and the resource-name hash (a custom 31-bit hash, ported to
Lua in text.lua and to C++ as sacred_hash31 — standard hash bruteforce
failed, so it had to come from the binary).
Lua 5.4 is embedded in the DLL. On the worker thread the bake scans
custom/lua/**/*.lua and runs each file, writing the returned record list to a
matching custom/<rel>.bin.
Key design points (see sdk/lua_bake.cpp):
-
One shared
lua_Statefor the whole bake.required modules accumulate state across files — that's howtext.luacollects every mod's inlineT"..."strings and emits one combinedcustom/scripts/us/global.res. -
package.pathis pre-pointed atcustom/lua/lib/?.luafirst, then the rest of the mod tree;package.cpathis emptied so scripts can't load arbitrary native DLLs into the process. -
The state is persistent — it survives the bake. Handlers registered with
sacred.on_trigger/sacred.on_tick/sacred.on_world_loadlive until Sacred exits, and the runtime trigger machinery takes ownership of the same state (the state is never closed). - The
sacredtable is assembled from three registration sites:register_sacred_api(log/read_file/write_file,lua_bake.cpp),install_lua_api(the runtime/NPC/quest-book functions,runtime_triggers.cpp), andinstall_data_api(the read-only data-table and engine-introspection lookups,lua_api_data.cpp).
Mods reach the game through a read-time file override that never touches vanilla
files. The SDK hooks CreateFileA at the IAT level: any read that resolves
to <game>\<sub>\<file> is re-pointed at <game>\custom\<sub>\<file> if that
override exists.
Sacred.exe Disk
| CreateFileA("scripts\us\global.res")
+- ijl15.dll fs_override hook
|-- exists custom\scripts\us\global.res? --- YES --- open THAT
+-- else pass through --- NO --- open vanilla
Guard rails (sdk/fs_override.cpp):
- Only read opens are redirected (
GENERIC_READ,OPEN_EXISTING/OPEN_ALWAYS). Write opens (saves, logs) pass through untouched. - Top-level files (the EXE, DLLs) and paths outside the game dir are never
redirected;
..\escapes are filtered.
This is format-agnostic — it works for global.res, Balance.bin, FunkCode
.bin files, pak/*.pak, anything Sacred opens via CreateFileA — and fully
reversible (delete the override, vanilla resumes next launch).
One file resists the plain hook: global.res is not loaded with a bare
CreateFileA — Patch 1 (sdk/patches.cpp, the FUN_0080e680 detour) is a
chained-XOR resource loader, so it carries its own custom/scripts/<lang>/
check. The two redirection paths are independent and deliberate: Patch 1 for the
resource loader, the CreateFileA hook for everything else.
For logic that must run during play, the SDK installs byte-patch trampolines
on a handful of engine functions (after .text is decrypted). Both trigger
dispatchers share the same 7-byte MSVC SEH prologue
(push -1; push <seh_record>), which is position-independent — the SDK copies
it verbatim into a trampoline and overwrites the original with a JMP rel32,
then chains back to original + 7. The thunk snapshots the engine's interpreter
context (ECX), reads the trigger name from [ECX + 0xa460], and dispatches to
the registered Lua handlers (each in pcall; errors are logged, not fatal).
Functions hooked (in sdk/runtime_triggers.cpp, except the resolvers which are
in sdk/text_logger.cpp):
| VA | Role |
|---|---|
FUN_004915a0 |
SelfTriggerQuest — fires <NPC>_DLG_* style triggers |
FUN_00491170 |
Dialog-Check — fires Dialog %s style triggers |
FUN_00475680 |
FunkCode walker — captures the cQuestManager pointer (drives on_world_load) |
FUN_0080eaf0 / FUN_0080f5e0
|
global.res resolve / dialog-text fetch — back dialog_override, the [dlgname] discovery log, and globalres
|
Live game-state operations that have no standalone engine primitive
(set gold, scan equipment) are done as direct struct writes through the
hero-pointer chain — e.g. gold at hero+0x3EE, the 18 equip slots at
hero+0x1A4. The cosmetic side (coin sound, floating "+N", HUD refresh) is
triggered by emitting a GOLD_CHANGE event to the engine's kernel event bus,
since the event handler itself only notifies UI listeners and doesn't touch the
gold field. The quest-display registry is grown by calling the engine's own
vector::resize (FUN_004b5370) and stamping quest_id into the new entry.
All struct reads/writes and engine calls are wrapped in SEH (__try/__except)
so a stale pointer logs and returns nil/false instead of crashing the game.
- Mechanism source:
sdk/dllmain.cpp,sdk/dump_text.cpp,sdk/lua_bake.cpp,sdk/fs_override.cpp,sdk/patches.cpp,sdk/runtime_triggers.cpp,sdk/lua_api_data.cpp,sdk/engine_resolve.cpp,sdk/text_logger.cpp. - Per-subsystem RE notes (dialog, quests, roster, FunkCode tags, the resource
hash, …):
sdk/.claude/knowledge/re/. - The disassembly corpus the notes cite:
sdk/re/ghidra/decompiled/and the decrypted imagesdk/Sacred_decrypted.exe.
Getting started
Authoring
- Quests and Dialog Authoring
- Native Quests
- Runtime NPCs
- Hero Classes
- Dialog Text
- Dialog Nodes (catalogue)
The engine's own verbs
Reference