DS2Revival/mods/common/patch.h

317 lines
14 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
// A log never grows without bound: after kMaxBytes (a line that repeats every frame would fill the disk in a long
// session: a 66 MB DS2Widescreen.log was seen after a two-hour game) one closing line is written and the rest is
// dropped. The ds2dev test harness raises the limit (SetLimit) for its traces.
void SetLimit(long bytes) { limit_ = bytes; }
void Write(const char* fmt, ...) {
if (!file_ || full_) return;
SYSTEMTIME t;
GetLocalTime(&t);
int n = std::fprintf(file_, "[%02d:%02d:%02d.%03d] ", t.wHour, t.wMinute, t.wSecond, t.wMilliseconds);
va_list ap;
va_start(ap, fmt);
int m = std::vfprintf(file_, fmt, ap);
va_end(ap);
std::fputc('\n', file_);
written_ += (n > 0 ? n : 0) + (m > 0 ? m : 0) + 1;
if (written_ >= limit_) {
full_ = true;
std::fprintf(file_, "[%02d:%02d:%02d.%03d] log limit of %ld bytes reached: further lines are not written\n", t.wHour, t.wMinute,
t.wSecond, t.wMilliseconds, limit_);
}
std::fflush(file_);
}
private:
FILE* file_ = nullptr;
long written_ = 0, limit_ = 4 * 1024 * 1024;
bool full_ = false;
};
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); }
// legacySection: where the key used to live; a value there is the default for the new place (older files keep working).
int Int(const char* section, const char* key, int def, const char* legacySection = nullptr) const {
if (legacySection) def = Int(legacySection, key, def);
return static_cast<int>(GetPrivateProfileIntA(section, key, def, path_));
}
bool Bool(const char* section, const char* key, bool def, const char* legacySection = nullptr) const {
return Int(section, key, def, legacySection) != 0;
}
// A number, or "auto" (also an empty or missing value) = autoValue, which depends on the situation (so DS2Revival.ini
// lists such a setting as "auto" rather than with a value that would be wrong in some of them).
int IntOrAuto(const char* section, const char* key, int autoValue) const {
char v[32];
GetPrivateProfileStringA(section, key, "", v, sizeof(v), path_);
return !v[0] || _stricmp(v, "auto") == 0 ? autoValue : std::atoi(v);
}
bool BoolOrAuto(const char* section, const char* key, bool autoValue) const { return IntOrAuto(section, key, autoValue) != 0; }
void String(const char* section, const char* key, const char* def, char* out, DWORD cap,
const char* legacySection = nullptr) const {
if (legacySection) {
std::string old(cap, '\0');
GetPrivateProfileStringA(legacySection, key, def, &old[0], cap, path_);
GetPrivateProfileStringA(section, key, old.c_str(), out, cap, path_);
return;
}
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);
}
// Our own old log files in `dir` (a scripts folder), from before the logs moved to scripts\logs: DS2*.log (also DS2*.bw.log and
// DS2*.<pid>.log) and ds2dev*.log. Only these names; a file that is open is left (the delete fails harmlessly).
inline void RetireOldLogs(const char* dir) {
static const char* const kPatterns[] = {"DS2*.log", "ds2dev*.log"};
for (const char* pattern : kPatterns) {
char mask[MAX_PATH];
sprintf_s(mask, "%s\\%s", dir, pattern);
WIN32_FIND_DATAA f;
HANDLE h = FindFirstFileA(mask, &f);
if (h == INVALID_HANDLE_VALUE) continue;
do {
if (f.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) continue;
char file[MAX_PATH];
sprintf_s(file, "%s\\%s", dir, f.cFileName);
DeleteFileA(file);
} while (FindNextFileA(h, &f));
FindClose(h);
}
}
// Where a plugin's log goes: <scriptsDir>\logs\<name><ext> (ext = ".log", ".bw.log", ".<pid>.log"...). The logs folder is created
// here; when it is new, the old logs that earlier versions kept in the scripts folder itself are deleted (RetireOldLogs), so
// the scripts folder holds only plugins. If the folder cannot be created the log stays next to the plugin as before.
inline void LogPathIn(const char* scriptsDir, const char* name, const char* ext, char* out, size_t cap) {
char logs[MAX_PATH];
sprintf_s(logs, "%s\\logs", scriptsDir);
if (CreateDirectoryA(logs, nullptr)) RetireOldLogs(scriptsDir);
if (GetFileAttributesA(logs) == INVALID_FILE_ATTRIBUTES) sprintf_s(out, cap, "%s\\%s%s", scriptsDir, name, ext);
else sprintf_s(out, cap, "%s\\%s%s", logs, name, ext);
}
// The log of the plugin at `self`, in the logs folder of the folder the .asi is in.
inline void LogPath(HMODULE self, const char* ext, char* out, size_t cap) {
char own[MAX_PATH];
GetModuleFileNameA(self, own, MAX_PATH);
char* slash = std::strrchr(own, '\\');
if (!slash) { SiblingPath(self, ext, out, cap); return; }
std::string name = slash + 1;
*slash = 0;
if (size_t dot = name.rfind('.'); dot != std::string::npos) name.resize(dot);
LogPathIn(own, name.c_str(), ext, out, cap);
}
} // namespace ds2