317 lines
14 KiB
C++
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
|