Skip to content

Reverse Engineering

Borys Stelmakh edited this page Jun 15, 2026 · 1 revision

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/.

Contents

Getting a foothold: the ijl15.dll proxy

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.

The .text decryption gate

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:

  1. PE loader maps the EXE and runs every imported DLL's DllMain (including our proxy).
  2. Control transfers to the .bind stub at the entry point.
  3. The stub decrypts .text in 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 + 0x100000 every 200 ms, computing entropy; the moment it drops below 7.0 (the .bind stub has finished) it dumps 0x48F000 bytes from 0x401000 to text_dump.bin. sdk/re/py/splice_decrypted.py then splices those bytes into a copy of the EXE at the .text raw offset (0x1000) to produce Sacred_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 .text must run after decryption. This is why the runtime trampolines and engine-VA resolution happen on the worker thread / in-game, not in DllMain.

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).

The Lua bake

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_State for the whole bake. required modules accumulate state across files — that's how text.lua collects every mod's inline T"..." strings and emits one combined custom/scripts/us/global.res.
  • package.path is pre-pointed at custom/lua/lib/?.lua first, then the rest of the mod tree; package.cpath is 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_load live until Sacred exits, and the runtime trigger machinery takes ownership of the same state (the state is never closed).
  • The sacred table 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), and install_data_api (the read-only data-table and engine-introspection lookups, lua_api_data.cpp).

fs_override: the CreateFileA hook

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.

Runtime trampolines

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.

Where to look next

  • 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 image sdk/Sacred_decrypted.exe.

Clone this wiki locally