536 lines
29 KiB
C++
536 lines
29 KiB
C++
// DS2BrokenWorld.asi - Dungeon Siege II: Broken World next to the Steam game, without a registry script, and
|
|
// Steam-made heroes imported into it. Delete the .asi to turn it off.
|
|
//
|
|
// The expansion is its own exe (v2.30.0.4277). Layout: <Dungeon Siege 2>/Broken World/DungeonSiege2.exe
|
|
// with its Resources/x*.ds2res and Maps/x*.ds2map; the base game's resources come from the parent folder,
|
|
// which we hand to it as the (virtual) DS2 install directory - no registry script needed.
|
|
// The Steam exe starts it automatically when that folder exists ([BrokenWorld] Launch=auto; hold Shift
|
|
// while starting, or set Launch=off, for the original game).
|
|
#include "../common/exclusive_cursor.h"
|
|
#include "../common/plugin.h"
|
|
#include "../common/vreg.h"
|
|
#include <intrin.h>
|
|
#include "cpuid_sites.h"
|
|
|
|
namespace {
|
|
|
|
// --- Install paths --------------------------------------------------------------------------------------
|
|
// Broken World finds the base game through AppPath / InstallationDirectory of the DS2 install key ("In order to
|
|
// play Dungeon Siege 2: Broken World, you must first install the full version of Dungeon Siege 2"; config var
|
|
// ds2_install_path -> %ds2_install_path%/Resources and /Maps) and itself through AppPath of its own key. Steam
|
|
// installs have neither; served when the real values are missing (mods/common/vreg.h).
|
|
char g_ds2Dir[MAX_PATH]; // the base game
|
|
char g_bwDir[MAX_PATH]; // Broken World's own folder
|
|
|
|
LSTATUS ServeInstallPaths(HKEY key, const char* name, LPDWORD type, LPBYTE data, LPDWORD size) {
|
|
if (_stricmp(name, "AppPath") != 0 && _stricmp(name, "InstallationDirectory") != 0) return ERROR_FILE_NOT_FOUND;
|
|
const char* dir = key == ds2::vreg::kBwKey ? g_bwDir : g_ds2Dir;
|
|
return dir[0] ? ds2::vreg::ServeString(dir, type, data, size) : ERROR_FILE_NOT_FOUND;
|
|
}
|
|
|
|
// Broken World reads a party's party_info.ds2 only if its format version is 0x3d (DS2 retail), 0x3f (its own)
|
|
// or the value of a crack-relocated constant at 0x17ffac8 that the crack left at 0 (only reference: the check
|
|
// "cmp eax,[0x17ffac8]" at 0x1811c62). The Steam v2.3 base game saves format 0x3e (party writer sub_41724b),
|
|
// so every Steam-made party failed to convert or import ("Failed to transfer vaulted character file ...
|
|
// corrupted"). Fill the constant with 0x3e.
|
|
constexpr DWORD kBwPartyVersionCheck = 0x1811c57;
|
|
constexpr BYTE kBwPartyVersionCheckBytes[] = {0x83, 0xF8, 0x3D, 0x89, 0x87, 0x98, 0x00, 0x00, 0x00, 0x74, 0x2D,
|
|
0x3B, 0x05, 0xC8, 0xFA, 0x7F, 0x01, 0x74, 0x25, 0x83, 0xF8, 0x3F};
|
|
constexpr DWORD kBwPartyVersionSlot = 0x17ffac8;
|
|
constexpr DWORD kSteamPartyVersion = 0x3e;
|
|
|
|
void AcceptSteamParties(const ds2::Ini& ini) {
|
|
if (!ini.Bool("BrokenWorld", "AcceptSteamParties", true)) return;
|
|
if (std::memcmp(reinterpret_cast<const void*>(kBwPartyVersionCheck), kBwPartyVersionCheckBytes,
|
|
sizeof(kBwPartyVersionCheckBytes)) != 0) {
|
|
ds2::log().Write("SKIP Broken World: party format check not found (different exe?)");
|
|
return;
|
|
}
|
|
const DWORD zero = 0;
|
|
ds2::PatchBytes(kBwPartyVersionSlot, &zero, &kSteamPartyVersion, sizeof(DWORD),
|
|
"Broken World: accept party format 0x3e (Steam DS2 characters)");
|
|
}
|
|
|
|
// After the format check, Broken World compares the party's content_crc (CRC of the game data it was saved
|
|
// with) against what it knows (check at 0x41495a = jmp [0x17feef8], __thiscall(crc) -> bool, in the protected
|
|
// section). Steam v2.3 parties carry the Steam data's CRC 0xD4EE6791, which a 2006 exe cannot know. Wrap the
|
|
// check: also accept the known editions (table below) and the CRCs in [BrokenWorld] AcceptContentCrcs (hex, comma
|
|
// separated).
|
|
// The slot is lazily bound: at start-up it holds a crack resolver stub (0x1871150 in the file) that, on the first
|
|
// call, writes the real function's address (0x181d35a) into the slot - over our wrapper - so only the first party
|
|
// read was ever wrapped. After every call the wrapper adopts whatever the slot now holds and puts itself back.
|
|
constexpr DWORD kBwContentCheckStub = 0x41495a;
|
|
constexpr BYTE kBwContentCheckStubBytes[] = {0xFF, 0x25, 0xF8, 0xEE, 0x7F, 0x01};
|
|
constexpr DWORD kBwContentCheckSlot = 0x17feef8;
|
|
using ContentCheck_t = bool(__fastcall*)(void* self, void* edx, DWORD crc);
|
|
ContentCheck_t g_bwContentCheck;
|
|
DWORD g_extraCrcs[8];
|
|
int g_extraCrcCount;
|
|
|
|
bool __fastcall BwContentCheck(void* self, void* edx, DWORD crc);
|
|
|
|
// --- Known editions ----------------------------------------------------------------------------------------
|
|
// A party's content_crc is the content id of the game it was saved with: the exe's PE header checksum plus three
|
|
// digests of the gas/skrit data (docs/multiplayer/mod-compatibility.md). The first four ids were measured by starting
|
|
// that setup in a private copy and reading the checksum and ContentSyncSpec (tools/contentid.py; docs/brokenworld.md,
|
|
// "Content ids of known editions"); the last four are constants in the games' own check:
|
|
// - D4EE6791 Steam Dungeon Siege 2 v2.3 (exe checksum 0x007DCE33)
|
|
// - D4EE4A9F the same data with the "fixed files v3" GOG exe (checksum 0x007DB141; only the checksum differs)
|
|
// - 790BFE9F Broken World (every Broken World exe we have has checksum 0xCAA45D2B; disc data = 2018 download data)
|
|
// - 75112728 Broken World with the 2018 repack's Resources\Widescreen Fix.ds2res (changes the world/contentdb digests)
|
|
// - CA03370A DE60C4EB 1A300219 AB123ABC: built into the exes (Steam: the first three; Broken World: all four). Not
|
|
// reproducible here (that needs retail data, which we do not have); AB123ABC is what the disc's premade heroes carry.
|
|
// All of them are accepted in single player, multiplayer and the vault (default [BrokenWorld] AcceptKnownEditions=1) and
|
|
// named in the log once. Any other id: single player accepts it (AcceptAnySaveSinglePlayer), multiplayer does not.
|
|
struct Edition {
|
|
DWORD id;
|
|
const char* name; // completes "party made with ..."
|
|
};
|
|
constexpr Edition kEditions[] = {
|
|
{0xD4EE6791, "Steam Dungeon Siege 2 v2.3"},
|
|
{0xD4EE4A9F, "the GOG exe"},
|
|
{0x790BFE9F, "Broken World"},
|
|
{0x75112728, "the 2018 repack's Widescreen Fix"},
|
|
{0xCA03370A, "an older Dungeon Siege 2 version (built-in id)"},
|
|
{0xDE60C4EB, "an older Dungeon Siege 2 version (built-in id)"},
|
|
{0x1A300219, "an older Dungeon Siege 2 version (built-in id)"},
|
|
{0xAB123ABC, "a Broken World premade hero (built-in id)"},
|
|
};
|
|
bool g_acceptKnownEditions = true;
|
|
DWORD g_loggedCrcs[32];
|
|
int g_loggedCrcCount;
|
|
|
|
const Edition* FindEdition(DWORD crc) {
|
|
for (const Edition& e : kEditions)
|
|
if (e.id == crc) return &e;
|
|
return nullptr;
|
|
}
|
|
|
|
// True the first time a CRC is logged (one line per CRC and run; the game checks every party file more than once).
|
|
bool FirstTime(DWORD crc) {
|
|
for (int i = 0; i < g_loggedCrcCount; ++i)
|
|
if (g_loggedCrcs[i] == crc) return false;
|
|
if (g_loggedCrcCount < 32) g_loggedCrcs[g_loggedCrcCount++] = crc;
|
|
return true;
|
|
}
|
|
|
|
// The game's own check accepted this CRC: name it in the log if it is a known edition.
|
|
void NoteKnown(DWORD crc) {
|
|
if (const Edition* e = FindEdition(crc))
|
|
if (FirstTime(crc)) ds2::log().Write("party content CRC %08lX: party made with %s", crc, e->name);
|
|
}
|
|
|
|
// The built-in table (when switched on) accepts this CRC.
|
|
bool AcceptEdition(DWORD crc) {
|
|
if (!g_acceptKnownEditions) return false;
|
|
const Edition* e = FindEdition(crc);
|
|
if (!e) return false;
|
|
if (FirstTime(crc)) ds2::log().Write("party content CRC %08lX accepted: party made with %s", crc, e->name);
|
|
return true;
|
|
}
|
|
|
|
// [BrokenWorld] AcceptContentCrcs: the player's own additions.
|
|
bool AcceptExtraCrc(DWORD crc) {
|
|
for (int i = 0; i < g_extraCrcCount; ++i)
|
|
if (crc == g_extraCrcs[i]) {
|
|
if (FirstTime(crc)) ds2::log().Write("party content CRC %08lX accepted (AcceptContentCrcs)", crc);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void LoadSettings(const ds2::Ini& ini) {
|
|
char list[256];
|
|
ini.String("BrokenWorld", "AcceptContentCrcs", "", list, sizeof(list));
|
|
char* ctx = nullptr;
|
|
for (char* tok = strtok_s(list, ", ", &ctx); tok && g_extraCrcCount < 8; tok = strtok_s(nullptr, ", ", &ctx))
|
|
g_extraCrcs[g_extraCrcCount++] = std::strtoul(tok, nullptr, 16);
|
|
g_acceptKnownEditions = ini.Bool("BrokenWorld", "AcceptKnownEditions", true);
|
|
}
|
|
|
|
// --- Saves made with other game data: accepted in single player --------------------------------------------
|
|
// The content check is a list-visibility gate: a party made on other game data (retail DS2 data instead of the
|
|
// Steam v2.3 data, a data mod, another patch level) has another content CRC and silently disappears from the
|
|
// hero list. [BrokenWorld] AcceptAnySaveSinglePlayer (default on) accepts any CRC while playing single player
|
|
// (each unknown CRC is logged once); multiplayer keeps the known list, so the content check there is unchanged.
|
|
// "Single player" is read from the engine at the moment of the check, in this order:
|
|
// - the Server object's first byte (IsMultiPlayer(): Server* at the pointer below) is set -> multiplayer;
|
|
// - the WorldState (pointer below; +0x1c current, +0x24 pending eWorldState) is one of the ws_mp_* states
|
|
// (33 mp_provider_select .. 44 mp_session_lost: provider/internet/LAN/match-maker pages, staging, MP hero
|
|
// and map select, MP in-game) -> multiplayer. Pending counts too: the hero's party.ds2 is read while the
|
|
// world loads the map, when current is a loading state and pending the target (tools/ds2state.py names).
|
|
// - anything else (main menu, ws_sp_*, loading toward them) -> single player.
|
|
// The hero list is read before any Server exists, so the Server byte alone cannot tell the multiplayer hero page
|
|
// from the single player one; the WorldState does (SP hero page = ws_sp_character_select 23, MP = 39).
|
|
struct EngineState {
|
|
DWORD worldStatePtr; // address of the WorldState* global
|
|
DWORD serverPtr; // address of the Server* global
|
|
};
|
|
constexpr EngineState kBwEngine = {0xd015d8, 0xd01554};
|
|
constexpr EngineState kSteamEngine = {0xbcb21c, 0xbcb290};
|
|
constexpr int kFirstMpState = 33, kLastMpState = 44;
|
|
bool g_acceptAnySave;
|
|
EngineState g_engine;
|
|
|
|
bool InSinglePlayer() {
|
|
bool single = true;
|
|
__try {
|
|
const BYTE* server = *reinterpret_cast<const BYTE* const*>(g_engine.serverPtr);
|
|
if (server && *server) single = false;
|
|
const BYTE* ws = *reinterpret_cast<const BYTE* const*>(g_engine.worldStatePtr);
|
|
if (ws) {
|
|
int cur = *reinterpret_cast<const int*>(ws + 0x1c), pend = *reinterpret_cast<const int*>(ws + 0x24);
|
|
if ((cur >= kFirstMpState && cur <= kLastMpState) || (pend >= kFirstMpState && pend <= kLastMpState))
|
|
single = false;
|
|
}
|
|
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
|
single = false; // cannot tell: keep the strict check
|
|
}
|
|
return single;
|
|
}
|
|
|
|
// True when an otherwise unknown content CRC is let through (single player and the setting on).
|
|
bool AcceptUnknownCrc(DWORD crc) {
|
|
if (!g_acceptAnySave || !InSinglePlayer()) return false;
|
|
if (FirstTime(crc)) ds2::log().Write("party content CRC %08lX: unknown game data, accepted in single player", crc);
|
|
return true;
|
|
}
|
|
|
|
void RehookContentCheck() {
|
|
auto slot = reinterpret_cast<DWORD*>(kBwContentCheckSlot);
|
|
DWORD mine = reinterpret_cast<DWORD>(BwContentCheck), now = *slot;
|
|
if (now == mine) return;
|
|
g_bwContentCheck = reinterpret_cast<ContentCheck_t>(now);
|
|
DWORD old;
|
|
if (!VirtualProtect(slot, 4, PAGE_EXECUTE_READWRITE, &old)) return;
|
|
InterlockedCompareExchange(reinterpret_cast<volatile LONG*>(slot), static_cast<LONG>(mine), static_cast<LONG>(now));
|
|
VirtualProtect(slot, 4, old, &old);
|
|
}
|
|
|
|
bool __fastcall BwContentCheck(void* self, void* edx, DWORD crc) {
|
|
bool known = g_bwContentCheck(self, edx, crc);
|
|
RehookContentCheck();
|
|
if (known) {
|
|
NoteKnown(crc);
|
|
return true;
|
|
}
|
|
if (AcceptEdition(crc) || AcceptExtraCrc(crc) || AcceptUnknownCrc(crc)) return true;
|
|
ds2::log().Write("Broken World: party content CRC %08lX not accepted", crc);
|
|
return false;
|
|
}
|
|
|
|
void AcceptSteamContentCrc(const ds2::Ini& ini) {
|
|
LoadSettings(ini);
|
|
g_acceptAnySave = ini.Bool("BrokenWorld", "AcceptAnySaveSinglePlayer", true);
|
|
g_engine = kBwEngine;
|
|
if (std::memcmp(reinterpret_cast<const void*>(kBwContentCheckStub), kBwContentCheckStubBytes,
|
|
sizeof(kBwContentCheckStubBytes)) != 0) {
|
|
ds2::log().Write("SKIP Broken World: content CRC check stub not found (different exe?)");
|
|
return;
|
|
}
|
|
DWORD orig = *reinterpret_cast<const DWORD*>(kBwContentCheckSlot);
|
|
g_bwContentCheck = reinterpret_cast<ContentCheck_t>(orig);
|
|
ds2::PatchPointer(kBwContentCheckSlot, orig, reinterpret_cast<void*>(BwContentCheck),
|
|
"Broken World: accept the known editions' content ids for imported parties");
|
|
}
|
|
|
|
// The Steam exe hides foreign-data parties the same way: its content check (0x4139d0, __stdcall(crc) -> bool,
|
|
// the content id or three constants at 0xbbeb58) gates the party_info.ds2 and party.ds2 readers (0x41c513,
|
|
// 0x41ee0e). Only matters when the Steam game itself runs ([BrokenWorld] Launch=off, Shift, no Broken World
|
|
// folder). Same setting, same single player test.
|
|
constexpr DWORD kSteamContentCheck = 0x4139d0;
|
|
constexpr BYTE kSteamContentCheckBytes[] = {0x55, 0x8B, 0xEC, 0x8B, 0x0D, 0x4C, 0xB2, 0xBC, 0x00};
|
|
using SteamContentCheck_t = bool(WINAPI*)(DWORD crc); // the game returns a bool in AL only
|
|
SteamContentCheck_t g_steamContentCheck;
|
|
|
|
bool WINAPI SteamContentCheck(DWORD crc) {
|
|
if (g_steamContentCheck(crc)) {
|
|
NoteKnown(crc);
|
|
return true;
|
|
}
|
|
return AcceptEdition(crc) || AcceptExtraCrc(crc) || AcceptUnknownCrc(crc);
|
|
}
|
|
|
|
void AcceptAnySaveSteam(const ds2::Ini& ini) {
|
|
LoadSettings(ini);
|
|
g_acceptAnySave = ini.Bool("BrokenWorld", "AcceptAnySaveSinglePlayer", true);
|
|
g_engine = kSteamEngine;
|
|
if (!g_acceptKnownEditions && !g_extraCrcCount && !g_acceptAnySave) return;
|
|
g_steamContentCheck = static_cast<SteamContentCheck_t>(
|
|
ds2::Detour(kSteamContentCheck, kSteamContentCheckBytes, sizeof(kSteamContentCheckBytes),
|
|
reinterpret_cast<void*>(SteamContentCheck), "Steam: accept parties made with the known editions' data (single player: any data)"));
|
|
}
|
|
|
|
using LaunchGetCommandLineA_t = LPSTR(WINAPI*)();
|
|
LaunchGetCommandLineA_t g_launchGetCommandLineA;
|
|
char g_bwExe[MAX_PATH];
|
|
|
|
// One plugin set for both games: when Broken World\scripts holds DS2Revival.asi (it loads the main scripts folder's plugins into
|
|
// Broken World), full copies of the plugins from an older install there would run instead of the main folder's (an older version
|
|
// next to newer ones). The Steam game renames them to .asi.old just before it starts Broken World, when nothing of Broken World is
|
|
// loaded yet (a direct start of the Broken World exe only logs them, see mods/revival_loader). The old .ini and .log files stay.
|
|
void RetireOldBrokenWorldCopies(const char* bwDir) {
|
|
char loader[MAX_PATH], main[MAX_PATH];
|
|
sprintf_s(loader, "%s\\scripts\\DS2Revival.asi", bwDir);
|
|
if (!ds2::FileExists(loader)) return;
|
|
ds2::ExeDir(main, sizeof(main));
|
|
static const char* const kNames[] = {"DS2Multiplayer", "DS2Widescreen", "DS2Graphics", "DS2Fixes", "DS2BrokenWorld", "DS2ModLoader", "DS2Controller"};
|
|
static const char* const kFolders[] = {"", "\\scripts", "\\plugins", "\\update"};
|
|
for (const char* name : kNames) {
|
|
char twin[MAX_PATH];
|
|
sprintf_s(twin, "%s\\scripts\\%s.asi", main, name);
|
|
if (!std::strcmp(name, "DS2Controller") && !ds2::FileExists(twin)) continue; // an opt-in plugin: only when the main game has it too
|
|
for (const char* sub : kFolders) {
|
|
char from[MAX_PATH], to[MAX_PATH];
|
|
sprintf_s(from, "%s%s\\%s.asi", bwDir, sub, name);
|
|
if (!ds2::FileExists(from)) continue;
|
|
sprintf_s(to, "%s.old", from);
|
|
if (MoveFileExA(from, to, MOVEFILE_REPLACE_EXISTING))
|
|
ds2::log().Write("Broken World: old plugin copy %s renamed to %s.asi.old (Broken World runs the main game's plugins)", from, name);
|
|
else
|
|
ds2::log().Write("Broken World: could not rename the old plugin copy %s (error %lu): delete it", from, GetLastError());
|
|
}
|
|
}
|
|
}
|
|
|
|
// The 2018 "Broken World Expansion" download ships Resources\Widescreen Fix.ds2res (a world/contentdb template override and a
|
|
// field-of-view skrit). DS2Widescreen does the same job, and the archive changes Broken World's content id (0x790BFE9F plain,
|
|
// 0x75112728 with it), so players with and without it cannot join each other. Switched off (renamed to .off) before Broken World
|
|
// starts, the same moment as the old plugin copies; only that exact file (its size), and [BrokenWorld] RetireWidescreenFix=0 keeps it.
|
|
bool g_retireWidescreenFix = true;
|
|
|
|
void RetireRepackWidescreenFix(const char* bwDir) {
|
|
if (!g_retireWidescreenFix) return;
|
|
char from[MAX_PATH], to[MAX_PATH];
|
|
sprintf_s(from, "%s\\Resources\\Widescreen Fix.ds2res", bwDir);
|
|
WIN32_FILE_ATTRIBUTE_DATA fa{};
|
|
if (!GetFileAttributesExA(from, GetFileExInfoStandard, &fa) || fa.nFileSizeHigh != 0 || fa.nFileSizeLow != 6252) return;
|
|
sprintf_s(to, "%s.off", from);
|
|
if (MoveFileExA(from, to, MOVEFILE_REPLACE_EXISTING))
|
|
ds2::log().Write("Broken World: the repack's Widescreen Fix.ds2res renamed to .off (DS2Widescreen sets the field of view; the archive changes the content id, so other players could not join)");
|
|
else
|
|
ds2::log().Write("Broken World: could not rename %s (error %lu)", from, GetLastError());
|
|
}
|
|
|
|
// First GetCommandLineA call = CRT start-up of the Steam exe (loader lock released, no game code run yet).
|
|
LPSTR WINAPI LaunchBrokenWorld() {
|
|
LPSTR cmd = g_launchGetCommandLineA();
|
|
static bool done = false;
|
|
if (done) return cmd;
|
|
done = true;
|
|
const char* args = cmd; // the arguments without the exe path
|
|
if (*args == '"') {
|
|
args = std::strchr(args + 1, '"');
|
|
args = args ? args + 1 : "";
|
|
} else {
|
|
while (*args && *args != ' ') ++args;
|
|
}
|
|
char line[4096], dir[MAX_PATH];
|
|
sprintf_s(line, "%c%s%c%s", 34, g_bwExe, 34, args);
|
|
strcpy_s(dir, g_bwExe);
|
|
if (char* s = std::strrchr(dir, 92)) *s = 0;
|
|
RetireOldBrokenWorldCopies(dir);
|
|
RetireRepackWidescreenFix(dir);
|
|
STARTUPINFOA si{sizeof(si)};
|
|
PROCESS_INFORMATION pi{};
|
|
if (CreateProcessA(g_bwExe, line, nullptr, nullptr, FALSE, 0, nullptr, dir, &si, &pi)) {
|
|
ds2::log().Write("started Broken World (pid %lu): %s", pi.dwProcessId, line);
|
|
CloseHandle(pi.hThread);
|
|
CloseHandle(pi.hProcess);
|
|
ExitProcess(0);
|
|
}
|
|
ds2::log().Write("could not start Broken World (%lu); running the original game", GetLastError());
|
|
return cmd;
|
|
}
|
|
|
|
void SetupBrokenWorldLauncher(const ds2::Ini& ini) {
|
|
char mode[16], dir[MAX_PATH];
|
|
ini.String("BrokenWorld", "Launch", "auto", mode, sizeof(mode));
|
|
g_retireWidescreenFix = ini.Bool("BrokenWorld", "RetireWidescreenFix", true);
|
|
if (_stricmp(mode, "off") == 0) return;
|
|
ds2::ExeDir(dir, sizeof(dir));
|
|
sprintf_s(g_bwExe, "%s%cBroken World%cDungeonSiege2.exe", dir, 92, 92);
|
|
if (!ds2::FileExists(g_bwExe)) return;
|
|
if (GetAsyncKeyState(VK_SHIFT) < 0) {
|
|
ds2::log().Write("Broken World found, Shift held: starting the original game");
|
|
return;
|
|
}
|
|
g_launchGetCommandLineA = static_cast<LaunchGetCommandLineA_t>(ds2::HookImport(
|
|
"KERNEL32.dll", "GetCommandLineA", reinterpret_cast<void*>(LaunchBrokenWorld), "GetCommandLineA (Broken World launcher)"));
|
|
}
|
|
|
|
// --- Frame rate: CPUID under a hypervisor -------------------------------------------------------------------
|
|
// Broken World's crack kept SecuROM's code virtual machine in its rebuilt .rld section, and the game calls
|
|
// protected functions every frame (world update). Each VM dispatcher executes CPUID leaf 1 as filler: the result
|
|
// is thrown away ("mov eax,1; push ebx; cpuid; mov eax,0xf41; pop ebx; mov cl,al", junk interleaved). On bare
|
|
// metal CPUID costs ~100 cycles, but whenever Windows runs its hypervisor (Memory Integrity / VBS, Hyper-V, WSL2,
|
|
// Docker, Virtual Machine Platform, Windows Sandbox...) every CPUID traps to it: measured ~400 ns each, and 76% of
|
|
// the main thread's samples sat right after one (in the jail, ~45 fps instead of 100+). That is why turning off
|
|
// Memory Integrity helps some players and not others (anything else may keep the hypervisor running).
|
|
// Each dispatcher CPUID (tools/bw_cpuid_sites.py -> cpuid_sites.h; CRC-checked) jumps to a stub that returns the
|
|
// leaf-1 values read once at start-up - exactly what the hardware returns - and runs a real CPUID for any other
|
|
// leaf. Flags are preserved; the instructions displaced by the 5-byte jump run in the stub.
|
|
DWORD Crc32(const BYTE* p, size_t n) {
|
|
DWORD c = 0xFFFFFFFF;
|
|
for (size_t i = 0; i < n; ++i) {
|
|
c ^= p[i];
|
|
for (int k = 0; k < 8; ++k) c = (c >> 1) ^ (0xEDB88320 & (0 - (c & 1)));
|
|
}
|
|
return ~c;
|
|
}
|
|
|
|
void FastCpuid(const ds2::Ini& ini) {
|
|
if (!ini.Bool("BrokenWorld", "FastCpuid", true)) return;
|
|
int leaf1[4];
|
|
__cpuid(leaf1, 1);
|
|
constexpr size_t kStub = 64, kSites = sizeof(kCpuidSites) / sizeof(kCpuidSites[0]);
|
|
auto pool = static_cast<BYTE*>(VirtualAlloc(nullptr, kSites * kStub, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE));
|
|
if (!pool) return;
|
|
int patched = 0, skipped = 0;
|
|
for (size_t i = 0; i < kSites; ++i) {
|
|
const CpuidSite& site = kCpuidSites[i];
|
|
auto at = reinterpret_cast<BYTE*>(site.va);
|
|
if (site.span > 16 || Crc32(at, site.span) != site.crc) {
|
|
++skipped;
|
|
continue;
|
|
}
|
|
BYTE* stub = pool + i * kStub;
|
|
BYTE* p = stub;
|
|
auto put32 = [&p](DWORD v) { std::memcpy(p, &v, 4); p += 4; };
|
|
*p++ = 0x9C; // pushfd
|
|
*p++ = 0x83, *p++ = 0xF8, *p++ = 0x01; // cmp eax, 1
|
|
*p++ = 0x75; // jne other_leaf
|
|
BYTE* toOther = p++;
|
|
*p++ = 0x9D; // popfd
|
|
*p++ = 0xB8, put32(static_cast<DWORD>(leaf1[0])); // mov eax, leaf1.eax
|
|
*p++ = 0xBB, put32(static_cast<DWORD>(leaf1[1])); // mov ebx, leaf1.ebx
|
|
*p++ = 0xB9, put32(static_cast<DWORD>(leaf1[2])); // mov ecx, leaf1.ecx
|
|
*p++ = 0xBA, put32(static_cast<DWORD>(leaf1[3])); // mov edx, leaf1.edx
|
|
*p++ = 0xEB; // jmp displaced
|
|
BYTE* toDisplaced = p++;
|
|
*toOther = static_cast<BYTE>(p - (toOther + 1));
|
|
*p++ = 0x9D; // other_leaf: popfd
|
|
*p++ = 0x0F, *p++ = 0xA2; // cpuid
|
|
*toDisplaced = static_cast<BYTE>(p - (toDisplaced + 1));
|
|
std::memcpy(p, at + 2, site.copy); // displaced: the instructions the jump overwrites
|
|
p += site.copy;
|
|
DWORD next = site.next ? site.next : site.va + 2 + site.copy;
|
|
*p++ = 0xE9, put32(next - (reinterpret_cast<DWORD>(p) + 4));
|
|
BYTE patch[16];
|
|
patch[0] = 0xE9;
|
|
DWORD rel = reinterpret_cast<DWORD>(stub) - (site.va + 5);
|
|
std::memcpy(patch + 1, &rel, 4);
|
|
std::memset(patch + 5, 0x90, site.span - 5);
|
|
DWORD old;
|
|
if (!VirtualProtect(at, site.span, PAGE_EXECUTE_READWRITE, &old)) {
|
|
++skipped;
|
|
continue;
|
|
}
|
|
std::memcpy(at, patch, site.span);
|
|
VirtualProtect(at, site.span, old, &old);
|
|
++patched;
|
|
}
|
|
FlushInstructionCache(GetCurrentProcess(), nullptr, 0);
|
|
ds2::log().Write("%s Broken World: %d SecuROM-VM CPUIDs answered from a cache (%d not matched)",
|
|
patched ? "OK " : "SKIP", patched, skipped);
|
|
}
|
|
|
|
// --- Camera rotation: undo repackers' mouse hacks ------------------------------------------------------------
|
|
// Holding the middle button rotates the camera with the mouse "fixed": the game hides the cursor, and every frame
|
|
// the helper sub_614a72 puts it back at the window centre (SetCursorPos at 0x614b5a) while the mouse handler
|
|
// (0x612618..) turns the distance from the centre into rotation. Some Broken World downloads carry hand edits on
|
|
// top of the crack that break this: the SetCursorPos call NOP'd and the "distance from centre" branch at 0x612622
|
|
// forced off (so the distance keeps growing and the camera spins far too fast), and the cursor-hiding calls
|
|
// (ShowCursor, 0x613adb and 0x617509) skipped. The unmodified crack and the Steam game do none of this, and
|
|
// DS2Widescreen handles the recentring in a borderless window. Each edit is undone only where the exe holds
|
|
// exactly that edit (checked with the instruction bytes around it). The two ShowCursor edits are the exception in the game's own
|
|
// exclusive fullscreen: there they are what brings the mouse pointer back (Windows 10+ shows none otherwise), so they stay
|
|
// ([Display] ExclusiveCursor, mods/common/exclusive_cursor.h, which DS2Widescreen also applies to an exe that lacks them).
|
|
// They are not the camera drag's pointer hiding (the main loop's start and the activation handler).
|
|
struct JumpRevert {
|
|
DWORD va;
|
|
BYTE jcc; // the original conditional jump opcode (74 je / 75 jne), now EB jmp
|
|
bool pointerFix; // also one of the two edits [Display] ExclusiveCursor wants (common/exclusive_cursor.h)
|
|
};
|
|
constexpr JumpRevert kMouseJumps[] = {
|
|
{0x612622, 0x74, false}, // mouse handler: use the distance from the centre when the mouse is fixed
|
|
{0x613adb, 0x75, true}, // activation: show/hide the cursor
|
|
{0x617509, 0x74, true}, // start of the main loop: hide the cursor in fullscreen
|
|
};
|
|
constexpr DWORD kRecenterCall = 0x614b5a; // "push eax; call [SetCursorPos]; mov al,[ebp-1]; leave"
|
|
|
|
void RestoreMouse(const ds2::Ini& ini) {
|
|
if (!ini.Bool("BrokenWorld", "RestoreMouse", true)) return;
|
|
int restored = 0;
|
|
// The exclusive-fullscreen pointer needs the two cursor-hiding edits (they are what brings the pointer back there): DS2Widescreen
|
|
// skips them in the clean exe, and an exe that already carries them keeps them, when the game can be in exclusive fullscreen.
|
|
char mode[32];
|
|
ini.String("Display", "Mode", "borderless", mode, sizeof(mode));
|
|
const bool keepPointer = ds2::excursor::Applies(ini.Bool("Display", "ExclusiveCursor", true), mode, GetCommandLineA());
|
|
for (const JumpRevert& j : kMouseJumps) {
|
|
auto at = reinterpret_cast<const BYTE*>(j.va);
|
|
if (j.pointerFix && keepPointer) {
|
|
ds2::log().Write("Broken World: mouse edit @%08lX kept for the exclusive-fullscreen pointer ([Display] ExclusiveCursor)", j.va);
|
|
continue;
|
|
}
|
|
if (at[-2] == 0x85 && at[-1] == 0xC0 && at[0] == 0xEB) { // test eax,eax; jmp (edited)
|
|
const BYTE jmp = 0xEB;
|
|
restored += ds2::PatchBytes(j.va, &jmp, &j.jcc, 1, "Broken World: undo a repacker's mouse edit") ? 1 : 0;
|
|
}
|
|
}
|
|
auto call = reinterpret_cast<const BYTE*>(kRecenterCall);
|
|
const BYTE nops[6] = {0x90, 0x90, 0x90, 0x90, 0x90, 0x90};
|
|
DWORD slot = ds2::ImportSlot("USER32.dll", "SetCursorPos");
|
|
if (slot && call[-1] == 0x50 && std::memcmp(call, nops, 6) == 0 && call[6] == 0x8A && call[7] == 0x45 &&
|
|
call[8] == 0xFF && call[9] == 0xC9) {
|
|
BYTE patch[6] = {0xFF, 0x15}; // call dword ptr [SetCursorPos]
|
|
std::memcpy(patch + 2, &slot, 4);
|
|
restored += ds2::PatchBytes(kRecenterCall, nops, patch, 6, "Broken World: restore the camera's cursor recentring") ? 1 : 0;
|
|
}
|
|
ds2::log().Write("Broken World: %d repacker mouse edit(s) undone", restored);
|
|
}
|
|
|
|
void SetupBrokenWorld(const ds2::Ini& ini, const ds2::Plugin& plugin) {
|
|
// Base game = the parent folder, or [BrokenWorld] BaseGame.
|
|
char base[MAX_PATH];
|
|
ini.String("BrokenWorld", "BaseGame", "", base, sizeof(base));
|
|
if (!base[0]) strcpy_s(base, plugin.baseDir);
|
|
ds2::ExeDir(g_bwDir, sizeof(g_bwDir));
|
|
if (ds2::LooksLikeDs2Folder(base)) {
|
|
strcpy_s(g_ds2Dir, base);
|
|
ds2::log().Write("Broken World: base game in %s", g_ds2Dir);
|
|
// binkw32.dll / mss32.dll are delay-loaded by name from the exe's system folder; the expansion's
|
|
// copies are identical to the base game's, so let the search find those instead of duplicating.
|
|
char sys[MAX_PATH], own[MAX_PATH];
|
|
sprintf_s(own, "%s\\system\\binkw32.dll", g_bwDir);
|
|
if (ini.Bool("BrokenWorld", "SystemDllSearch", true) && !ds2::FileExists(own)) {
|
|
sprintf_s(sys, "%s\\system", g_ds2Dir);
|
|
SetDllDirectoryA(sys);
|
|
ds2::log().Write("Broken World: DLL search includes %s", sys);
|
|
}
|
|
} else {
|
|
ds2::log().Write("Broken World: no Dungeon Siege 2 (Resources/Logic.ds2res) in %s - set [BrokenWorld] BaseGame", base);
|
|
}
|
|
ds2::vreg::Install(ServeInstallPaths, true, true, "install paths");
|
|
AcceptSteamParties(ini);
|
|
AcceptSteamContentCrc(ini);
|
|
FastCpuid(ini);
|
|
RestoreMouse(ini);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
BOOL WINAPI DllMain(HMODULE self, DWORD reason, LPVOID) {
|
|
if (reason != DLL_PROCESS_ATTACH) return TRUE;
|
|
DisableThreadLibraryCalls(self);
|
|
ds2::Plugin plugin;
|
|
if (!ds2::StartPlugin(self, "DS2BrokenWorld", plugin)) return TRUE;
|
|
ds2::Ini ini(plugin.ini);
|
|
if (plugin.exe == ds2::Exe::BrokenWorld)
|
|
SetupBrokenWorld(ini, plugin);
|
|
else {
|
|
if (plugin.exe == ds2::Exe::Steam23) AcceptAnySaveSteam(ini);
|
|
SetupBrokenWorldLauncher(ini);
|
|
}
|
|
return TRUE;
|
|
}
|