643 lines
36 KiB
C++
643 lines
36 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"
|
|
#include "bind_tables.h"
|
|
|
|
namespace {
|
|
|
|
bool g_slotsBound = false; // BindSlots succeeded (slots pre-bound + hot VM program devirtualised)
|
|
|
|
// --- 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;
|
|
}
|
|
if (*reinterpret_cast<const DWORD*>(kBwPartyVersionSlot) == kSteamPartyVersion) { // BindSlots wrote it already
|
|
ds2::log().Write("Broken World: party format 0x3e already accepted (BindSlots)");
|
|
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;
|
|
if (g_slotsBound) { // the VM is essentially never entered now; the CPUID cache is redundant
|
|
ds2::log().Write("Broken World: FastCpuid not needed (slots bound, hot VM program devirtualised)");
|
|
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().Detail("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);
|
|
}
|
|
|
|
// --- Start-up binding: resolve every SecuROM indirection in memory on the crack players use --------------------
|
|
// Generalises the CPUID fix. At start-up, pre-bind all 2450 lazily bound slots to their real targets and write the
|
|
// 404 constants SecuROM's stubs would have written (tables in bind_tables.h, from tools/bwclean/data - addresses
|
|
// only), and devirtualise the one inline VM program that is >99.9% of all VM entries by restoring its original
|
|
// instructions. Those instructions are recovered at run time from the player's own Steam DungeonSiege2.exe (the base
|
|
// game Broken World runs from, see g_ds2Dir); we ship no Steam code bytes. Each slot is written only when it currently
|
|
// holds its resolver stub or the expected target; otherwise it is skipped and logged (a different crack/version).
|
|
|
|
// Build the clean copy of a devirtualised function in RWX memory from the player's Steam exe; 0 on any mismatch.
|
|
void* BuildDevirt(const ds2bind::Devirt& d) {
|
|
char path[MAX_PATH];
|
|
sprintf_s(path, "%s\\DungeonSiege2.exe", g_ds2Dir);
|
|
HANDLE fh = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
|
|
if (fh == INVALID_HANDLE_VALUE) return nullptr;
|
|
DWORD size = GetFileSize(fh, nullptr), got = 0;
|
|
BYTE* buf = static_cast<BYTE*>(HeapAlloc(GetProcessHeap(), 0, size ? size : 1));
|
|
void* result = nullptr;
|
|
if (buf && ReadFile(fh, buf, size, &got, nullptr) && got >= 0x200) {
|
|
auto dos = reinterpret_cast<IMAGE_DOS_HEADER*>(buf);
|
|
auto nt = reinterpret_cast<IMAGE_NT_HEADERS32*>(buf + dos->e_lfanew);
|
|
if (dos->e_magic == IMAGE_DOS_SIGNATURE && dos->e_lfanew + sizeof(*nt) <= got &&
|
|
nt->Signature == IMAGE_NT_SIGNATURE && nt->FileHeader.TimeDateStamp == ds2bind::kSteamTimestamp) {
|
|
auto sec = IMAGE_FIRST_SECTION(nt); // the first section is .text
|
|
if (sec->PointerToRawData + sec->SizeOfRawData <= got) {
|
|
const BYTE* text = buf + sec->PointerToRawData;
|
|
DWORD tlen = sec->SizeOfRawData;
|
|
const BYTE* cont = reinterpret_cast<const BYTE*>(d.cont); // live crack image (byte-identical engine)
|
|
const BYTE* prefix = reinterpret_cast<const BYTE*>(d.entry);
|
|
for (DWORD i = d.stolenLen + d.prefixLen; i + d.tailLen <= tlen; ++i) {
|
|
if (std::memcmp(text + i, cont, d.tailLen) != 0) continue;
|
|
if (std::memcmp(text + i - d.stolenLen - d.prefixLen, prefix, d.prefixLen) != 0) continue;
|
|
DWORD n = d.prefixLen + d.stolenLen + d.tailLen;
|
|
BYTE* fn = static_cast<BYTE*>(VirtualAlloc(nullptr, n, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE));
|
|
if (fn) {
|
|
std::memcpy(fn, prefix, d.prefixLen);
|
|
std::memcpy(fn + d.prefixLen, text + i - d.stolenLen, d.stolenLen);
|
|
std::memcpy(fn + d.prefixLen + d.stolenLen, cont, d.tailLen);
|
|
result = fn;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (buf) HeapFree(GetProcessHeap(), 0, buf);
|
|
return result;
|
|
}
|
|
|
|
void BindSlots(const ds2::Ini& ini) {
|
|
if (!ini.Bool("BrokenWorld", "BindSlots", true)) return;
|
|
auto base = reinterpret_cast<const BYTE*>(GetModuleHandleA(nullptr));
|
|
auto nt = reinterpret_cast<const IMAGE_NT_HEADERS32*>(base + reinterpret_cast<const IMAGE_DOS_HEADER*>(base)->e_lfanew);
|
|
if (nt->FileHeader.TimeDateStamp != ds2bind::kCrackTimestamp) {
|
|
ds2::log().Write("SKIP Broken World: BindSlots - exe timestamp %08lX is not the known crack", nt->FileHeader.TimeDateStamp);
|
|
return;
|
|
}
|
|
constexpr DWORD lo = 0x17fc000, hi = 0x1800000; // the SecuROM slot + constant region of .rld
|
|
DWORD old;
|
|
if (!VirtualProtect(reinterpret_cast<void*>(lo), hi - lo, PAGE_EXECUTE_READWRITE, &old)) {
|
|
ds2::log().Write("SKIP Broken World: BindSlots - VirtualProtect failed (%lu)", GetLastError());
|
|
return;
|
|
}
|
|
int bound = 0, skipped = 0;
|
|
DWORD firstSkip = 0;
|
|
for (const ds2bind::Slot& s : ds2bind::kSlots) {
|
|
DWORD cur = *reinterpret_cast<DWORD*>(s.slot);
|
|
if (cur == s.stub || cur == s.target) { *reinterpret_cast<DWORD*>(s.slot) = s.target; ++bound; }
|
|
else { ++skipped; if (!firstSkip) firstSkip = s.slot; }
|
|
}
|
|
int consts = 0, cskip = 0;
|
|
for (const ds2bind::Const& c : ds2bind::kConsts) {
|
|
DWORD cur = *reinterpret_cast<DWORD*>(c.slot);
|
|
if (cur == 0 || cur == c.value) { *reinterpret_cast<DWORD*>(c.slot) = c.value; ++consts; }
|
|
else ++cskip;
|
|
}
|
|
int devirt = 0, devirtSkip = 0, devirtFail = 0, devirtDone = 0;
|
|
for (const ds2bind::Devirt& d : ds2bind::kDevirt) {
|
|
DWORD cur = *reinterpret_cast<DWORD*>(d.slot);
|
|
bool stubOrTarget = cur == d.entry || (cur >= 0x1850000 && cur < 0x1876000);
|
|
if (!stubOrTarget) { ++devirtDone; continue; } // already a clean copy (e.g. the clean research exe) - leave it
|
|
void* fn = BuildDevirt(d);
|
|
if (fn) { *reinterpret_cast<DWORD*>(d.slot) = reinterpret_cast<DWORD>(fn); ++devirt; }
|
|
else { *reinterpret_cast<DWORD*>(d.slot) = d.entry; ++devirtSkip; ++devirtFail; } // original inline VM; FastCpuid covers speed
|
|
}
|
|
VirtualProtect(reinterpret_cast<void*>(lo), hi - lo, old, &old);
|
|
FlushInstructionCache(GetCurrentProcess(), nullptr, 0);
|
|
int devirtTotal = sizeof(ds2bind::kDevirt) / sizeof(ds2bind::kDevirt[0]);
|
|
g_slotsBound = skipped * 4 < bound && devirtFail == 0; // mostly bound and no hot program left on the VM
|
|
ds2::log().Write("%s Broken World: bound %d/%d slots, %d/%d consts, devirt %d/%d%s%s (first skip @%08X)",
|
|
g_slotsBound ? "OK " : "WARN", bound, bound + skipped, consts, consts + cskip, devirt + devirtDone,
|
|
devirtTotal, devirtFail ? " (no Steam v2.3 exe - inline VM kept)" : "",
|
|
g_slotsBound ? "" : " - FastCpuid fallback", firstSkip);
|
|
}
|
|
|
|
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().Detail("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");
|
|
BindSlots(ini); // pre-bind every slot + devirtualise the hot VM program (generalises FastCpuid)
|
|
AcceptSteamParties(ini); // a no-op after BindSlots (it already wrote 0x3e to the party-format slot)
|
|
AcceptSteamContentCrc(ini); // wraps the (now pre-bound) content-CRC slot
|
|
FastCpuid(ini); // fallback: only patches CPUID when BindSlots was skipped (see g_slotsBound)
|
|
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::SummaryAtExit summary;
|
|
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;
|
|
}
|