1190 lines
62 KiB
C++
1190 lines
62 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().Write("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().Write("input: cursor updates of window class %s filtered (stutter while moving the mouse)", wc->lpszClassName);
|
|
return g_RegisterClassA(©);
|
|
}
|
|
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().Write("input: cursor updates of window class %s filtered (stutter while moving the mouse)", wc->lpszClassName);
|
|
return g_RegisterClassExA(©);
|
|
}
|
|
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().Write("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().Write("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().Write("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().Write("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().Write("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().Write("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().Write("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().Write("dormant animations: GoBody init %08lX, Aspect handle manager at %08lX, delay %lu ms", g_bodyInit, g_handleMgrGlobal, g_dormantDelayMs);
|
|
}
|
|
|
|
} // 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::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", "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;
|
|
}
|