DS2Revival/mods/fixes/fixes.cpp
DS2Revival a1e2f8f9b5
Some checks failed
build / build (push) Failing after 1m35s
CI: build on the project's own runner at git.dsii.uk
2026-10-27 22:02:02 -04:00

1346 lines
72 KiB
C++

// DS2Fixes.asi - small fixes for Dungeon Siege II (Steam v2.3) and Broken World on modern Windows.
// Delete the .asi to turn them off; [Fixes] in DS2Fixes.ini (optional) turns single fixes off.
#include "../common/plugin.h"
#include <unordered_map>
#include <intrin.h>
UINT GameTextCodepage(); // text.cpp: the code page the game converts its narrow text with
#include <cwchar>
namespace {
// --- System-wide keyboard lag ----------------------------------------------------------------
// At start-up the game installs a global low-level keyboard hook (SetWindowsHookExA(WH_KEYBOARD_LL) at
// 0x5ecee5, callback 0x5ed327; it only suppresses the Windows key and shortcuts in fullscreen) on its frame
// thread and never removes it. Windows routes EVERY keystroke of EVERY app through that thread, so whenever
// a frame is slow, typing anywhere stalls (up to LowLevelHooksTimeout per key). The game ignores the result
// (it just stores the handle), so refusing it is safe; in a borderless window the Windows key is harmless.
using SetWindowsHookExA_t = HHOOK(WINAPI*)(int, HOOKPROC, HINSTANCE, DWORD);
SetWindowsHookExA_t g_SetWindowsHookExA;
HHOOK WINAPI HookSetWindowsHookExA(int id, HOOKPROC proc, HINSTANCE mod, DWORD thread) {
if (id == WH_KEYBOARD_LL) {
ds2::log().Detail("input: global keyboard hook not installed (stops system-wide typing lag)");
return nullptr;
}
return g_SetWindowsHookExA(id, proc, mod, thread);
}
// --- Stutter while the mouse moves -----------------------------------------------------------
// Windows sends WM_SETCURSOR for every mouse movement over the window. The game's window procedure answers each
// one, while its own cursor is active, with SetCursor(NULL) followed by IDirect3DDevice9::ShowCursor(TRUE)
// (Broken World 0x52981b..0x52984f; the Steam game has the same handler) - two real cursor changes per mouse
// event, i.e. thousands of window-manager calls per second with a modern mouse. Each can block on the window
// manager's lock: profiled hitches (25-125 ms frames) spent 27% of their time in NtUserSetCursor from this
// handler, against 6% in normal frames. Pass WM_SETCURSOR (client area) to the game only when the cursor is not
// already the one the game set last time, or at most every 100 ms, so cursor changes still show up.
WNDPROC g_gameWndProc;
HCURSOR g_lastCursor;
DWORD g_lastPass;
LRESULT CALLBACK QuietCursorWndProc(HWND h, UINT msg, WPARAM wp, LPARAM lp) {
if (msg == WM_SETCURSOR && LOWORD(lp) == HTCLIENT) {
HCURSOR cur = GetCursor();
DWORD now = GetTickCount();
if (cur && cur == g_lastCursor && now - g_lastPass < 100) return TRUE;
LRESULT r = CallWindowProcA(g_gameWndProc, h, msg, wp, lp);
g_lastCursor = GetCursor();
g_lastPass = now;
return r;
}
return CallWindowProcA(g_gameWndProc, h, msg, wp, lp);
}
bool IsGameClass(LPCSTR name) {
return name && !IS_INTRESOURCE(name) && _strnicmp(name, "gpgwndclass", 11) == 0;
}
using RegisterClassA_t = ATOM(WINAPI*)(const WNDCLASSA*);
using RegisterClassExA_t = ATOM(WINAPI*)(const WNDCLASSEXA*);
RegisterClassA_t g_RegisterClassA;
RegisterClassExA_t g_RegisterClassExA;
ATOM WINAPI HookRegisterClassA(const WNDCLASSA* wc) {
if (wc && IsGameClass(wc->lpszClassName) && !g_gameWndProc) {
WNDCLASSA copy = *wc;
g_gameWndProc = wc->lpfnWndProc;
copy.lpfnWndProc = QuietCursorWndProc;
ds2::log().Detail("input: cursor updates of window class %s filtered (stutter while moving the mouse)", wc->lpszClassName);
return g_RegisterClassA(&copy);
}
return g_RegisterClassA(wc);
}
ATOM WINAPI HookRegisterClassExA(const WNDCLASSEXA* wc) {
if (wc && IsGameClass(wc->lpszClassName) && !g_gameWndProc) {
WNDCLASSEXA copy = *wc;
g_gameWndProc = wc->lpfnWndProc;
copy.lpfnWndProc = QuietCursorWndProc;
ds2::log().Detail("input: cursor updates of window class %s filtered (stutter while moving the mouse)", wc->lpszClassName);
return g_RegisterClassExA(&copy);
}
return g_RegisterClassExA(wc);
}
// --- Camera spinning wildly with the middle mouse button at high frame rates ------------------------------
// While the middle button is held, every frame the game reads the cursor, turns the camera by its distance from
// the window centre, and puts it back at the centre: SetCursorPos from one call site (Steam 0x5efc84, Broken World
// 0x614b5a, in the function after the "+0x124/+0x128 = centre" stores). Since Windows 10 1709 such a warp is not
// reliably seen by the next read (SDL issue 2695); at hundreds of frames per second the game reads again within
// about a millisecond (DS2Widescreen LogCursor) and counts the same movement again, frame after frame.
// Fix: that one call no longer moves the real cursor. The (hidden) real cursor moves freely and the game is shown
// real position + offset, the offset making its read right after the recentre equal the centre - the same
// deltas, with no warp to wait for. Only when the real cursor nears its monitor's edge is it warped back for
// real; reads that still show the old position then are answered with the centre. Mouse-look ends on any
// other SetCursorPos (the game's restore of the cursor), ShowCursor(TRUE), or 250 ms without a recentre; the
// real cursor is then put where the game believes it is.
constexpr char kRecentrePattern[] =
"8B 45 F0 8B 80 AC 00 00 00 8B 4D F0 03 81 24 01 00 00 50 FF 15 ?? ?? ?? ?? 8A 45 FF C9 C2 04 00";
constexpr DWORD kRecentreCallOffset = 19; // FF 15 [SetCursorPos]
using SetCursorPos_t = BOOL(WINAPI*)(int, int);
using GetCursorPos_t = BOOL(WINAPI*)(LPPOINT);
using ShowCursor_t = int(WINAPI*)(BOOL);
SetCursorPos_t g_SetCursorPos;
GetCursorPos_t g_GetCursorPos;
ShowCursor_t g_ShowCursor;
DWORD g_recentreSlot; // the IAT slot the call used: called at run time, so later hooks (DS2Widescreen) apply
bool g_inRecentre; // inside the game's recentre call
bool g_mouseLook; // the game sees real cursor + g_off
POINT g_off;
DWORD g_lastRecentre;
bool g_warping; // the real cursor was warped from g_warpFrom to g_warpTo; reads may lag behind
POINT g_warpFrom, g_warpTo;
DWORD g_warpAt;
BOOL WINAPI RecentreThunk(int x, int y) {
g_inRecentre = true;
BOOL ok = (*reinterpret_cast<SetCursorPos_t*>(g_recentreSlot))(x, y);
g_inRecentre = false;
return ok;
}
void* g_recentreTarget = reinterpret_cast<void*>(&RecentreThunk); // "call [g_recentreTarget]"
void EndMouseLook(const POINT* real) {
if (!g_mouseLook) return;
g_mouseLook = false;
g_warping = false;
POINT r;
if (!real && g_GetCursorPos(&r)) real = &r;
if (real && (g_off.x || g_off.y)) g_SetCursorPos(real->x + g_off.x, real->y + g_off.y);
g_off = {0, 0};
}
bool NearMonitorEdge(POINT p) {
MONITORINFO mi = {sizeof(mi)};
if (!GetMonitorInfoA(MonitorFromPoint(p, MONITOR_DEFAULTTONEAREST), &mi)) return true;
const RECT& m = mi.rcMonitor;
LONG mx = (m.right - m.left) / 8, my = (m.bottom - m.top) / 8;
return p.x < m.left + mx || p.x >= m.right - mx || p.y < m.top + my || p.y >= m.bottom - my;
}
BOOL WINAPI SteadySetCursorPos(int x, int y) {
if (!g_inRecentre) { // the game placing the cursor itself (e.g. back where the drag started)
g_mouseLook = false;
g_warping = false;
g_off = {0, 0};
return g_SetCursorPos(x, y);
}
POINT real;
if (!g_GetCursorPos(&real)) return g_SetCursorPos(x, y);
g_lastRecentre = GetTickCount();
g_mouseLook = true;
if (g_warping && g_lastRecentre - g_warpAt < 100) { // the last warp is not visible yet: keep waiting for it
g_warpTo = {x, y};
return TRUE;
}
g_warping = false;
if (NearMonitorEdge(real)) {
g_warping = true;
g_warpFrom = real;
g_warpTo = {x, y};
g_warpAt = g_lastRecentre;
g_off = {0, 0};
return g_SetCursorPos(x, y);
}
g_off = {x - real.x, y - real.y};
return TRUE;
}
BOOL WINAPI SteadyGetCursorPos(LPPOINT p) {
BOOL ok = g_GetCursorPos(p);
if (!ok || !p || !g_mouseLook) return ok;
DWORD now = GetTickCount();
if (now - g_lastRecentre > 250) { // mouse-look ended without a restore or ShowCursor
EndMouseLook(p);
return g_GetCursorPos(p);
}
if (g_warping) {
auto d2 = [](POINT a, POINT b) { return double(a.x - b.x) * (a.x - b.x) + double(a.y - b.y) * (a.y - b.y); };
if (now - g_warpAt < 100 && d2(*p, g_warpFrom) < d2(*p, g_warpTo)) { // still the pre-warp position
*p = g_warpTo;
return ok;
}
g_warping = false;
}
p->x += g_off.x;
p->y += g_off.y;
return ok;
}
int WINAPI SteadyShowCursor(BOOL show) {
if (show) EndMouseLook(nullptr);
return g_ShowCursor(show);
}
void InstallSteadyMouseLook() {
DWORD at = ds2::FindUnique(kRecentrePattern);
if (!at) {
ds2::log().Write("SKIP input: steady camera drag (recentre call not found)");
return;
}
DWORD operand = at + kRecentreCallOffset + 2;
DWORD slot = *reinterpret_cast<const DWORD*>(operand);
if (slot != ds2::ImportSlot("USER32.dll", "SetCursorPos")) {
ds2::log().Write("SKIP input: steady camera drag (call at %08lX is not SetCursorPos)", operand - 2);
return;
}
g_SetCursorPos = static_cast<SetCursorPos_t>(
ds2::HookImport("USER32.dll", "SetCursorPos", reinterpret_cast<void*>(SteadySetCursorPos), "SetCursorPos (steady camera drag)"));
g_GetCursorPos = static_cast<GetCursorPos_t>(
ds2::HookImport("USER32.dll", "GetCursorPos", reinterpret_cast<void*>(SteadyGetCursorPos), "GetCursorPos (steady camera drag)"));
g_ShowCursor = static_cast<ShowCursor_t>(
ds2::HookImport("USER32.dll", "ShowCursor", reinterpret_cast<void*>(SteadyShowCursor), "ShowCursor (steady camera drag)"));
if (!g_SetCursorPos || !g_GetCursorPos) return;
g_recentreSlot = slot;
DWORD target = reinterpret_cast<DWORD>(&g_recentreTarget);
ds2::PatchBytes(operand, &slot, &target, 4, "input: camera drag without a cursor warp every frame");
}
// --- Gas Powered Games sign-up screen --------------------------------------------------------
// The first time Single Player is chosen, UIFrontend::TransitionToSP (Steam 0x44dd10) asks sub_449ff7 whether the
// game setting regrequest_sp exists; if not it stores it and shows the e-mail/postal-code newsletter form, whose
// service has long been gone. "No, Thanks" stores regrequest_sp = true and optout = true in the game's own settings
// file. Write those two lines before the game reads the file, when they are missing, so the form never appears.
// Broken World has the same check (under its copy protection) and its own file. The documents folder is read from
// the registry (calling the shell under the loader lock is not safe); test instances use DS2DEV_DOCS.
bool HasKey(const std::string& text, const char* key) {
size_t n = std::strlen(key);
for (size_t at = 0; at < text.size();) {
size_t end = text.find('\n', at);
if (end == std::string::npos) end = text.size();
size_t k = text.find_first_not_of(" \t", at);
if (k < end && text.compare(k, n, key) == 0 && k + n < end && (text[k + n] == ' ' || text[k + n] == '='))
return true;
at = end + 1;
}
return false;
}
void FixDocuments(wchar_t* path); // saves section below
// My Documents as the shell stores it, read without calling the shell (this runs under the loader lock); test
// instances use DS2DEV_DOCS.
bool DocumentsFromRegistry(wchar_t* out) {
if (GetEnvironmentVariableW(L"DS2DEV_DOCS", out, MAX_PATH)) return true;
wchar_t raw[MAX_PATH] = {};
DWORD size = sizeof(raw);
return RegGetValueW(HKEY_CURRENT_USER, L"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders", L"Personal",
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND, nullptr, raw, &size) == ERROR_SUCCESS &&
ExpandEnvironmentStringsW(raw, out, MAX_PATH);
}
void SkipSignUpForm(bool brokenWorld) {
// Wide all the way: a documents path with characters outside the ANSI code page is handed to the game as an
// ASCII junction (FixDocuments), and the ini is written where the game will look for it.
wchar_t wdocs[MAX_PATH] = {};
if (!DocumentsFromRegistry(wdocs)) {
ds2::log().Write("SKIP sign-up form: documents folder not found");
return;
}
FixDocuments(wdocs);
char docs[MAX_PATH] = {};
if (!WideCharToMultiByte(CP_ACP, 0, wdocs, -1, docs, MAX_PATH, nullptr, nullptr)) {
ds2::log().Write("SKIP sign-up form: documents folder not found");
return;
}
const char* folder = brokenWorld ? "Dungeon Siege 2 Broken World" : "Dungeon Siege 2";
char dir[MAX_PATH], file[MAX_PATH];
sprintf_s(dir, "%s\\My Games", docs);
CreateDirectoryA(dir, nullptr);
sprintf_s(dir, "%s\\My Games\\%s", docs, folder);
CreateDirectoryA(dir, nullptr);
sprintf_s(file, "%s\\%s.ini", dir, brokenWorld ? "DungeonSiege2BrokenWorld" : "DungeonSiege2");
std::string text;
if (FILE* f = nullptr; fopen_s(&f, file, "rb") == 0 && f) {
char buf[4096];
for (size_t got; (got = fread(buf, 1, sizeof(buf), f)) > 0;) text.append(buf, got);
fclose(f);
}
std::string add;
if (!HasKey(text, "regrequest_sp")) add += "regrequest_sp = true\r\n";
if (!HasKey(text, "optout")) add += "optout = true\r\n";
if (add.empty()) return;
if (!text.empty() && text.back() != '\n') text += "\r\n";
text += add;
FILE* f = nullptr;
if (fopen_s(&f, file, "wb") != 0 || !f) {
ds2::log().Write("SKIP sign-up form: cannot write %s", file);
return;
}
fwrite(text.data(), 1, text.size(), f);
fclose(f);
ds2::log().Detail("sign-up form: marked as answered in %s", file);
}
// --- Dynamic vertex upload reads past the end of the caller's array -------------------------------------------
// The Rapi object keeps a dynamic vertex buffer of 0x10000 vertices of 24 bytes (FVF XYZ|DIFFUSE|TEX1, created at
// Steam 0x50860c) and fills it through one routine (Steam 0x501a20; Broken World 0x50fcf0: this = Rapi, args = vertex
// count, out: first index, source pointer; nine callers: aspect sub-meshes, shadow/blur quads, particles). It rounds
// the count up to a multiple of 8 vertices, locks that many and copies N * 24 bytes from the source in 64-byte steps
// with SSE2 (movdqu/movntdq) or MMX (movq/movntq), the scalar `rep movsd` of count * 24 bytes only on CPUs without
// SSE. So on any current CPU every call with count % 8 != 0 reads up to 7 * 24 = 168 bytes (a 4-vertex quad: 96)
// BEYOND the end of the caller's array. The destination is fine (it was locked with the rounded size). The extra
// bytes are copied into vertices nobody draws, so the over-read is harmless unless the array ends within 168 bytes of
// the end of the committed memory around it - then the copy faults (an access violation inside the optimised copy loop).
// Fix: when the bytes after the array (up to the rounded size) are not readable, hand the original routine a private
// copy of the array padded with zeroes; everything else (the common case) is passed straight through.
using UploadFn = bool(__fastcall*)(void*, void*, int, int*, const void*);
UploadFn g_upload;
constexpr BYTE kUploadPrologue[] = {0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x08, 0x53, 0x8B, 0xD9}; // push ebp; mov ebp,esp; sub esp,8; push ebx; mov ebx,ecx
// The SSE2 copy loop inside the routine (2 x prefetchnta, 4 x movdqu, first movntdq): found in both exes.
constexpr char kUploadLoop[] = "0F 18 86 80 00 00 00 0F 18 86 A0 00 00 00 F3 0F 6F 0E F3 0F 6F 56 10 F3 0F 6F 5E 20 F3 0F 6F 66 30 66 0F E7 0F";
// True when `bytes` bytes starting at `p` can be read.
bool ReadableRange(const BYTE* p, size_t bytes) {
while (bytes) {
MEMORY_BASIC_INFORMATION m;
if (!VirtualQuery(p, &m, sizeof(m)) || m.State != MEM_COMMIT || (m.Protect & (PAGE_NOACCESS | PAGE_GUARD))) return false;
const BYTE* end = static_cast<const BYTE*>(m.BaseAddress) + m.RegionSize;
size_t got = static_cast<size_t>(end - p);
if (got >= bytes) return true;
bytes -= got;
p = end;
}
return true;
}
bool __fastcall SafeVertexUpload(void* self, void* edx, int count, int* first, const void* src) {
if (count > 0 && (count & 7) && src) {
size_t have = static_cast<size_t>(count) * 24;
size_t padded = static_cast<size_t>((count + 7) & ~7) * 24;
const BYTE* end = static_cast<const BYTE*>(src) + have;
// the over-read is on the page of the last valid byte (always readable) unless it crosses into the next page
if (((reinterpret_cast<DWORD>(end) - 1) >> 12) != ((reinterpret_cast<DWORD>(end) + (padded - have) - 1) >> 12) &&
!ReadableRange(end, padded - have)) {
BYTE stackBuf[2048];
BYTE* buf = padded <= sizeof(stackBuf) ? stackBuf : static_cast<BYTE*>(VirtualAlloc(nullptr, padded, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE));
if (buf) {
std::memcpy(buf, src, have);
std::memset(buf + have, 0, padded - have);
bool ok = g_upload(self, edx, count, first, buf);
if (buf != stackBuf) VirtualFree(buf, 0, MEM_RELEASE);
return ok;
}
}
}
return g_upload(self, edx, count, first, src);
}
void InstallSafeVertexUpload() {
DWORD loop = ds2::FindUnique(kUploadLoop);
if (!loop) {
ds2::log().Write("SKIP vertex upload over-read: copy loop not found");
return;
}
DWORD start = 0;
for (DWORD a = loop - sizeof(kUploadPrologue); a + 0x200 > loop && !start; --a)
if (std::memcmp(reinterpret_cast<void*>(a), kUploadPrologue, sizeof(kUploadPrologue)) == 0) start = a;
if (!start) {
ds2::log().Write("SKIP vertex upload over-read: routine start not found before %08lX", loop);
return;
}
g_upload = static_cast<UploadFn>(ds2::Detour(start, kUploadPrologue, sizeof(kUploadPrologue),
reinterpret_cast<void*>(SafeVertexUpload), "vertex upload (no read past the source array)"));
}
// --- "Your hardware configuration is below minimum specification" at the first start ---------------------------------
// system_detail.gas knows each graphics vendor by its PCI id (3dfx ... Intel, ATI, NVIDIA); any other id gets the
// defaults of [video_capabilities] (below_min_spec = true, simple_render = true). That is every driverless "Basic
// Render Driver" (0x1414: remote sessions, VMs, Windows on ARM), Hyper-V/VMware/VirtualBox adapters and new vendors.
// The start-up code (Steam 0x8f7637, string "min_spec_warning") then shows a modal "Dungeon Siege II Message" box
// (MessageBoxW) once and writes min_spec_warning=true to the player's config. The text talks about 32 MB cards; on
// such a PC the game runs fine. The box is answered with OK without showing it (the game still records that it
// warned). The 2.0 GHz CPU warning is handled the same way.
using MessageBoxW_t = int(WINAPI*)(HWND, LPCWSTR, LPCWSTR, UINT);
MessageBoxW_t g_MessageBoxW;
int WINAPI HookMessageBoxW(HWND wnd, LPCWSTR text, LPCWSTR caption, UINT type) {
if (text && (std::wcsncmp(text, L"Your hardware configuration is below minimum specification", 58) == 0 ||
std::wcsncmp(text, L"Your video hardware is supported by Dungeon Siege II but requires a 2.0 GHz", 70) == 0)) {
ds2::log().Detail("start-up: the \"below minimum specification\" warning is not shown (unknown graphics vendor id in system_detail.gas)");
return IDOK;
}
return g_MessageBoxW(wnd, text, caption, type);
}
// --- Saves: documents folder the game cannot use ----------------------------------------------------------------
// The game builds its "user_path" (settings, saves, logs, screenshots) from the My Documents folder: Config.cpp
// (Steam 0x625a4c) asks SHGetFolderPathA/W (through SHFOLDER.DLL, resolved with GetProcAddress, and SHELL32's
// SHGetFolderPathW import), appends "\My Games\Dungeon Siege 2" and creates it. Everything after that is narrow
// (ANSI) text, so a documents path with characters outside the Windows ANSI code page (a Cyrillic or Chinese user
// name on an English Windows) becomes '?' and cannot be created. The game then silently falls back to
// user_path = %exe_path% (its own install folder), so heroes and saves go there instead, and when that folder is
// not writable either (Program Files, Controlled Folder Access) it stops with "Unable to access or create a
// critical path that is required to operate". Seen in private test copies of the unmodified game: a Cyrillic/CJK
// documents folder stayed empty and the hero was saved in the game folder; a write-protected documents folder
// (ACL deny, the same ERROR_ACCESS_DENIED Controlled Folder Access returns) made the game use its install folder,
// and when that was protected too the critical path box appeared (docs/roadmap/bug-fixes.md, R5).
//
// Fix 1 (AsciiDocumentsPath): when the real documents path is not representable in the ANSI code page, hand the
// game an ASCII-only directory junction to it (%ProgramData%\DS2Revival\docs_<hash>, else %PUBLIC%, else the 8.3
// short name), so it reads and writes the real folder. Fix 2 (SaveFolderCheck): a few seconds after start, try to
// create <documents>\My Games\<game> and a temporary file; when that is refused say so, with the likely causes
// and the fix, instead of the game's silent fallback.
using ShGetFolderPathW_t = HRESULT(WINAPI*)(HWND, int, HANDLE, DWORD, LPWSTR);
using ShGetFolderPathA_t = HRESULT(WINAPI*)(HWND, int, HANDLE, DWORD, LPSTR);
using GetProcAddress_t = FARPROC(WINAPI*)(HMODULE, LPCSTR);
ShGetFolderPathW_t g_shellW; // SHELL32 import as it was before us (ds2dev's redirect in a test copy)
ShGetFolderPathW_t g_shfolderW; // what GetProcAddress(SHFOLDER, "SHGetFolderPathW") returned before us
ShGetFolderPathA_t g_shfolderA;
GetProcAddress_t g_getProcAddress;
bool g_asciiDocs = true;
wchar_t g_aliasFor[MAX_PATH], g_alias[MAX_PATH]; // cache: the real documents path and what the game gets for it
constexpr int kCsidlPersonal = 5;
// UTF-8 text for the log (the log's own %ls conversion stops at the first character the ANSI code page lacks).
std::string U8(const wchar_t* s) {
char buf[MAX_PATH * 4];
return WideCharToMultiByte(CP_UTF8, 0, s, -1, buf, sizeof(buf), nullptr, nullptr) > 0 ? buf : "?";
}
// True when `s` converts to the code page the game uses (the system's, or the text code page set by text.cpp) without a
// replacement or best-fit character. UTF-8 counts as safe only when the game really runs UTF-8.
bool AnsiSafe(const wchar_t* s) {
UINT cp = GameTextCodepage();
if (cp == CP_UTF8) return true;
char buf[MAX_PATH * 3];
BOOL used = FALSE;
int n = WideCharToMultiByte(cp, WC_NO_BEST_FIT_CHARS, s, -1, buf, sizeof(buf), nullptr, &used);
return n > 0 && !used;
}
DWORD Fnv(const wchar_t* s) {
DWORD h = 2166136261u;
for (; *s; ++s) h = (h ^ static_cast<DWORD>(towlower(*s))) * 16777619u;
return h;
}
// Makes `dir` (an empty directory) a junction to `target`.
bool MakeJunction(const wchar_t* dir, const wchar_t* target) {
std::wstring sub = L"\\??\\";
sub += target;
while (sub.size() > 4 && sub.back() == L'\\') sub.pop_back();
std::wstring print = sub.substr(4);
size_t subBytes = sub.size() * 2, printBytes = print.size() * 2;
std::string buf(8 + 8 + subBytes + 2 + printBytes + 2, '\0');
auto put16 = [&](size_t at, unsigned v) { buf[at] = static_cast<char>(v & 0xFF); buf[at + 1] = static_cast<char>(v >> 8); };
DWORD tag = 0xA0000003; // IO_REPARSE_TAG_MOUNT_POINT
std::memcpy(&buf[0], &tag, 4);
put16(4, static_cast<unsigned>(buf.size() - 8)); // ReparseDataLength
put16(8, 0); // SubstituteNameOffset
put16(10, static_cast<unsigned>(subBytes));
put16(12, static_cast<unsigned>(subBytes + 2)); // PrintNameOffset
put16(14, static_cast<unsigned>(printBytes));
std::memcpy(&buf[16], sub.c_str(), subBytes);
std::memcpy(&buf[16 + subBytes + 2], print.c_str(), printBytes);
HANDLE h = CreateFileW(dir, GENERIC_WRITE, 0, nullptr, OPEN_EXISTING,
FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS, nullptr);
if (h == INVALID_HANDLE_VALUE) return false;
DWORD got = 0;
BOOL ok = DeviceIoControl(h, 0x000900A4 /* FSCTL_SET_REPARSE_POINT */, &buf[0], static_cast<DWORD>(buf.size()),
nullptr, 0, &got, nullptr);
CloseHandle(h);
return ok != FALSE;
}
// An ASCII-only path that leads to the same folder as `real`; false when none could be made.
bool MakeAlias(const wchar_t* real, wchar_t* out) {
wchar_t base[MAX_PATH];
const wchar_t* vars[] = {L"ProgramData", L"PUBLIC"};
for (const wchar_t* var : vars) {
if (!GetEnvironmentVariableW(var, base, MAX_PATH) || !AnsiSafe(base)) continue;
wchar_t dir[MAX_PATH];
swprintf_s(dir, L"%ls\\DS2Revival", base);
CreateDirectoryW(dir, nullptr);
swprintf_s(out, MAX_PATH, L"%ls\\docs_%08lx", dir, Fnv(real));
DWORD attr = GetFileAttributesW(out);
if (attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_REPARSE_POINT)) return true; // made by an earlier start
if (attr == INVALID_FILE_ATTRIBUTES && CreateDirectoryW(out, nullptr)) {
if (MakeJunction(out, real)) return true;
RemoveDirectoryW(out);
}
}
wchar_t shortPath[MAX_PATH];
if (GetShortPathNameW(real, shortPath, MAX_PATH) && AnsiSafe(shortPath)) {
lstrcpynW(out, shortPath, MAX_PATH);
return true;
}
return false;
}
// After a successful My Documents lookup: swap a path the game cannot use for one it can.
void FixDocuments(wchar_t* path) {
if (!g_asciiDocs || !path[0] || AnsiSafe(path)) return;
if (!g_aliasFor[0] || _wcsicmp(g_aliasFor, path) != 0) {
wchar_t alias[MAX_PATH] = {};
bool ok = MakeAlias(path, alias);
ds2::log().Write(ok ? "saves: documents folder %s is not representable in the ANSI code page %u; the game gets %s instead"
: "saves: documents folder %s is not representable in the ANSI code page %u and no ASCII alias could be made",
U8(path).c_str(), GameTextCodepage(), U8(alias).c_str());
lstrcpynW(g_aliasFor, path, MAX_PATH);
lstrcpynW(g_alias, ok ? alias : path, MAX_PATH);
}
lstrcpynW(path, g_alias, MAX_PATH);
}
HRESULT WINAPI HookShellW(HWND w, int csidl, HANDLE token, DWORD flags, LPWSTR out) {
HRESULT hr = g_shellW(w, csidl, token, flags, out);
if (SUCCEEDED(hr) && out && (csidl & 0xFF) == kCsidlPersonal) FixDocuments(out);
return hr;
}
HRESULT WINAPI HookShfolderW(HWND w, int csidl, HANDLE token, DWORD flags, LPWSTR out) {
HRESULT hr = g_shfolderW(w, csidl, token, flags, out);
if (SUCCEEDED(hr) && out && (csidl & 0xFF) == kCsidlPersonal) FixDocuments(out);
return hr;
}
// The ANSI variant loses the characters before we could look: ask the wide one and convert what the game can use.
HRESULT WINAPI HookShfolderA(HWND w, int csidl, HANDLE token, DWORD flags, LPSTR out) {
ShGetFolderPathW_t wide = g_shellW ? g_shellW : g_shfolderW;
if ((csidl & 0xFF) == kCsidlPersonal && out && wide) {
wchar_t path[MAX_PATH] = {};
HRESULT hr = wide(w, csidl, token, flags, path);
if (SUCCEEDED(hr)) {
FixDocuments(path);
WideCharToMultiByte(CP_ACP, 0, path, -1, out, MAX_PATH, nullptr, nullptr);
return hr;
}
}
return g_shfolderA(w, csidl, token, flags, out);
}
FARPROC WINAPI HookGetProcAddress(HMODULE module, LPCSTR name) {
FARPROC p = g_getProcAddress(module, name);
if (!p || IS_INTRESOURCE(name)) return p;
if (lstrcmpA(name, "SHGetFolderPathW") == 0) {
g_shfolderW = reinterpret_cast<ShGetFolderPathW_t>(p);
return reinterpret_cast<FARPROC>(HookShfolderW);
}
if (lstrcmpA(name, "SHGetFolderPathA") == 0) {
g_shfolderA = reinterpret_cast<ShGetFolderPathA_t>(p);
return reinterpret_cast<FARPROC>(HookShfolderA);
}
return p;
}
// Can the game create its folders under My Documents? The test runs at start-up (file calls only, no shell: we are
// under the loader lock) and a refusal (Controlled Folder Access, another security program, folder permissions) is
// explained in words. When only the user_path folder itself cannot be made the game falls back to its install folder
// without a word; when the folder exists but its Save/Logs sub-folders cannot be made it stops with "Unable to access
// or create a critical path" within a fraction of a second, so the message is also shown right before the game's own
// error box (hook of DialogBoxIndirectParamA), on the game's own thread; else a few seconds after start.
struct SaveBlock {
bool blocked = false;
DWORD err = 0;
wchar_t dir[MAX_PATH] = {};
LONG shown = 0;
} g_block;
void ShowBlockedMessage() {
if (!g_block.blocked || InterlockedExchange(&g_block.shown, 1)) return;
wchar_t text[1800];
swprintf_s(text,
L"Dungeon Siege II cannot write to its save folder:\n\n%ls\n\nWindows answered %ls (error %lu). The usual cause is "
L"Controlled folder access (Windows Security > Virus & threat protection > Ransomware protection), another security "
L"program, or the folder's permissions.\n\nUntil that is fixed the game keeps its settings and saves in its own install "
L"folder, or stops with \"Unable to access or create a critical path\". Saves made earlier in your Documents folder will "
L"not be listed.\n\nFix: in Windows Security > Ransomware protection > Allow an app through Controlled folder access, add "
L"DungeonSiege2.exe (Add an allowed app > Browse all apps), or allow the folder in your security program. Then start the "
L"game again.",
g_block.dir, g_block.err == ERROR_ACCESS_DENIED ? L"\"access denied\"" : L"an error", g_block.err);
if (GetEnvironmentVariableA("DS2DEV_DOCS", nullptr, 0)) // test instance: never put a box on the desktop
ds2::log().Write("saves: message box not shown in a test instance: %s", U8(text).c_str());
else
MessageBoxW(nullptr, text, L"Dungeon Siege II - saves blocked", MB_OK | MB_ICONWARNING | MB_TOPMOST);
}
using DialogBoxIndirectParamA_t = INT_PTR(WINAPI*)(HINSTANCE, LPCDLGTEMPLATEA, HWND, DLGPROC, LPARAM);
DialogBoxIndirectParamA_t g_dialogBox;
INT_PTR WINAPI HookDialogBox(HINSTANCE inst, LPCDLGTEMPLATEA tmpl, HWND parent, DLGPROC proc, LPARAM param) {
ShowBlockedMessage(); // the game's error box follows (it is raised on the game's main thread, outside the loader lock)
return g_dialogBox(inst, tmpl, parent, proc, param);
}
DWORD WINAPI ShowBlockedLater(LPVOID) {
Sleep(4000);
ShowBlockedMessage();
return 0;
}
void CheckSaveFolder(bool brokenWorld) {
wchar_t docs[MAX_PATH] = {};
if (!DocumentsFromRegistry(docs)) return;
FixDocuments(docs);
auto make = [](const wchar_t* path) -> DWORD {
return CreateDirectoryW(path, nullptr) || GetLastError() == ERROR_ALREADY_EXISTS ? 0 : GetLastError();
};
wchar_t dir[MAX_PATH];
swprintf_s(dir, L"%ls\\My Games", docs);
DWORD err = make(dir); // keep the first refusal: the next level would only say "path not found"
swprintf_s(dir, L"%ls\\My Games\\%ls", docs, brokenWorld ? L"Dungeon Siege 2 Broken World" : L"Dungeon Siege 2");
if (!err) err = make(dir);
if (!err) {
wchar_t probe[MAX_PATH];
swprintf_s(probe, L"%ls\\ds2_write_test_%lu.tmp", dir, GetCurrentProcessId());
HANDLE h = CreateFileW(probe, GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_DELETE_ON_CLOSE, nullptr);
if (h == INVALID_HANDLE_VALUE) err = GetLastError();
else {
DWORD wrote = 0;
if (!WriteFile(h, "x", 1, &wrote, nullptr)) err = GetLastError();
CloseHandle(h);
}
}
if (!err) {
ds2::log().Detail("saves: %s is writable", U8(dir).c_str());
return;
}
ds2::log().Write("saves: cannot write to %s (error %lu)", U8(dir).c_str(), err);
g_block.err = err;
lstrcpynW(g_block.dir, dir, MAX_PATH);
g_block.blocked = true;
g_dialogBox = static_cast<DialogBoxIndirectParamA_t>(ds2::HookImport(
"USER32.dll", "DialogBoxIndirectParamA", reinterpret_cast<void*>(HookDialogBox), "DialogBoxIndirectParamA (explain a blocked save folder first)"));
CloseHandle(CreateThread(nullptr, 0, ShowBlockedLater, nullptr, 0, nullptr));
}
void InstallSaveFolderFixes(bool asciiDocs, bool check, bool brokenWorld) {
g_asciiDocs = asciiDocs;
if (asciiDocs) {
g_shellW = static_cast<ShGetFolderPathW_t>(ds2::HookImport("SHELL32.dll", "SHGetFolderPathW", reinterpret_cast<void*>(HookShellW),
"SHGetFolderPathW (ASCII documents path)"));
g_getProcAddress = static_cast<GetProcAddress_t>(ds2::HookImport("KERNEL32.dll", "GetProcAddress", reinterpret_cast<void*>(HookGetProcAddress),
"GetProcAddress (SHFOLDER documents path)"));
}
if (check) CheckSaveFolder(brokenWorld);
}
// --- Animation data of dormant objects is never released ---------------------------------------------------------------
// Every Go with a chore_dictionary gets a nema::Blender (Aspect+0x110) when it is created: GoBody's init (Steam 0x93185a)
// walks the dictionary and 0x538b50 builds one chore object per entry, each holding the key arrays of the animations it
// plays (nema::PRSKeys: 40 position + 32 rotation keys per bone, ~40 KB per animation). When the party leaves a region
// most of its Gos are deleted, but scripted/persistent ones (triggers, use points, levers, quest NPCs, guards: a few
// hundred per region) stay in the GoDb, outside every world frustum (Go+0x88 == 0). The engine already purges their
// meshes when they leave the frustum (Go::SetWorldFrustumMembership 0x8732c9 -> Aspect purge 0x5416b0) but nothing ever
// releases the Blender, so every region visited keeps ~7 MB of animation data for the rest of the session (63 regions
// of the campaign: +475 MB; the 2 GB address space runs out after about 70 first visits - measured with teleports).
// Fix: when a Go has been outside every frustum for DormantDelaySeconds, destroy its Blender with the engine's own
// destructor (Aspects without chore dictionary never have one, so an Aspect without Blender is a supported state);
// when the Go gets a frustum again, rebuild the chores with the same loop as 0x93185a and request the chore it was
// playing. Only ambient chores (default, walk, fidget) are released: a Go that was dying, fighting or opening is kept.
constexpr char kChoreLoop[] =
"FF 75 ?? 8B 0D ?? ?? ?? ?? 8B 5E 04 E8 ?? ?? ?? ?? 8B CB 89 45 ?? E8 ?? ?? ?? ?? 8B 4D ?? 50 8B 45 ?? 83 C0 10 50 E8 ?? ?? ?? ?? 8D 4D ?? E8";
// nema::Aspect's chore builder (Steam 0x538b50): creates the Blender with its constructor when missing
constexpr char kEnsureChore[] = "55 8B EC 56 8B F1 8B 86 10 01 00 00 85 C0 75 23 68 AC 00 00 00 E8 ?? ?? ?? ?? 83 C4 04 85 C0 74 0A 56 8B C8 E8"; // Steam; the constructor call is the last byte
constexpr char kEnsureChoreBw[] = "55 8B EC 53 57 8B F9 8B 87 10 01 00 00 85 C0 75 23 68 AC 00 00 00 E8 ?? ?? ?? ?? 83 C4 04 85 C0 74 0A 57 8B C8 E8"; // Broken World: other registers
// GoAspect::GetAspectHandle (returns +0x3c) directly followed by GoAspect::GetAspectPtr (mov eax,[ecx+0x40])
constexpr char kGetAspectPtr[] = "8B 49 3C 8B 45 08 89 08 5D C2 04 00 8B 41 40 C3";
constexpr char kAspectDtorBlender[] = "8B BE 10 01 00 00 3B FB 74 ?? 8B CF E8 ?? ?? ?? ?? 57 E8 ?? ?? ?? ?? 83 C4 04";
constexpr char kSetMembership[] = "55 8B EC 83 EC 24 53 56 8B F1 83 7E 04 00 57 0F 84 ?? ?? ?? ?? 8B 5D 08 8D BE 88 00 00 00 39 1F 0F 84";
constexpr char kGoDelete[] = "55 8B EC 83 EC 38 53 56 57 8B 7D 08 8B 77 68 8B D9 8B 0D ?? ?? ?? ?? 85 C9 89 75 FC 74 06 57 E8";
constexpr char kPlayChore[] = "55 8B EC 56 6A 00 6A 00 6A 00 6A 00 6A 00 8B F1 E8 ?? ?? ?? ?? 8B 8E 10 01 00 00 85 C9 75 07 32 C0 5E 5D C2 14 00";
constexpr BYTE kBodyPrologue[] = {0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x18, 0x53, 0x56, 0x57, 0x8B, 0xF1};
constexpr BYTE kMembershipPrologue[] = {0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x24, 0x53, 0x56, 0x8B, 0xF1};
using EnsureChore_t = void*(__fastcall*)(void* aspect, void* edx, void* entry, int flag);
using GoFlag_t = int(__fastcall*)(void* go);
using IterNext_t = void(__fastcall*)(void* iterator); // ++ on the dictionary's map iterator (a node pointer)
using PlayChore_t = char(__fastcall*)(void* aspect, void* edx, int chore, int stance, int a4, int a5, int a6);
using BlenderDtor_t = void(__fastcall*)(void* blender);
using BlenderCtor_t = void*(__fastcall*)(void* blender, void* edx, void* aspect);
using SetMembership_t = void(__fastcall*)(void* go, void* edx, DWORD mask);
using AspectLookup_t = void*(__fastcall*)(void* mgr, void* edx, DWORD handle); // handle -> Aspect*, used by every consumer
using GoDelete_t = void*(__fastcall*)(void* godb, void* edx, void* go, int a2, int a3); // __thiscall with three stack arguments (retn 0Ch) // returns something the callers use (iterator after the removed Go)
DWORD g_handleMgrGlobal, g_bodyInit;
EnsureChore_t g_ensureChore;
GoFlag_t g_goFlag;
IterNext_t g_iterNext;
PlayChore_t g_playChore;
BlenderDtor_t g_blenderDtor;
BlenderCtor_t g_blenderCtor;
PlayChore_t g_playChoreOrig; // trampoline of the hooked PlayChore
DWORD g_getAspectPtr;
SetMembership_t g_setMembership;
AspectLookup_t g_aspectLookup;
GoDelete_t g_goDelete;
DWORD g_dormantDelayMs = 10000;
bool g_dormantTimer;
struct DormantGo {
DWORD goid, handle, since;
bool freed, hadChore;
DWORD maskA, maskB; // chore slots in use before the release (Blender+0x40 / +0x30), for the log
};
std::unordered_map<DWORD, DormantGo> g_dormant; // by Go*
std::unordered_map<DWORD, DWORD> g_byHandle; // Aspect handle -> Go* of the entries whose Blender is released
BYTE g_freedBits[1 << 17]; // one bit per Aspect handle index: "Blender released", for the lookup hook
CRITICAL_SECTION g_dormantLock;
thread_local bool t_busy; // inside our own rebuild/release: the lookup hook must not recurse
thread_local void* t_deleting; // Go being deleted on this thread
DWORD g_mainThread;
void* g_mgrSeen; // Aspect handle manager of the current world; changes when a world is unloaded
long long g_dormantOnDemand;
bool g_profile; // [Fixes] DormantProfile=1: count the hot-path hook calls and their cycles
long long g_profCalls[3], g_profCycles[3], g_profLastCalls[3]; // lookup, GetAspectPtr, PlayChore
DWORD g_profLastTick;
long long g_rebuildWhy[16];
long long g_dormantFreed, g_dormantRebuilt, g_dormantDropped, g_dormantRebuildFailed, g_dormantNoChore, g_dormantReported;
template <class T>
T Field(const void* base, DWORD off) { return *reinterpret_cast<const T*>(static_cast<const BYTE*>(base) + off); }
template <class T>
T& FieldRef(void* base, DWORD off) { return *reinterpret_cast<T*>(static_cast<BYTE*>(base) + off); }
DWORD Rel32Target(DWORD callAt) { return callAt + 5 + *reinterpret_cast<const LONG*>(callAt + 1); }
// The Aspect behind a handle, or null; checks the handle like the engine does (index in range, generation equal).
void* AspectOf(DWORD handle) {
if (!handle) return nullptr;
void* mgr = *reinterpret_cast<void**>(g_handleMgrGlobal);
if (!mgr) return nullptr;
BYTE* begin = Field<BYTE*>(mgr, 0x24);
BYTE* end = Field<BYTE*>(mgr, 0x28);
DWORD idx = handle & 0xFFFFFF;
if (!begin || idx >= static_cast<DWORD>(end - begin) / 0x18) return nullptr;
if (Field<DWORD>(begin + idx * 0x18, 8) != (handle >> 24)) return nullptr;
return Field<void*>(begin + idx * 0x18, 0);
}
// The body component of a Go (its vtable's init slot is a jump thunk to GoBody's init), or null.
void* BodyComponent(void* go) {
BYTE** comps = Field<BYTE**>(go, 0x9C);
BYTE** compsEnd = Field<BYTE**>(go, 0xA0);
for (BYTE** c = comps; c && c < compsEnd; ++c) {
BYTE* comp = *c;
if (!comp) continue;
BYTE* vt = Field<BYTE*>(comp, 0);
if (!vt) continue;
BYTE* slot = Field<BYTE*>(vt, 0x18);
if (slot && slot[0] == 0xE9 && Rel32Target(reinterpret_cast<DWORD>(slot)) == g_bodyInit && Field<void*>(comp, 4) == go) return comp;
}
return nullptr;
}
enum { kChoreDefault = 3, kChoreWalk = 4, kChoreFidget = 9 };
// Everything below reads engine memory that may belong to a deleted Go: guarded by SEH. Returns 1 = Blender released,
// 0 = not (yet) eligible, -1 = the Go is gone (forget it).
int TryReleaseBlender(void* go, DormantGo& d) {
__try {
if (Field<DWORD>(go, 0x68) != d.goid || Field<DWORD>(go, 0x88) != 0 || (Field<BYTE>(go, 0xC0) & 1)) return -1;
void* ga = Field<void*>(go, 4);
if (!ga || Field<DWORD>(ga, 0x3C) != d.handle) return -1;
void* aspect = AspectOf(d.handle);
if (!aspect || Field<DWORD>(aspect, 0xC) != d.goid) return -1;
void* blender = Field<void*>(aspect, 0x110);
if (!blender) return -1;
int cur = Field<int>(aspect, 0xDC);
if ((cur != kChoreDefault && cur != kChoreWalk && cur != kChoreFidget) || Field<int>(aspect, 0xF8) != cur || Field<BYTE>(aspect, 0x10C)) return 0;
for (int i = 0; i < 15; ++i) {
if (Field<void**>(blender, 0x40)[i]) d.maskA |= 1u << i;
if (Field<void**>(blender, 0x30)[i]) d.maskB |= 1u << i;
}
d.hadChore = cur >= 0 && cur < 15 && (Field<void**>(blender, 0x40)[cur] || Field<void**>(blender, 0x30)[cur]);
if (!d.maskA && !d.maskB) return -1; // nothing to release
// destroy the chores and construct an empty Blender in the same memory: Aspect+0x110 never becomes null, and the
// result is what a Go without those chores has (consumers already cope with missing chores)
g_blenderDtor(blender);
g_blenderCtor(blender, nullptr, aspect);
return 1;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return -1;
}
}
// Rebuild what GoBody's init built (same loop as 0x93185a) and ask for the chore the Go was playing.
bool RebuildBlender(void* go, const DormantGo& d) {
__try {
if (Field<DWORD>(go, 0x68) != d.goid) { ++g_rebuildWhy[1]; return false; }
void* ga = Field<void*>(go, 4);
if (!ga || Field<DWORD>(ga, 0x3C) != d.handle) { ++g_rebuildWhy[2]; return false; }
void* aspect = AspectOf(d.handle);
if (!aspect) { ++g_rebuildWhy[3]; return false; }
void* body = BodyComponent(go);
if (!body || (Field<DWORD>(go, 0x68) & 0xC0000000) == 0x80000000) { ++g_rebuildWhy[5]; return false; }
BYTE* dict = Field<BYTE*>(body, 0x14);
if (!dict) { ++g_rebuildWhy[6]; return false; }
BYTE* head = Field<BYTE*>(dict, 4);
if (!head) { ++g_rebuildWhy[7]; return false; }
int flag = g_goFlag(go);
BYTE* n = Field<BYTE*>(head, 0);
for (int guard = 0; n && n != head && guard < 64; ++guard) { // std::map: advance with the engine's own iterator
g_ensureChore(aspect, nullptr, n + 0x10, flag);
g_iterNext(&n);
}
void* blender = Field<void*>(aspect, 0x110);
int cur = Field<int>(aspect, 0xDC);
bool has = blender && cur >= 0 && cur < 15 && (Field<void**>(blender, 0x40)[cur] || Field<void**>(blender, 0x30)[cur]);
if (d.hadChore && !has) { // had the current chore before, lost it: a real problem
++g_dormantNoChore;
if (g_dormantNoChore <= 12) {
DWORD ma = 0, mb = 0, dictMask = 0;
for (int i = 0; blender && i < 15; ++i) {
if (Field<void**>(blender, 0x40)[i]) ma |= 1u << i;
if (Field<void**>(blender, 0x30)[i]) mb |= 1u << i;
}
BYTE* dn = Field<BYTE*>(head, 0);
for (int guard = 0; dn && dn != head && guard < 64; ++guard) {
dictMask |= 1u << (Field<DWORD>(dn, 0x10) & 31);
g_iterNext(&dn);
}
ds2::log().Write("rebuild lost a chore: current %d, before A=%04lX B=%04lX, dictionary entries %08lX, now A=%04lX B=%04lX", cur, d.maskA, d.maskB, dictMask, ma, mb);
}
}
if (blender) {
g_playChore(aspect, nullptr, Field<int>(aspect, 0xDC), Field<int>(aspect, 0xE0), Field<int>(aspect, 0xE4), Field<int>(aspect, 0xEC), Field<int>(aspect, 0xE8));
FieldRef<BYTE>(aspect, 0x10C) = 1; // apply the request on the next update, whatever PlayChore decided about repeats
}
return true;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
void SetFreedBit(DWORD handle, bool on) {
DWORD idx = handle & 0xFFFFFF;
if (idx >= (1u << 20)) return;
if (on) g_freedBits[idx >> 3] |= static_cast<BYTE>(1u << (idx & 7));
else g_freedBits[idx >> 3] &= static_cast<BYTE>(~(1u << (idx & 7)));
}
// Caller holds g_dormantLock.
void ForgetLocked(std::unordered_map<DWORD, DormantGo>::iterator it) {
if (it->second.freed) {
SetFreedBit(it->second.handle, false);
g_byHandle.erase(it->second.handle);
}
g_dormant.erase(it);
}
// A new world has a new handle manager: everything remembered belongs to the old one.
void CheckWorld() {
void* mgr = *reinterpret_cast<void**>(g_handleMgrGlobal);
if (mgr == g_mgrSeen) return;
EnterCriticalSection(&g_dormantLock);
if (g_mgrSeen) {
ds2::log().Detail("dormant animations: new world (handle manager %p -> %p), forgetting %zu objects", g_mgrSeen, mgr, g_dormant.size());
g_dormant.clear();
g_byHandle.clear();
std::memset(g_freedBits, 0, sizeof(g_freedBits));
}
g_mgrSeen = mgr;
LeaveCriticalSection(&g_dormantLock);
}
// Still the same Go with the same Aspect? (guarded: the Go may be gone)
bool StillSameGo(void* go, const DormantGo& d) {
__try {
if (Field<DWORD>(go, 0x68) != d.goid) return false;
void* ga = Field<void*>(go, 4);
if (!ga || Field<DWORD>(ga, 0x3C) != d.handle) return false;
void* aspect = AspectOf(d.handle);
return aspect && Field<DWORD>(aspect, 0xC) == d.goid;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return false;
}
}
void CALLBACK DormantTimer(HWND, UINT, UINT_PTR, DWORD) {
DWORD now = GetTickCount();
long long before = g_dormantFreed;
CheckWorld();
t_busy = true;
EnterCriticalSection(&g_dormantLock);
for (auto it = g_dormant.begin(); it != g_dormant.end();) {
DormantGo& d = it->second;
if (d.freed) { // entries of deleted Gos are normally removed by the Go delete hook; this catches the rest
if (!StillSameGo(reinterpret_cast<void*>(it->first), d)) {
++g_dormantDropped;
auto dead = it++;
ForgetLocked(dead);
} else {
++it;
}
continue;
}
if (now - d.since < g_dormantDelayMs) {
++it;
continue;
}
int r = TryReleaseBlender(reinterpret_cast<void*>(it->first), d);
if (r > 0) {
d.freed = true;
SetFreedBit(d.handle, true);
g_byHandle[d.handle] = it->first;
++g_dormantFreed;
++it;
} else if (r < 0) {
++g_dormantDropped;
it = g_dormant.erase(it);
} else {
d.since = now; // busy with a non-ambient chore: look again later
++it;
}
}
LeaveCriticalSection(&g_dormantLock);
t_busy = false;
if (g_profile && now - g_profLastTick >= 10000) {
const char* nm[3] = {"lookup 0x429389", "GetAspectPtr", "PlayChore"};
for (int i = 0; i < 3; ++i) {
long long dc = g_profCalls[i] - g_profLastCalls[i];
double secs = g_profLastTick ? (now - g_profLastTick) / 1000.0 : 0;
if (secs > 0 && dc) ds2::log().Detail("profile %s: %lld calls (%.0f/s), %.1f cycles per call in the hook (excluding the original)", nm[i], dc, dc / secs, double(g_profCycles[i]) / double(g_profCalls[i]));
g_profLastCalls[i] = g_profCalls[i];
}
g_profLastTick = now;
}
if (g_dormantFreed != before || g_dormantRebuilt + g_dormantRebuildFailed + g_dormantOnDemand != g_dormantReported) {
g_dormantReported = g_dormantRebuilt + g_dormantRebuildFailed + g_dormantOnDemand;
ds2::log().Detail("dormant animations: %zu Gos currently released, %lld releases in total (%lld rebuilt on wake, %lld on demand, %lld rebuilds failed, %lld rebuilt WITHOUT a chore it had, %lld forgotten, %zu tracked; failure reasons %lld/%lld/%lld/%lld/%lld/%lld/%lld)",
g_byHandle.size(), g_dormantFreed, g_dormantRebuilt, g_dormantOnDemand, g_dormantRebuildFailed, g_dormantNoChore, g_dormantDropped, g_dormant.size(),
g_rebuildWhy[1], g_rebuildWhy[2], g_rebuildWhy[3], g_rebuildWhy[4], g_rebuildWhy[5], g_rebuildWhy[6], g_rebuildWhy[7]);
}
}
void __fastcall HookSetMembership(void* go, void* edx, DWORD mask) {
DWORD before = Field<DWORD>(go, 0x88);
g_setMembership(go, edx, mask);
DWORD after = Field<DWORD>(go, 0x88);
if (!g_dormantTimer && IsGUIThread(FALSE) && SetTimer(nullptr, 0, 2000, DormantTimer)) {
g_dormantTimer = true;
g_mainThread = GetCurrentThreadId();
}
CheckWorld();
if (before != 0 && after == 0 && go != t_deleting) {
// dormant now: remember it if it has a Blender worth releasing
DormantGo d = {};
__try {
void* ga = Field<void*>(go, 4);
if (!ga || (Field<BYTE>(go, 0xC0) & 1)) return;
d.goid = Field<DWORD>(go, 0x68);
d.handle = Field<DWORD>(ga, 0x3C);
void* aspect = AspectOf(d.handle);
if (!aspect || !Field<void*>(aspect, 0x110)) return;
} __except (EXCEPTION_EXECUTE_HANDLER) {
return;
}
d.since = GetTickCount();
EnterCriticalSection(&g_dormantLock);
g_dormant[reinterpret_cast<DWORD>(go)] = d;
LeaveCriticalSection(&g_dormantLock);
} else if (before == 0 && after != 0) {
DormantGo d = {};
bool known = false;
EnterCriticalSection(&g_dormantLock);
auto it = g_dormant.find(reinterpret_cast<DWORD>(go));
if (it != g_dormant.end()) {
d = it->second;
known = true;
ForgetLocked(it);
}
LeaveCriticalSection(&g_dormantLock);
if (known && d.freed) {
t_busy = true;
if (RebuildBlender(go, d)) ++g_dormantRebuilt;
else ++g_dormantRebuildFailed;
t_busy = false;
}
}
}
inline bool IsReleased(DWORD handle);
// Something touches the Aspect of a Go whose chores were released (a script playing a chore, movement or magic code reading
// durations from the Blender, equipping, saving): give the chores back first, then let the Go sleep again after the delay.
// Entered from the Aspect lookup (0x429389), GoAspect::GetAspectPtr (its call sites are redirected) and PlayChore.
void OnDemand(DWORD handle) {
if (!IsReleased(handle) || t_busy) return;
if (GetCurrentThreadId() != g_mainThread) return; // never rebuild from a loader thread
DormantGo d = {};
void* go = nullptr;
EnterCriticalSection(&g_dormantLock);
auto h = g_byHandle.find(handle);
if (h != g_byHandle.end()) {
go = reinterpret_cast<void*>(h->second);
auto it = g_dormant.find(h->second);
if (it != g_dormant.end()) {
d = it->second;
ForgetLocked(it);
} else {
g_byHandle.erase(h);
SetFreedBit(handle, false);
go = nullptr;
}
}
LeaveCriticalSection(&g_dormantLock);
if (!go) return;
t_busy = true;
bool ok = RebuildBlender(go, d);
t_busy = false;
if (ok) {
++g_dormantOnDemand;
d.freed = false;
d.since = GetTickCount(); // dormant again after the delay
EnterCriticalSection(&g_dormantLock);
g_dormant[reinterpret_cast<DWORD>(go)] = d;
LeaveCriticalSection(&g_dormantLock);
} else {
++g_dormantRebuildFailed;
}
}
// Fast path of the three hooks: one bit test (no lock, no call) unless the Aspect belongs to a released Go.
inline bool IsReleased(DWORD handle) {
DWORD idx = handle & 0xFFFFFF;
return idx < (1u << 20) && (g_freedBits[idx >> 3] & (1u << (idx & 7)));
}
void* __fastcall HookAspectLookup(void* mgr, void* edx, DWORD handle) {
void* aspect = g_aspectLookup(mgr, edx, handle);
if (g_profile) {
unsigned long long t = __rdtsc();
if (aspect && IsReleased(handle)) OnDemand(handle);
g_profCycles[0] += __rdtsc() - t;
++g_profCalls[0];
return aspect;
}
if (aspect && IsReleased(handle)) OnDemand(handle);
return aspect;
}
void* __fastcall HookGetAspectPtr(void* goAspect) {
void* aspect = Field<void*>(goAspect, 0x40);
if (g_profile) {
unsigned long long t = __rdtsc();
if (aspect && IsReleased(Field<DWORD>(goAspect, 0x3C))) OnDemand(Field<DWORD>(goAspect, 0x3C));
g_profCycles[1] += __rdtsc() - t;
++g_profCalls[1];
return aspect;
}
if (aspect && IsReleased(Field<DWORD>(goAspect, 0x3C))) OnDemand(Field<DWORD>(goAspect, 0x3C));
return aspect;
}
char __fastcall HookPlayChore(void* aspect, void* edx, int chore, int stance, int a4, int a5, int a6) {
if (g_profile) {
unsigned long long t = __rdtsc();
if (IsReleased(Field<DWORD>(aspect, 4))) OnDemand(Field<DWORD>(aspect, 4));
g_profCycles[2] += __rdtsc() - t;
++g_profCalls[2];
} else if (IsReleased(Field<DWORD>(aspect, 4))) {
OnDemand(Field<DWORD>(aspect, 4)); // the Aspect's own handle
}
return g_playChoreOrig(aspect, edx, chore, stance, a4, a5, a6);
}
// A Go is being deleted: forget it first (its Aspect and Blender go with it), and do not remember the membership change
// the deletion itself makes (0x8732c9 is called with "none" from inside).
void* __fastcall HookGoDelete(void* godb, void* edx, void* go, int a2, int a3) {
EnterCriticalSection(&g_dormantLock);
auto it = g_dormant.find(reinterpret_cast<DWORD>(go));
if (it != g_dormant.end()) ForgetLocked(it);
LeaveCriticalSection(&g_dormantLock);
void* prev = t_deleting;
t_deleting = go;
void* r = g_goDelete(godb, edx, go, a2, a3);
t_deleting = prev;
return r;
}
void InstallDormantAnimations(const ds2::Ini& ini) {
DWORD loop = ds2::FindUnique(kChoreLoop);
DWORD dtor = ds2::FindUnique(kAspectDtorBlender);
DWORD set = ds2::FindUnique(kSetMembership);
if (!loop || !dtor || !set) {
ds2::log().Write("SKIP dormant animations: code not found (loop %08lX, Aspect dtor %08lX, membership %08lX)", loop, dtor, set);
return;
}
DWORD start = 0;
for (DWORD a = loop - sizeof(kBodyPrologue); a + 0x100 > loop && !start; --a)
if (std::memcmp(reinterpret_cast<void*>(a), kBodyPrologue, sizeof(kBodyPrologue)) == 0) start = a;
if (!start) {
ds2::log().Write("SKIP dormant animations: GoBody init not found before %08lX", loop);
return;
}
g_bodyInit = start;
g_handleMgrGlobal = *reinterpret_cast<const DWORD*>(loop + 5);
g_goFlag = reinterpret_cast<GoFlag_t>(Rel32Target(loop + 22));
DWORD ensure = ds2::FindUnique(kEnsureChore), ctorCall = ensure + 36;
if (!ensure) {
ensure = ds2::FindUnique(kEnsureChoreBw);
ctorCall = ensure + 37;
}
if (!ensure || Rel32Target(loop + 38) != ensure) {
ds2::log().Write("SKIP dormant animations: chore builder not found or not the one the body init calls (%08lX)", ensure);
return;
}
g_ensureChore = reinterpret_cast<EnsureChore_t>(ensure);
g_blenderCtor = reinterpret_cast<BlenderCtor_t>(Rel32Target(ctorCall));
g_iterNext = reinterpret_cast<IterNext_t>(Rel32Target(loop + 46));
g_blenderDtor = reinterpret_cast<BlenderDtor_t>(Rel32Target(dtor + 12));
DWORD play = ds2::FindUnique(kPlayChore);
if (!play) {
ds2::log().Write("SKIP dormant animations: PlayChore not found");
return;
}
g_playChore = reinterpret_cast<PlayChore_t>(play); // replaced by the trampoline below once hooked
g_dormantDelayMs = static_cast<DWORD>(ini.Int("Fixes", "DormantDelaySeconds", 10)) * 1000;
g_profile = ini.Bool("Fixes", "DormantProfile", false);
InitializeCriticalSection(&g_dormantLock);
g_setMembership = static_cast<SetMembership_t>(ds2::Detour(set, kMembershipPrologue, sizeof(kMembershipPrologue),
reinterpret_cast<void*>(HookSetMembership), "dormant animations (release the Blender of Gos outside every frustum)"));
if (g_setMembership) {
static const BYTE lookupPrologue[] = {0x55, 0x8B, 0xEC, 0x51, 0x56, 0x8B, 0xF1}; // push ebp; mov ebp,esp; push ecx; push esi; mov esi,ecx
g_aspectLookup = static_cast<AspectLookup_t>(ds2::Detour(Rel32Target(loop + 12), lookupPrologue, sizeof(lookupPrologue),
reinterpret_cast<void*>(HookAspectLookup), "dormant animations (rebuild a released Blender on demand)"));
DWORD del = ds2::FindUnique(kGoDelete);
static const BYTE deletePrologue[] = {0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x38, 0x53, 0x56, 0x57, 0x8B, 0x7D, 0x08};
if (del)
g_goDelete = static_cast<GoDelete_t>(ds2::Detour(del, deletePrologue, sizeof(deletePrologue),
reinterpret_cast<void*>(HookGoDelete), "dormant animations (forget deleted Gos)"));
static const BYTE playPrologue[] = {0x55, 0x8B, 0xEC, 0x56, 0x6A, 0x00}; // push ebp; mov ebp,esp; push esi; push 0
g_playChoreOrig = static_cast<PlayChore_t>(ds2::Detour(play, playPrologue, sizeof(playPrologue),
reinterpret_cast<void*>(HookPlayChore), "dormant animations (PlayChore rebuilds on demand)"));
if (g_playChoreOrig) g_playChore = g_playChoreOrig;
// GoAspect::GetAspectPtr is two instructions: redirect its call sites instead of detouring it
DWORD gp = ds2::FindUnique(kGetAspectPtr);
int patched = 0;
if (gp) {
g_getAspectPtr = gp + 12;
auto nt = ds2::ExeHeaders();
auto sec = IMAGE_FIRST_SECTION(nt);
auto text = reinterpret_cast<BYTE*>(GetModuleHandleA(nullptr)) + sec->VirtualAddress;
for (size_t i = 0; i + 5 <= sec->Misc.VirtualSize; ++i) {
if (text[i] != 0xE8) continue;
DWORD site = reinterpret_cast<DWORD>(text + i);
if (Rel32Target(site) != g_getAspectPtr) continue;
DWORD rel = reinterpret_cast<DWORD>(&HookGetAspectPtr) - (site + 5);
DWORD old;
if (VirtualProtect(reinterpret_cast<void*>(site + 1), 4, PAGE_EXECUTE_READWRITE, &old)) {
std::memcpy(reinterpret_cast<void*>(site + 1), &rel, 4);
VirtualProtect(reinterpret_cast<void*>(site + 1), 4, old, &old);
++patched;
}
}
FlushInstructionCache(GetCurrentProcess(), text, sec->Misc.VirtualSize);
}
ds2::log().Detail("dormant animations: GetAspectPtr %08lX, %d call sites redirected", g_getAspectPtr, patched);
if (!g_aspectLookup || !g_goDelete || !g_playChoreOrig || !patched)
ds2::log().Write("WARNING dormant animations: lookup hook %p, delete hook %p, PlayChore hook %p, %d GetAspectPtr sites - running without some safety hooks", g_aspectLookup, g_goDelete, g_playChoreOrig, patched);
}
if (g_setMembership)
ds2::log().Detail("dormant animations: GoBody init %08lX, Aspect handle manager at %08lX, delay %lu ms", g_bodyInit, g_handleMgrGlobal, g_dormantDelayMs);
}
// --- "Alive but dead": a hero brought back by a level-up still gets the Defeat screen ----------------------------------
// Single player only. Rules::ChangeLife treats every positive life change that ends at 1 or more as a revive (no check
// of who asked), and rules.skrit OnLevelUp gives the levelling member full life, so XP that arrives after the killing
// blow (the kill of the enemy that dies in the same moment, a projectile in the air, damage over time) stands a dead or
// unconscious hero up again. But the defeat was already decided: Victory::Update (Steam 0x8df5d3, Broken World
// 0x8f713e; called every frame by the world update) finds no conscious member, kills the unconscious ones
// (SSetLifeState 3), sets Victory+6 (m_bDefeated) and the countdown Victory+8 = 4.0, and when it runs out requests
// ws_sp_defeat (0x1e): "Defeat / Respawn", the simulation paused, and Respawn teleports the party back to the starting
// group. Nothing ever looks at the party again once Victory+6 is set (UIPartyManager::RSEndRespawnParty clears it), so the
// player sees the Defeat screen with a living, levelled hero.
//
// Fix (the owner's "lenient" decision: a hero who dies and is brought back by a level-up simply plays on, it is no
// defeat): before each Victory::Update, when a defeat is pending (+6) and the screen party has a conscious member again,
// - during the countdown: clear +6, so the pending defeat is gone and the normal check runs again (it finds the
// conscious member; if that one dies too the countdown starts afresh), and undo what the defeat callback
// (Victory+0x24, the UI's handler: posts message 0x2a, closes the dialogue windows and sets the flag UI+0x6c that keeps
// the in-game menu shut) did to the UI;
// - when ws_sp_defeat is already current (the dialog is up): do what the Respawn button does at its end without the
// respawn - release the pause (the wrapper around MohawkGame::RSUserPause that also updates the HUD's pause label),
// ask for ws_sp_ingame (0x1d; the world-state handler hides defeat_menu for 0x1e -> 0x1d), clear +6 and the UI flag.
// No teleport, no corpse, no gold left at a gravestone (Respawn loses 25% of the gold), no "deaths" count.
// A genuine defeat (nobody conscious) never matches. The level-up heal itself is untouched. Multiplayer never sets +6 (the
// MP branch of Victory::Update has no defeat), and the hook also stays out of it by asking IsMultiPlayer.
// [Fixes] LevelUpRevive=0 turns this off.
constexpr char kVictoryUpdate[] = "55 8B EC 83 EC 4C 57 8B F9 80 7F 04 00 0F 85 ?? ?? ?? ?? E8 ?? ?? ?? ?? 84 C0 0F 85 ?? ?? ?? ?? 8A 4F 06 84 C9";
constexpr BYTE kVictoryUpdatePrologue[] = {0x55, 0x8B, 0xEC, 0x83, 0xEC, 0x4C, 0x57, 0x8B, 0xF9};
constexpr int kStateIngame = 0x1d, kStateDefeat = 0x1e; // eWorldState ws_sp_ingame, ws_sp_defeat
constexpr int kLifeConscious = 1;
struct GopColl { void** first; void** last; };
using VictoryUpdate_t = void(__fastcall*)(void* victory);
using Singleton_t = void*(__cdecl*)();
using ThisBool_t = bool(__thiscall*)(void*);
using ThisPtr_t = void*(__thiscall*)(void*);
using GetChildren_t = const GopColl&(__thiscall*)(void*);
using GetLifeState_t = int(__thiscall*)(void*);
using WsRequest_t = int(__thiscall*)(void*, int, double);
using Pause_t = void(__thiscall*)(void*, bool);
VictoryUpdate_t g_victoryUpdate;
bool(__cdecl* g_isMultiPlayer)();
Singleton_t g_serverSingleton, g_worldStateSingleton, g_gameSingleton;
ThisPtr_t g_getScreenParty;
GetChildren_t g_getChildren;
ThisBool_t g_isHumanMember, g_isPackOnly;
GetLifeState_t g_getLifeState, g_getCurrentState, g_getPendingState;
WsRequest_t g_wsRequest;
Pause_t g_setPause; // the engine's wrapper: RSUserPause for the local player, then the HUD's pause label
long g_levelUpCancels, g_levelUpCloses;
// Victory+0x24 holds the UI's defeat callback {method, ?, object, thunk}: the method (Steam 0x494c8a) ends with "mov byte
// [reg+0x6c], 1" on the UI object, a flag that keeps the in-game menu shut (RSEndRespawnParty clears it again). Recognise the
// store in the method's first bytes and clear the flag on the callback's object; false when the callback is not what we know.
bool ClearDefeatUiFlag(BYTE* victory) {
__try {
const BYTE* method = *reinterpret_cast<BYTE* const*>(victory + 0x24);
BYTE* ui = *reinterpret_cast<BYTE* const*>(victory + 0x2C);
if (!method || !ui) return false;
for (int i = 0; i < 0x40; ++i)
if (method[i] == 0xC6 && (method[i + 1] & 0xF8) == 0x40 && method[i + 2] == 0x6C && method[i + 3] == 1) {
ui[0x6C] = 0;
return true;
}
} __except (EXCEPTION_EXECUTE_HANDLER) {
}
return false;
}
// A conscious, non-pack member of the screen party: what Victory::Update itself looks for before it declares a defeat.
bool PartyHasConsciousMember() {
void* server = g_serverSingleton();
void* party = server ? g_getScreenParty(server) : nullptr;
if (!party) return false;
const GopColl& kids = g_getChildren(party);
if (!kids.first || kids.last < kids.first || kids.last - kids.first > 64) return false;
for (void** p = kids.first; p != kids.last; ++p)
if (*p && g_isHumanMember(*p) && !g_isPackOnly(*p) && g_getLifeState(*p) == kLifeConscious) return true;
return false;
}
void __fastcall HookVictoryUpdate(void* victory) {
BYTE* v = static_cast<BYTE*>(victory);
// +4 game ended, +5 defeat enabled, +6 defeated (countdown running or screen requested)
if (!v[4] && v[5] && v[6] && !g_isMultiPlayer() && PartyHasConsciousMember()) {
void* ws = g_worldStateSingleton();
int cur = ws ? g_getCurrentState(ws) : 0, pending = ws ? g_getPendingState(ws) : 0;
if (ws && cur == kStateDefeat) {
void* game = g_gameSingleton();
if (game) g_setPause(game, false);
g_wsRequest(ws, kStateIngame, 0.0);
v[6] = 0;
bool ui = ClearDefeatUiFlag(v);
++g_levelUpCloses;
ds2::log().Write("LevelUpRevive: a party member is conscious again while the Defeat screen was up: pause released, back to the game (no respawn); UI flag %s", ui ? "cleared" : "NOT found");
} else if (ws && pending != kStateDefeat) {
v[6] = 0; // countdown only: nothing was shown yet
bool ui = ClearDefeatUiFlag(v);
++g_levelUpCancels;
ds2::log().Write("LevelUpRevive: a party member is conscious again during the defeat countdown (%.2f s left): defeat cancelled; UI flag %s",
*reinterpret_cast<float*>(v + 8), ui ? "cleared" : "NOT found");
}
// else: the request for the screen is in flight (the world state changes on the next update); the next call closes it
}
g_victoryUpdate(victory);
}
template <class T>
T ExeExport(const char* name) { return reinterpret_cast<T>(GetProcAddress(GetModuleHandleA(nullptr), name)); }
void InstallLevelUpRevive() {
DWORD update = ds2::FindUnique(kVictoryUpdate);
if (!update) {
ds2::log().Write("SKIP LevelUpRevive: Victory::Update not found (different exe build?)");
return;
}
g_isMultiPlayer = ExeExport<bool(__cdecl*)()>("?IsMultiPlayer@@YA_NXZ");
g_serverSingleton = ExeExport<Singleton_t>("?FUBI_GetClassSingleton@Server@@CAPAV1@XZ");
g_worldStateSingleton = ExeExport<Singleton_t>("?FUBI_GetClassSingleton@WorldState@@CAPAV1@XZ");
g_gameSingleton = ExeExport<Singleton_t>("?FUBI_GetClassSingleton@MohawkGame@@CAPAV1@XZ");
g_getScreenParty = ExeExport<ThisPtr_t>("?GetScreenParty@Server@@QAEPAVGo@@XZ");
g_getChildren = ExeExport<GetChildren_t>("?GetChildren@Go@@QBEABUGopColl@@XZ");
g_isHumanMember = ExeExport<ThisBool_t>("?IsAnyHumanPartyMember@Go@@QBE_NXZ");
g_isPackOnly = ExeExport<ThisBool_t>("?IsPackOnly@Go@@QBE_NXZ");
g_getLifeState = ExeExport<GetLifeState_t>("?GetLifeState@Go@@QBE?AW4eLifeState@@XZ");
g_getCurrentState = ExeExport<GetLifeState_t>("?GetCurrentState@WorldState@@QBE?AW4eWorldState@@XZ");
g_getPendingState = ExeExport<GetLifeState_t>("?GetPendingState@WorldState@@QBE?AW4eWorldState@@XZ");
g_wsRequest = ExeExport<WsRequest_t>("?Request@WorldState@@QAEHW4eWorldState@@N@Z");
DWORD rsUserPause = reinterpret_cast<DWORD>(GetProcAddress(GetModuleHandleA(nullptr), "?RSUserPause@MohawkGame@@QAEX_NPBUPlayerId_@@@Z"));
if (!g_isMultiPlayer || !g_serverSingleton || !g_worldStateSingleton || !g_gameSingleton || !g_getScreenParty || !g_getChildren ||
!g_isHumanMember || !g_isPackOnly || !g_getLifeState || !g_getCurrentState || !g_getPendingState || !g_wsRequest || !rsUserPause) {
ds2::log().Write("SKIP LevelUpRevive: an engine export is missing");
return;
}
// The pause wrapper (Steam 0x42b26e, Broken World 0x42d966) is the only code that calls RSUserPause: 0x1d bytes in.
auto nt = ds2::ExeHeaders();
auto sec = IMAGE_FIRST_SECTION(nt);
auto text = reinterpret_cast<BYTE*>(GetModuleHandleA(nullptr)) + sec->VirtualAddress;
DWORD site = 0;
int calls = 0;
for (size_t i = 0; i + 5 <= sec->Misc.VirtualSize; ++i) {
if (text[i] != 0xE8) continue;
if (Rel32Target(reinterpret_cast<DWORD>(text + i)) == rsUserPause) { site = reinterpret_cast<DWORD>(text + i); ++calls; }
}
static const BYTE wrapperStart[] = {0x55, 0x8B, 0xEC, 0x56, 0x8B, 0xF1}; // push ebp; mov ebp,esp; push esi; mov esi,ecx
if (calls != 1 || std::memcmp(reinterpret_cast<void*>(site - 0x1D), wrapperStart, sizeof(wrapperStart)) != 0) {
ds2::log().Write("SKIP LevelUpRevive: pause wrapper not found (%d call sites of RSUserPause)", calls);
return;
}
g_setPause = reinterpret_cast<Pause_t>(site - 0x1D);
g_victoryUpdate = static_cast<VictoryUpdate_t>(ds2::Detour(update, kVictoryUpdatePrologue, sizeof(kVictoryUpdatePrologue),
reinterpret_cast<void*>(HookVictoryUpdate), "LevelUpRevive (Victory::Update)"));
if (g_victoryUpdate) ds2::log().Detail("LevelUpRevive: Victory::Update %08lX, pause wrapper %08lX", update, site - 0x1D);
}
} // namespace
void InstallTextCodepage(const ds2::Ini& ini); // text.cpp
BOOL WINAPI DllMain(HMODULE self, DWORD reason, LPVOID) {
if (reason != DLL_PROCESS_ATTACH) return TRUE;
DisableThreadLibraryCalls(self);
ds2::Plugin plugin;
if (!ds2::StartPlugin(self, "DS2Fixes", plugin)) return TRUE;
ds2::SummaryAtExit summary;
ds2::Ini ini(plugin.ini);
InstallTextCodepage(ini); // first: the saves code below asks which code page the game really uses
if (ini.Bool("Fixes", "NoGlobalKeyboardHook", true))
g_SetWindowsHookExA = static_cast<SetWindowsHookExA_t>(ds2::HookImport(
"USER32.dll", "SetWindowsHookExA", reinterpret_cast<void*>(HookSetWindowsHookExA), "SetWindowsHookExA (no global keyboard hook)"));
if (ini.Bool("Fixes", "QuietCursor", true)) {
g_RegisterClassA = static_cast<RegisterClassA_t>(ds2::HookImport(
"USER32.dll", "RegisterClassA", reinterpret_cast<void*>(HookRegisterClassA), "RegisterClassA (cursor update filter)"));
g_RegisterClassExA = static_cast<RegisterClassExA_t>(ds2::HookImport(
"USER32.dll", "RegisterClassExA", reinterpret_cast<void*>(HookRegisterClassExA), "RegisterClassExA (cursor update filter)"));
}
if (ini.Bool("Fixes", "SteadyCameraDrag", true)) InstallSteadyMouseLook();
if (ini.Bool("Fixes", "SafeVertexUpload", true)) InstallSafeVertexUpload();
if (ini.Bool("Fixes", "SkipMinSpecWarning", true))
g_MessageBoxW = static_cast<MessageBoxW_t>(ds2::HookImport(
"USER32.dll", "MessageBoxW", reinterpret_cast<void*>(HookMessageBoxW), "MessageBoxW (no below-minimum-specification box)"));
// Steam v2.3: on by default (proven in single player and multiplayer). Broken World has the same code (found by the same
// signatures) but its wake/rebuild was not demonstrated yet: off by default there, [Fixes] DormantAnimations=1 turns it on.
if (ini.BoolOrAuto("Fixes", "DormantAnimations", plugin.exe != ds2::Exe::BrokenWorld)) InstallDormantAnimations(ini);
if (ini.Bool("Fixes", "LevelUpRevive", true)) InstallLevelUpRevive();
if (ini.Bool("Fixes", "SkipSignUpForm", true)) SkipSignUpForm(plugin.exe == ds2::Exe::BrokenWorld);
InstallSaveFolderFixes(ini.Bool("Fixes", "AsciiDocumentsPath", true), ini.Bool("Fixes", "SaveFolderCheck", true),
plugin.exe == ds2::Exe::BrokenWorld);
return TRUE;
}