SE2Revival/mods/common/patch.h
2026-10-23 20:47:44 -04:00

243 lines
10 KiB
C++

// Shared helpers for DS2 ASI plugins: version gate, guarded code patches, logging, ini access.
//
// Patches are applied only when the bytes at the site match what the reference build
// (Steam v2.3, PE timestamp 0x4D2E4863) contains, so a different exe is left untouched.
#pragma once
#include <windows.h>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <share.h>
#include <cstdlib>
#include <string>
namespace ds2 {
// DungeonSiege2.exe v2.30.0.0 (Steam, linked 2011-01-13 00:33:39 UTC).
constexpr DWORD kV23Timestamp = 0x4D2E4863;
// Dungeon Siege II: Broken World, DungeonSiege2.exe v2.30.0.4277 (linked 2006-06-22). Separate exe that
// runs from its own folder and loads the base game's resources from the DS2 install directory.
constexpr DWORD kBrokenWorldTimestamp = 0x449B02B3;
enum class Exe { Steam23, BrokenWorld, Unknown };
inline const IMAGE_NT_HEADERS32* ExeHeaders() {
auto base = reinterpret_cast<const BYTE*>(GetModuleHandleA(nullptr));
return reinterpret_cast<const IMAGE_NT_HEADERS32*>(base + reinterpret_cast<const IMAGE_DOS_HEADER*>(base)->e_lfanew);
}
inline DWORD ExeTimestamp() { return ExeHeaders()->FileHeader.TimeDateStamp; }
inline Exe ExeKind() {
DWORD ts = ExeTimestamp();
return ts == kV23Timestamp ? Exe::Steam23 : ts == kBrokenWorldTimestamp ? Exe::BrokenWorld : Exe::Unknown;
}
// IAT slot of an import of the main exe, by DLL name and function name ("#52" = ordinal 52); 0 if absent.
// Name-based so the same hooks work in every build (Steam v2.3, Broken World).
inline DWORD ImportSlot(const char* dll, const char* func) {
auto base = reinterpret_cast<const BYTE*>(GetModuleHandleA(nullptr));
const IMAGE_DATA_DIRECTORY& dir = ExeHeaders()->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT];
if (!dir.VirtualAddress) return 0;
WORD ordinal = func[0] == '#' ? static_cast<WORD>(std::atoi(func + 1)) : 0;
for (auto d = reinterpret_cast<const IMAGE_IMPORT_DESCRIPTOR*>(base + dir.VirtualAddress); d->Name; ++d) {
if (_stricmp(reinterpret_cast<const char*>(base + d->Name), dll) != 0) continue;
auto names = reinterpret_cast<const IMAGE_THUNK_DATA32*>(base + (d->OriginalFirstThunk ? d->OriginalFirstThunk : d->FirstThunk));
auto slots = reinterpret_cast<const IMAGE_THUNK_DATA32*>(base + d->FirstThunk);
for (int i = 0; names[i].u1.AddressOfData; ++i) {
DWORD t = names[i].u1.AddressOfData;
bool match = (t & IMAGE_ORDINAL_FLAG32)
? ordinal && IMAGE_ORDINAL32(t) == ordinal
: !ordinal && std::strcmp(reinterpret_cast<const char*>(
reinterpret_cast<const IMAGE_IMPORT_BY_NAME*>(base + t)->Name),
func) == 0;
if (match) return reinterpret_cast<DWORD>(&slots[i]);
}
}
return 0;
}
// The single occurrence of an IDA-style pattern ("6A 00 ?? 50") in the main exe's first (code) section;
// 0 if absent or ambiguous.
inline DWORD FindUnique(const char* pattern) {
BYTE bytes[64];
bool any[64];
size_t n = 0;
for (const char* p = pattern; *p && n < sizeof(bytes);) {
if (*p == ' ') { ++p; continue; }
if (p[0] == '?') {
any[n] = true, bytes[n++] = 0;
p += p[1] == '?' ? 2 : 1;
} else {
any[n] = false, bytes[n++] = static_cast<BYTE>(std::strtoul(std::string(p, 2).c_str(), nullptr, 16));
p += 2;
}
}
auto nt = ExeHeaders();
auto sec = IMAGE_FIRST_SECTION(nt);
auto start = reinterpret_cast<const BYTE*>(GetModuleHandleA(nullptr)) + sec->VirtualAddress;
size_t size = sec->Misc.VirtualSize;
DWORD found = 0;
for (size_t i = 0; n && i + n <= size; ++i) {
size_t k = 0;
while (k < n && (any[k] || start[i + k] == bytes[k])) ++k;
if (k == n) {
if (found) return 0; // ambiguous
found = reinterpret_cast<DWORD>(start + i);
}
}
return found;
}
class Log {
public:
void Open(const char* path) { file_ = _fsopen(path, "w", _SH_DENYNO); } // readable while the game runs
void Write(const char* fmt, ...) {
if (!file_) return;
SYSTEMTIME t;
GetLocalTime(&t);
std::fprintf(file_, "[%02d:%02d:%02d.%03d] ", t.wHour, t.wMinute, t.wSecond, t.wMilliseconds);
va_list ap;
va_start(ap, fmt);
std::vfprintf(file_, fmt, ap);
va_end(ap);
std::fputc('\n', file_);
std::fflush(file_);
}
private:
FILE* file_ = nullptr;
};
inline Log& log() {
static Log instance;
return instance;
}
// Overwrite code at `addr` after checking it still holds `expect`.
inline bool PatchBytes(DWORD addr, const void* expect, const void* bytes, size_t len, const char* what) {
auto p = reinterpret_cast<BYTE*>(addr);
if (std::memcmp(p, expect, len) != 0) {
log().Write("SKIP %s @%08X: unexpected bytes (different exe build?)", what, addr);
return false;
}
DWORD old;
if (!VirtualProtect(p, len, PAGE_EXECUTE_READWRITE, &old)) {
log().Write("FAIL %s @%08X: VirtualProtect error %lu", what, addr, GetLastError());
return false;
}
std::memcpy(p, bytes, len);
VirtualProtect(p, len, old, &old);
FlushInstructionCache(GetCurrentProcess(), p, len);
log().Write("OK %s @%08X", what, addr);
return true;
}
// Retarget the rel32 CALL at `site` from `oldTarget` to `newTarget`.
inline bool RedirectCall(DWORD site, DWORD oldTarget, DWORD newTarget, const char* what) {
BYTE expect[5] = {0xE8};
BYTE repl[5] = {0xE8};
LONG oldRel = static_cast<LONG>(oldTarget - (site + 5));
LONG newRel = static_cast<LONG>(newTarget - (site + 5));
std::memcpy(expect + 1, &oldRel, 4);
std::memcpy(repl + 1, &newRel, 4);
return PatchBytes(site, expect, repl, 5, what);
}
// Overwrite the first bytes of a game function (which must equal `expect`, len >= 5) with a JMP to
// `target`, padding with NOPs. The original function is not callable afterwards: use this only for
// functions the replacement re-implements completely.
inline bool JmpReplace(DWORD site, const BYTE* expect, size_t len, void* target, const char* what) {
BYTE repl[16];
if (len < 5 || len > sizeof(repl)) return false;
std::memset(repl, 0x90, len);
repl[0] = 0xE9;
LONG rel = static_cast<LONG>(reinterpret_cast<DWORD>(target) - (site + 5));
std::memcpy(repl + 1, &rel, 4);
return PatchBytes(site, expect, repl, len, what);
}
// Detour a game function and return a callable trampoline to the original. The first `len` bytes at
// `site` must equal `expect` and be whole, position-independent instructions (no relative jumps/calls).
inline void* Detour(DWORD site, const BYTE* expect, size_t len, void* hook, const char* what) {
if (std::memcmp(reinterpret_cast<void*>(site), expect, len) != 0) {
log().Write("SKIP %s @%08X: unexpected bytes", what, site);
return nullptr;
}
if (len < 5 || len > 16) return nullptr;
auto tramp = static_cast<BYTE*>(VirtualAlloc(nullptr, 32, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE));
if (!tramp) {
log().Write("FAIL %s @%08X: VirtualAlloc error %lu", what, site, GetLastError());
return nullptr;
}
std::memcpy(tramp, expect, len);
tramp[len] = 0xE9;
LONG back = static_cast<LONG>((site + len) - (reinterpret_cast<DWORD>(tramp) + len + 5));
std::memcpy(tramp + len + 1, &back, 4);
FlushInstructionCache(GetCurrentProcess(), tramp, len + 5);
if (!JmpReplace(site, expect, len, hook, what)) {
VirtualFree(tramp, 0, MEM_RELEASE);
return nullptr;
}
return tramp;
}
// Swap a function pointer stored in the image (vtable slot, callback table) after checking its value.
inline bool PatchPointer(DWORD slot, DWORD expect, const void* replacement, const char* what) {
DWORD repl = reinterpret_cast<DWORD>(replacement);
return PatchBytes(slot, &expect, &repl, 4, what);
}
// Replace the import-address-table slot at `iatSlot` (a fixed VA in the v2.3 image) and return
// the original function pointer (nullptr, and the slot untouched, if it cannot be written).
inline void* HookIat(DWORD iatSlot, void* replacement, const char* what) {
auto slot = reinterpret_cast<void**>(iatSlot);
DWORD old;
if (!VirtualProtect(slot, sizeof(void*), PAGE_READWRITE, &old)) {
log().Write("FAIL IAT %s @%08X: VirtualProtect error %lu", what, iatSlot, GetLastError());
return nullptr;
}
void* original = *slot;
*slot = replacement;
VirtualProtect(slot, sizeof(void*), old, &old);
log().Write("OK IAT %s @%08X (was %p)", what, iatSlot, original);
return original;
}
// HookIat by import name (see ImportSlot); nullptr if the exe does not import it.
inline void* HookImport(const char* dll, const char* func, void* replacement, const char* what) {
DWORD slot = ImportSlot(dll, func);
if (!slot) {
log().Write("SKIP IAT %s: %s!%s not imported", what, dll, func);
return nullptr;
}
return HookIat(slot, replacement, what);
}
class Ini {
public:
explicit Ini(const char* path) { lstrcpynA(path_, path, MAX_PATH); }
int Int(const char* section, const char* key, int def) const {
return static_cast<int>(GetPrivateProfileIntA(section, key, def, path_));
}
bool Bool(const char* section, const char* key, bool def) const { return Int(section, key, def) != 0; }
void String(const char* section, const char* key, const char* def, char* out, DWORD cap) const {
GetPrivateProfileStringA(section, key, def, out, cap, path_);
}
void Set(const char* section, const char* key, const char* value) const {
WritePrivateProfileStringA(section, key, value, path_);
}
private:
char path_[MAX_PATH];
};
// Path of a file next to this plugin (the .asi), with its extension replaced.
inline void SiblingPath(HMODULE self, const char* ext, char* out, size_t cap) {
GetModuleFileNameA(self, out, static_cast<DWORD>(cap));
char* dot = std::strrchr(out, '.');
if (dot) *dot = '\0';
strncat_s(out, cap, ext, _TRUNCATE);
}
} // namespace ds2