543 lines
27 KiB
C++
543 lines
27 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>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
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;
|
|
}
|
|
|
|
// ---- The log ---------------------------------------------------------------------------------------------------------------
|
|
// One log file per game start, written by every plugin in the process: <scripts>\logs\DS2Revival.log (DS2Revival.bw.log for
|
|
// the Broken World process, which shares the main game's scripts folder), every line "[time] [plugin] text". Each plugin is its
|
|
// own DLL with its own Log object, so the sharing is done by the file: every plugin opens the same file with FILE_APPEND_DATA
|
|
// only (the system appends each WriteFile at the end atomically, even from several handles and processes) and writes a whole
|
|
// line per call, so lines never tear. The first plugin to start picks the file (OpenShared below); the others find the
|
|
// choice in the process environment (DS2REVIVAL_LOG = "pid|path"; the pid keeps a child process, e.g. Broken World started
|
|
// by the Steam game, from inheriting its parent's file).
|
|
//
|
|
// The last kKeepOldLogs runs are kept: DS2Revival.log is the current run, DS2Revival.1.log the one before, and so on. The
|
|
// rotation happens at the start of the run, by renaming: a file that another running game process still has open (two
|
|
// copies of the game, a second Broken World) cannot be renamed, and then this process writes DS2Revival.<pid>.log instead,
|
|
// so two processes never share or truncate one file. Test instances of the ds2dev harness (DS2DEV_DOCS) always use
|
|
// DS2Revival.<pid>.log and are not rotated (several run at once and the test tools look for their pid).
|
|
//
|
|
// Less noise: the default level writes what helps a player or us diagnose a problem (versions, settings, SKIP / FAIL /
|
|
// errors, one line per feature group, events a player caused). Per-address "OK ... @00XXXXXX" lines, hook counters and other
|
|
// internals are Detail() and only written with [Logging] Detail=1 (on by default in test instances); the patches that did get
|
|
// installed are listed on one summary line per plugin instead (Feature / Installed).
|
|
constexpr int kKeepOldLogs = 3;
|
|
|
|
// A test instance of the ds2dev harness (DS2DEV_DOCS): its own log file per pid, Detail on. DS2_LOG_AS_PLAYER=1 makes such an
|
|
// instance log the way a player's game does (DS2Revival.log with rotation, Detail per the settings file), to test exactly that.
|
|
inline bool TestInstanceLog() {
|
|
return GetEnvironmentVariableA("DS2DEV_DOCS", nullptr, 0) != 0 && GetEnvironmentVariableA("DS2_LOG_AS_PLAYER", nullptr, 0) == 0;
|
|
}
|
|
|
|
inline std::string LogFileName(const std::string& base, int n) { // n = 0: the current run, n >= 1: the n-th run back
|
|
return n ? base + "." + std::to_string(n) + ".log" : base + ".log";
|
|
}
|
|
|
|
// Files named <prefix>.log, <prefix>.bw.log or <prefix>.<number>.log in `dir`: the per-plugin logs of versions up to 1.3.0.
|
|
// Only these names; a file that is open elsewhere is left (the delete fails harmlessly).
|
|
inline void DeleteLegacyPluginLogs(const std::string& dir) {
|
|
static const char* const kLegacy[] = {"DS2Multiplayer", "DS2Widescreen", "DS2Graphics", "DS2Fixes", "DS2BrokenWorld", "DS2ModLoader"};
|
|
for (const char* name : kLegacy) {
|
|
WIN32_FIND_DATAA f;
|
|
HANDLE h = FindFirstFileA((dir + "\\" + name + ".*").c_str(), &f);
|
|
if (h == INVALID_HANDLE_VALUE) continue;
|
|
do {
|
|
std::string rest = f.cFileName + std::strlen(name); // ".log", ".bw.log", ".1234.log"
|
|
bool ours = rest == ".log" || rest == ".bw.log";
|
|
if (!ours && rest.size() > 5 && rest.compare(rest.size() - 4, 4, ".log") == 0) {
|
|
std::string mid = rest.substr(1, rest.size() - 5);
|
|
ours = !mid.empty() && mid.find_first_not_of("0123456789") == std::string::npos;
|
|
}
|
|
if (ours && !(f.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) DeleteFileA((dir + "\\" + f.cFileName).c_str());
|
|
} while (FindNextFileA(h, &f));
|
|
FindClose(h);
|
|
}
|
|
}
|
|
|
|
// <base>.<pid>.log files of earlier runs that had to avoid a running game (a pid has 2+ digits; 1..kKeepOldLogs are rotated runs).
|
|
inline void DeleteOldPidLogs(const std::string& dir, const std::string& base) {
|
|
WIN32_FIND_DATAA f;
|
|
HANDLE h = FindFirstFileA((dir + "\\" + base + ".*.log").c_str(), &f);
|
|
if (h == INVALID_HANDLE_VALUE) return;
|
|
do {
|
|
std::string mid(f.cFileName + base.size() + 1);
|
|
if (mid.size() < 5) continue;
|
|
mid.resize(mid.size() - 4);
|
|
if (mid.size() >= 2 && mid.find_first_not_of("0123456789") == std::string::npos) DeleteFileA((dir + "\\" + f.cFileName).c_str());
|
|
} while (FindNextFileA(h, &f));
|
|
FindClose(h);
|
|
}
|
|
|
|
// Starts a new run's file: shifts the old runs back by one (the oldest goes) and returns the path of the current file, which
|
|
// no longer exists. If the current file cannot be moved because another running game process has it open, returns "".
|
|
inline std::string RotateLogs(const std::string& dir, const std::string& base) {
|
|
const std::string cur = dir + "\\" + LogFileName(base, 0);
|
|
if (GetFileAttributesA(cur.c_str()) != INVALID_FILE_ATTRIBUTES) {
|
|
const std::string park = dir + "\\" + base + "." + std::to_string(GetCurrentProcessId()) + ".new";
|
|
if (!MoveFileExA(cur.c_str(), park.c_str(), MOVEFILE_REPLACE_EXISTING)) return ""; // still open in another game
|
|
DeleteFileA((dir + "\\" + LogFileName(base, kKeepOldLogs)).c_str());
|
|
for (int n = kKeepOldLogs - 1; n >= 1; --n)
|
|
MoveFileExA((dir + "\\" + LogFileName(base, n)).c_str(), (dir + "\\" + LogFileName(base, n + 1)).c_str(), MOVEFILE_REPLACE_EXISTING);
|
|
if (!MoveFileExA(park.c_str(), (dir + "\\" + LogFileName(base, 1)).c_str(), MOVEFILE_REPLACE_EXISTING)) DeleteFileA(park.c_str());
|
|
}
|
|
return cur;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
class Log {
|
|
public:
|
|
// A private file of its own (truncated), not the run's shared one: the test harness, the tool kit, test programs.
|
|
void Open(const char* path) {
|
|
path_ = path;
|
|
h_ = CreateFileA(path, GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); // readable while the game runs
|
|
}
|
|
|
|
// The run's shared file in <scriptsDir>\logs (see the comment above); `plugin` tags the lines, `bw` = the Broken World process.
|
|
// The logs folder is created here; when it is new, the old logs that versions before 1.2.2 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 in the
|
|
// scripts folder.
|
|
void OpenShared(const char* scriptsDir, const char* plugin, bool bw) {
|
|
lstrcpynA(tag_, plugin, sizeof(tag_));
|
|
const bool testInstance = TestInstanceLog();
|
|
std::string dir = std::string(scriptsDir) + "\\logs";
|
|
if (CreateDirectoryA(dir.c_str(), nullptr)) RetireOldLogs(scriptsDir);
|
|
if (GetFileAttributesA(dir.c_str()) == INVALID_FILE_ATTRIBUTES) dir = scriptsDir;
|
|
const std::string base = bw ? "DS2Revival.bw" : "DS2Revival";
|
|
const DWORD pid = GetCurrentProcessId();
|
|
char mutexName[64], pidText[16], chosen[2 * MAX_PATH] = {};
|
|
sprintf_s(mutexName, "Local\\DS2RevivalLog.%lu", pid);
|
|
sprintf_s(pidText, "%lu|", pid);
|
|
HANDLE lock = CreateMutexA(nullptr, FALSE, mutexName); // the plugins start one after the other; this makes sure of it
|
|
if (lock) WaitForSingleObject(lock, 10000);
|
|
std::string path;
|
|
if (GetEnvironmentVariableA("DS2REVIVAL_LOG", chosen, sizeof(chosen)) && std::strncmp(chosen, pidText, std::strlen(pidText)) == 0) {
|
|
path = chosen + std::strlen(pidText); // a plugin of this process chose already
|
|
const size_t slash = path.rfind('\\');
|
|
if (slash == std::string::npos || _stricmp(path.substr(0, slash).c_str(), dir.c_str()) != 0) path.clear(); // it is in another scripts folder
|
|
else h_ = CreateFileA(path.c_str(), FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
|
}
|
|
if (path.empty()) {
|
|
const std::string pidName = dir + "\\DS2Revival." + std::to_string(pid) + ".log";
|
|
if (testInstance) {
|
|
path = pidName;
|
|
} else {
|
|
path = RotateLogs(dir, base);
|
|
if (path.empty()) path = pidName; // another running game has DS2Revival.log
|
|
else {
|
|
DeleteLegacyPluginLogs(dir);
|
|
DeleteOldPidLogs(dir, base);
|
|
}
|
|
}
|
|
h_ = CreateFileA(path.c_str(), FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, path == pidName ? OPEN_ALWAYS : CREATE_NEW, FILE_ATTRIBUTE_NORMAL, nullptr);
|
|
if (h_ == INVALID_HANDLE_VALUE && path != pidName) { // another process created the main name in the meantime
|
|
path = pidName;
|
|
h_ = CreateFileA(path.c_str(), FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
|
}
|
|
first_ = true;
|
|
SetEnvironmentVariableA("DS2REVIVAL_LOG", (std::string(pidText) + path).c_str());
|
|
}
|
|
if (lock) {
|
|
ReleaseMutex(lock);
|
|
CloseHandle(lock);
|
|
}
|
|
path_ = path;
|
|
detail_ = testInstance; // the plugin sets the real value from [Logging] Detail once it has found its settings
|
|
}
|
|
|
|
// 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 (per plugin, so one noisy plugin cannot hide the others). The ds2dev test harness raises the limit (SetLimit)
|
|
// for its traces.
|
|
void SetLimit(long bytes) { limit_ = bytes; }
|
|
// [Logging] Detail=1: the per-address and per-event lines (Detail) are written too.
|
|
void SetDetail(bool on) { detail_ = on; }
|
|
bool detail() const { return detail_; }
|
|
const std::string& path() const { return path_; } // the file this log writes to (tests)
|
|
|
|
void Write(const char* fmt, ...) {
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
Emit(fmt, ap);
|
|
va_end(ap);
|
|
}
|
|
// Like Write, only with [Logging] Detail=1.
|
|
void Detail(const char* fmt, ...) {
|
|
if (!detail_) return;
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
Emit(fmt, ap);
|
|
va_end(ap);
|
|
}
|
|
|
|
// Like Write for the first plugin that opened this run's file; the others stay quiet (unless Detail is on). For facts that are
|
|
// the same in every plugin of the process (which settings file, ...), so the shared log says them once.
|
|
void Once(const char* fmt, ...) {
|
|
if (!first_ && !detail_) return;
|
|
va_list ap;
|
|
va_start(ap, fmt);
|
|
Emit(fmt, ap);
|
|
va_end(ap);
|
|
}
|
|
|
|
// A patch / hook that is in place. With Detail the caller already wrote its "OK ... @address" line; without, the name goes
|
|
// on the plugin's summary line (Installed). After the summary (hooks that are put in later, e.g. when the Direct3D device
|
|
// exists) it gets a line of its own. The same name twice in a row counts once more ("x3").
|
|
void Feature(const char* what) {
|
|
if (summarized_) {
|
|
Write("installed: %s", what);
|
|
return;
|
|
}
|
|
if (!features_.empty() && features_.back().first == what) ++features_.back().second;
|
|
else features_.emplace_back(what, 1);
|
|
}
|
|
// The one summary line of the patches Feature collected; a plugin calls it when its start-up is done.
|
|
void Installed() {
|
|
summarized_ = true;
|
|
if (features_.empty()) return;
|
|
std::string list;
|
|
int total = 0;
|
|
for (const auto& f : features_) {
|
|
list += (list.empty() ? "" : "; ") + f.first;
|
|
if (f.second > 1) list += " x" + std::to_string(f.second);
|
|
total += f.second;
|
|
}
|
|
features_.clear();
|
|
Write("installed %d patch(es): %s", total, list.c_str());
|
|
}
|
|
|
|
private:
|
|
void Emit(const char* fmt, va_list ap) {
|
|
if (h_ == INVALID_HANDLE_VALUE || full_) return;
|
|
SYSTEMTIME t;
|
|
GetLocalTime(&t);
|
|
char head[96];
|
|
int n = sprintf_s(head, "[%02d:%02d:%02d.%03d] ", t.wHour, t.wMinute, t.wSecond, t.wMilliseconds);
|
|
if (tag_[0]) n += sprintf_s(head + n, sizeof(head) - n, "[%s] ", tag_);
|
|
va_list again;
|
|
va_copy(again, ap);
|
|
const int m = _vscprintf(fmt, ap);
|
|
std::string line(head, n);
|
|
if (m > 0) {
|
|
line.resize(n + m);
|
|
vsnprintf(&line[n], m + 1, fmt, again);
|
|
}
|
|
va_end(again);
|
|
line += '\n';
|
|
Put(line.data(), line.size());
|
|
written_ += static_cast<long>(line.size());
|
|
if (written_ >= limit_) {
|
|
full_ = true;
|
|
char last[200];
|
|
const int len = sprintf_s(last, "[%02d:%02d:%02d.%03d] %s%slog limit of %ld bytes reached: further lines are not written\n", t.wHour,
|
|
t.wMinute, t.wSecond, t.wMilliseconds, tag_[0] ? "[" : "", tag_, limit_);
|
|
Put(last, len);
|
|
}
|
|
}
|
|
void Put(const char* s, size_t size) {
|
|
DWORD done;
|
|
WriteFile(h_, s, static_cast<DWORD>(size), &done, nullptr); // one call per line: appended whole
|
|
}
|
|
|
|
HANDLE h_ = INVALID_HANDLE_VALUE;
|
|
std::string path_;
|
|
char tag_[32] = {};
|
|
long written_ = 0, limit_ = 2 * 1024 * 1024;
|
|
bool full_ = false, detail_ = false, summarized_ = false, first_ = false;
|
|
std::vector<std::pair<std::string, int>> features_;
|
|
};
|
|
|
|
inline Log& log() {
|
|
static Log instance;
|
|
return instance;
|
|
}
|
|
|
|
// Put in DllMain right after StartPlugin succeeded: whichever way the start-up function returns, the plugin's summary line
|
|
// (Log::Installed) is written then.
|
|
struct SummaryAtExit {
|
|
~SummaryAtExit() { log().Installed(); }
|
|
};
|
|
|
|
// A patch / hook went in at `addr`: its "OK" line with Detail, else a name on the plugin's summary line.
|
|
inline void LogOk(const char* what, DWORD addr) {
|
|
if (log().detail()) log().Write("OK %s @%08X", what, addr);
|
|
else log().Feature(what);
|
|
}
|
|
|
|
// 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);
|
|
LogOk(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);
|
|
if (log().detail()) log().Write("OK IAT %s @%08X (was %p)", what, iatSlot, original);
|
|
else log().Feature(what);
|
|
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);
|
|
}
|
|
|
|
// 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
|