SE2Revival/mods/toolkit/toolkit.cpp
DS2Revival c06d0479e6
Some checks failed
build / build (push) Failing after 37s
CI: build on the project's own runner at git.dsii.uk
2026-10-27 22:06:33 -04:00

776 lines
38 KiB
C++

// DS2Toolkit.asi - runs GPG's Dungeon Siege II Tool Kit 1.1 (SiegeEditor2.exe, DS2AnimViewer.exe, DS2TankViewer.exe,
// DungeonSiege2Mod.exe; Nov 2005) against the Steam game. Loaded by Ultimate ASI Loader (version.dll) placed next to
// the tool kit's programs: SiegeEditor2 imports VERSION.dll.
//
// The tool kit finds its paths through machine-wide registry keys its installer and the retail game wrote:
// HKLM\Software\Microsoft\Microsoft Games\DungeonSiege2 InstallationDirectory -> the game ("ds_install_path",
// "%ds_exe_path%/DungeonSiege2.exe", "%ds_exe_path%/Maps/")
// HKLM\Software\Gas Powered Games\DS2TK the tool kit's folder ("dstk_path", "%dstk_path%/Icons/*.ico")
// HKLM\Software\Gas Powered Games\SiegeEditor2 machine-wide editor settings, opened once per setting
// Steam installs have none of them (the tool kit's installer refused with "Dungeon Siege II does not appear to be
// installed on this machine!"; SiegeEditor2 started without the installer stops on "%dstk_path%"). This plugin answers
// them in memory when they are missing - nothing is written to the registry; a real key always wins. The game folder is
// [Toolkit] GameDir, else the folder above the tool kit's, else the Steam library that has Dungeon Siege 2. Every key
// and value the tools ask for is logged once (DS2Toolkit.log).
#include "../common/patch.h"
#include "bwenums.h"
#include <commctrl.h>
#include <cstdio>
#include <cstring>
#include <cwctype>
#include <string>
#include <vector>
namespace {
char g_gameDir[MAX_PATH], g_toolDir[MAX_PATH];
std::string g_autoMap, g_autoRegion; // region opened at start-up (ds2toolkit_map= / ds2toolkit_region=)
bool g_quietErrors = false; // Broken World mode: report_errors=false (errors to the log, no dialog each)
const HKEY kGameKey = reinterpret_cast<HKEY>(static_cast<ULONG_PTR>(0x7D520011));
const HKEY kToolKitKey = reinterpret_cast<HKEY>(static_cast<ULONG_PTR>(0x7D520012));
const HKEY kEditorKey = reinterpret_cast<HKEY>(static_cast<ULONG_PTR>(0x7D520013));
bool IsFake(HKEY k) { return k == kGameKey || k == kToolKitKey || k == kEditorKey; }
using RegOpenKeyExA_t = LSTATUS(WINAPI*)(HKEY, LPCSTR, DWORD, REGSAM, PHKEY);
using RegOpenKeyA_t = LSTATUS(WINAPI*)(HKEY, LPCSTR, PHKEY);
using RegQueryValueExA_t = LSTATUS(WINAPI*)(HKEY, LPCSTR, LPDWORD, LPDWORD, LPBYTE, LPDWORD);
using RegQueryValueA_t = LSTATUS(WINAPI*)(HKEY, LPCSTR, LPSTR, PLONG);
using RegCloseKey_t = LSTATUS(WINAPI*)(HKEY);
RegOpenKeyExA_t g_RegOpenKeyExA;
RegOpenKeyA_t g_RegOpenKeyA;
RegQueryValueExA_t g_RegQueryValueExA;
RegQueryValueA_t g_RegQueryValueA;
RegCloseKey_t g_RegCloseKey;
bool HasGame(const std::string& dir) {
return GetFileAttributesA((dir + "\\DungeonSiege2.exe").c_str()) != INVALID_FILE_ATTRIBUTES &&
GetFileAttributesA((dir + "\\Resources\\Logic.ds2res").c_str()) != INVALID_FILE_ATTRIBUTES;
}
// Steam: HKCU\Software\Valve\Steam SteamPath, then every "path" in steamapps\libraryfolders.vdf.
bool FindSteamGame(std::string& out) {
HKEY k;
char steam[MAX_PATH] = {};
DWORD size = sizeof(steam);
if (RegOpenKeyExA(HKEY_CURRENT_USER, "Software\\Valve\\Steam", 0, KEY_READ, &k) != ERROR_SUCCESS) return false;
LSTATUS r = RegQueryValueExA(k, "SteamPath", nullptr, nullptr, reinterpret_cast<LPBYTE>(steam), &size);
RegCloseKey(k);
if (r != ERROR_SUCCESS || !steam[0]) return false;
std::string libs = std::string(steam) + "\\steamapps\\libraryfolders.vdf";
FILE* f = nullptr;
std::string text;
if (fopen_s(&f, libs.c_str(), "rb") == 0 && f) {
char buf[4096];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) text.append(buf, n);
fclose(f);
}
std::string candidates[64];
int count = 0;
candidates[count++] = steam;
for (size_t at = text.find("\"path\""); at != std::string::npos && count < 64; at = text.find("\"path\"", at + 6)) {
size_t a = text.find('"', at + 6), b = a == std::string::npos ? a : text.find('"', a + 1);
if (b == std::string::npos) break;
std::string p;
for (size_t i = a + 1; i < b; ++i) // VDF escapes backslashes
if (!(text[i] == '\\' && i + 1 < b && text[i + 1] == '\\')) p += text[i];
candidates[count++] = p;
}
for (int i = 0; i < count; ++i) {
std::string dir = candidates[i] + "\\steamapps\\common\\Dungeon Siege 2";
for (char& c : dir)
if (c == '/') c = '\\';
if (HasGame(dir)) {
out = dir;
return true;
}
}
return false;
}
HKEY FakeFor(HKEY root, LPCSTR sub) {
if (root != HKEY_LOCAL_MACHINE || !sub) return nullptr;
char lower[512];
strncpy_s(lower, sub, _TRUNCATE);
_strlwr_s(lower);
if (std::strstr(lower, "microsoft games\\") && std::strstr(lower, "dungeon")) return g_gameDir[0] ? kGameKey : nullptr;
if (std::strstr(lower, "gas powered games\\ds2tk")) return kToolKitKey;
if (std::strstr(lower, "gas powered games\\siegeeditor")) return kEditorKey;
return nullptr;
}
// Each line is logged once (the editor opens its settings key thousands of times).
void LogOnce(const char* what) {
static std::string seen;
std::string k = std::string("|") + what + "|";
if (seen.find(k) != std::string::npos || seen.size() > 64 * 1024) return;
seen += k;
ds2::log().Write("%s", what);
}
LSTATUS Fake(LSTATUS r, HKEY root, LPCSTR sub, PHKEY out, const char* api) {
HKEY fake = FakeFor(root, sub);
if (root == HKEY_LOCAL_MACHINE && sub) {
char line[600];
sprintf_s(line, "%s HKLM\\%s -> %s", api, sub, r == ERROR_SUCCESS ? "real key" : fake ? "served" : "missing");
LogOnce(line);
}
if (r == ERROR_SUCCESS || !out || !fake) return r;
*out = fake;
return ERROR_SUCCESS;
}
LSTATUS WINAPI HookRegOpenKeyExA(HKEY root, LPCSTR sub, DWORD opt, REGSAM sam, PHKEY out) {
return Fake(g_RegOpenKeyExA(root, sub, opt, sam, out), root, sub, out, "RegOpenKeyExA");
}
LSTATUS WINAPI HookRegOpenKeyA(HKEY root, LPCSTR sub, PHKEY out) {
return Fake(g_RegOpenKeyA(root, sub, out), root, sub, out, "RegOpenKeyA");
}
extern bool g_bwMode;
extern std::string g_resPaths, g_mapPaths;
char g_iniPath[MAX_PATH];
bool g_askGame = false; // ask which game at the editor's first settings lookup (outside the loader lock)
void AskGame();
LSTATUS ServeValue(HKEY key, const char* name, LPDWORD type, LPBYTE data, LPDWORD size) {
static const char* const kPathNames[] = {"InstallationDirectory", "AppPath", "EXE Path", "InstallDir", "Path",
"InstallPath", "(default)"};
const char* dir = key == kGameKey ? g_gameDir : key == kToolKitKey ? g_toolDir : nullptr;
// [Settings] in DS2Toolkit.ini: any Siege Editor 2 setting (the editor reads them from this key, e.g.
// contentdb_jit, micro_templates, bits), answered before the plugin's own values.
if (key == kEditorKey && g_askGame) {
g_askGame = false;
AskGame();
}
if (key == kEditorKey && g_iniPath[0]) {
char v[1024];
GetPrivateProfileStringA("Settings", name, "\x01", v, sizeof(v), g_iniPath);
if (v[0] != '\x01') {
char line[1200];
sprintf_s(line, "served SiegeEditor2 key %s = %s ([Settings])", name, v);
LogOnce(line);
DWORD len = static_cast<DWORD>(std::strlen(v) + 1), cap = size ? *size : 0;
if (type) *type = REG_SZ;
if (size) *size = len;
if (!data) return ERROR_SUCCESS;
if (cap < len) return ERROR_MORE_DATA;
std::memcpy(data, v, len);
return ERROR_SUCCESS;
}
}
// Broken World mode: also skrit_retry=false. Broken World's archives replace base-game AI skrits with versions that
// call functions/enums only its exe has (e.g. ist_blood_assassin); the 2005 editor cannot compile them and asked
// "Strikes: 1 / AI failed to find, load, or compile Skrit ... Take another swing?" for each.
// report_errors=false ([Toolkit] QuietErrors=1, Broken World mode only; default 0): no error dialogs. It was the
// default while Broken World's enum values (ist_blood_assassin, the new ALTER_* enchantment types) raised errors at
// every start; ExtendEnums now teaches the editor those names, so errors are reported again by default.
// ds2dll_path: the folder with BWCompat.ds2dll (tools/bwcompat_gen.py): FuBi imports a *.ds2dll's exports as script
// functions, and this one exports, as stubs, the functions Broken World's exe added that its skrits call.
// map / region: Siege Editor 2 opens this region at start-up. From the command line: ds2toolkit_map=<map>
// ds2toolkit_region=<region> (e.g. ds2x_world x2_02x_prison) - for testing without the Load Region dialog.
if (key == kEditorKey && ((!_stricmp(name, "map") && !g_autoMap.empty()) || (!_stricmp(name, "region") && !g_autoRegion.empty()))) {
const std::string& v = !_stricmp(name, "map") ? g_autoMap : g_autoRegion;
char line[300];
sprintf_s(line, "served SiegeEditor2 key %s = %s", name, v.c_str());
LogOnce(line);
DWORD len = static_cast<DWORD>(v.size() + 1), cap = size ? *size : 0;
if (type) *type = REG_SZ;
if (size) *size = len;
if (!data) return ERROR_SUCCESS;
if (cap < len) return ERROR_MORE_DATA;
std::memcpy(data, v.c_str(), len);
return ERROR_SUCCESS;
}
static const std::string kFalse = "false";
static const std::string bwDlls = std::string(g_toolDir) + "\\bw";
if (key == kEditorKey && g_bwMode &&
(!_stricmp(name, "res_paths") || !_stricmp(name, "map_paths") || !_stricmp(name, "skrit_retry") ||
!_stricmp(name, "ds2dll_path") || (!_stricmp(name, "report_errors") && g_quietErrors))) {
const std::string& v = !_stricmp(name, "res_paths") ? g_resPaths : !_stricmp(name, "map_paths") ? g_mapPaths
: !_stricmp(name, "ds2dll_path") ? bwDlls : kFalse;
char line[700];
sprintf_s(line, "served SiegeEditor2 key %s = %s", name, v.c_str());
LogOnce(line);
DWORD len = static_cast<DWORD>(v.size() + 1), cap = size ? *size : 0;
if (type) *type = REG_SZ;
if (size) *size = len;
if (!data) return ERROR_SUCCESS;
if (cap < len) return ERROR_MORE_DATA;
std::memcpy(data, v.c_str(), len);
return ERROR_SUCCESS;
}
const char* which = key == kGameKey ? "game key" : key == kToolKitKey ? "DS2TK key" : "SiegeEditor2 key";
char line[300];
if (dir)
for (const char* n : kPathNames)
if (_stricmp(name, n) == 0) {
sprintf_s(line, "served %s %s = %s", which, name, dir);
LogOnce(line);
DWORD len = static_cast<DWORD>(std::strlen(dir) + 1), cap = size ? *size : 0;
if (type) *type = REG_SZ;
if (size) *size = len;
if (!data) return ERROR_SUCCESS;
if (cap < len) return ERROR_MORE_DATA;
std::memcpy(data, dir, len);
return ERROR_SUCCESS;
}
sprintf_s(line, "%s value %s: not answered (defaults)", which, name);
LogOnce(line);
return ERROR_FILE_NOT_FOUND;
}
LSTATUS WINAPI HookRegQueryValueExA(HKEY key, LPCSTR name, LPDWORD res, LPDWORD type, LPBYTE data, LPDWORD size) {
if (!IsFake(key)) return g_RegQueryValueExA(key, name, res, type, data, size);
return ServeValue(key, name && *name ? name : "(default)", type, data, size);
}
LSTATUS WINAPI HookRegQueryValueA(HKEY key, LPCSTR sub, LPSTR data, PLONG size) {
if (!IsFake(key)) return g_RegQueryValueA(key, sub, data, size);
DWORD s = size ? static_cast<DWORD>(*size) : 0;
LSTATUS r = ServeValue(key, sub && *sub ? sub : "(default)", nullptr, reinterpret_cast<LPBYTE>(data), size ? &s : nullptr);
if (size) *size = static_cast<LONG>(s);
return r;
}
LSTATUS WINAPI HookRegCloseKey(HKEY key) { return IsFake(key) ? ERROR_SUCCESS : g_RegCloseKey(key); }
// --- Broken World mode (command line "ds2toolkit_bw=1", or [Toolkit] BrokenWorld=1) --------------------------------
// The tool kit mounts only the base game's archives, so Broken World's maps fail on its own content ("Unable to open
// file 't_xxx_dun2x_flr_base_04x08-a'": a mesh in Broken World's xTerrain.ds2res). Siege Editor 2 reads its archive
// folders from its machine-wide settings key (values res_paths, map_paths, mod_paths with *.ds2res, *.ds2map,
// *.ds2mod; defaults Resources, Maps, Mods - table at 0x1044bf0, defaults set at 0x8bba2b). In this mode the served
// SiegeEditor2 key answers res_paths and map_paths with the base game's folders and Broken World's, the way Broken
// World itself mounts both. (Tried first and not taken: res_paths= on the command line, and Broken World's archives
// listed in the Mods folder - as *.ds2res they are skipped, as *.ds2mod they are listed but not mounted.)
bool g_bwMode = false;
std::string g_resPaths, g_mapPaths;
// "key=value" from the command line (value up to the next space), or "".
std::string CommandValue(const char* key) {
std::string cmd = GetCommandLineA(), k = std::string(key) + "=";
size_t at = cmd.find(k);
if (at == std::string::npos) return "";
size_t end = cmd.find_first_of(" \"", at + k.size());
return cmd.substr(at + k.size(), end == std::string::npos ? std::string::npos : end - at - k.size());
}
std::string BrokenWorldDir(const ds2::Ini& ini) {
char bw[MAX_PATH];
ini.String("Toolkit", "BrokenWorldDir", "", bw, sizeof(bw));
return bw[0] ? std::string(bw) : std::string(g_gameDir) + "\\Broken World";
}
bool BrokenWorldInstalled(const ds2::Ini& ini) {
return GetFileAttributesA((BrokenWorldDir(ini) + "\\Resources\\xTerrain.ds2res").c_str()) != INVALID_FILE_ATTRIBUTES;
}
void InstallBrokenWorldMode(const ds2::Ini& ini) {
std::string dir = BrokenWorldDir(ini);
if (GetFileAttributesA((dir + "\\Resources\\xTerrain.ds2res").c_str()) == INVALID_FILE_ATTRIBUTES) {
ds2::log().Write("Broken World mode: no Resources\\xTerrain.ds2res in %s - set [Toolkit] BrokenWorldDir=", dir.c_str());
return;
}
g_resPaths = std::string(g_gameDir) + "\\Resources;" + dir + "\\Resources";
g_mapPaths = std::string(g_gameDir) + "\\Maps;" + dir + "\\Maps";
g_bwMode = true;
ds2::log().Write("Broken World mode: res_paths=%s map_paths=%s", g_resPaths.c_str(), g_mapPaths.c_str());
}
// --- Broken World enum names (mods/toolkit/bwenums.h, generated by tools/se2_bwenums_gen.py) ----------------------
// Broken World added values to two enums its content names: eItemSkillType (ist_blood_assassin, ist_fist_of_stone,
// used by k_inc_character_utils.skrit - without them rules.skrit and efct_spell_wrath_of_the_wind do not compile:
// "'ist_blood_assassin': undeclared identifier") and eAlteration (32 ALTER_* enchantment types in pcontent.gas:
// "Enchantment must specify valid alteration type"). The editor builds both string tables in static initialisers;
// this runs before them (Ultimate ASI Loader loads plugins from the CRT start-up's first kernel32 call, before
// _initterm) and points each initialiser's table/count immediates at a table holding the editor's own entries, read
// from its image, followed by Broken World's. The editor's own values keep their numbers.
bool ExtendEnums() {
bool all = true;
for (const bwenums::Enum& e : bwenums::kEnums) {
if (*reinterpret_cast<const DWORD*>(e.object) != 0) { // the object is zero-initialised data until then
ds2::log().Write("SKIP %s names: its table is already built (plugin loaded after static initialisers)", e.name);
all = false;
continue;
}
const DWORD n = e.count, total = n + static_cast<DWORD>(e.extraCount);
DWORD table;
if (e.pairs) {
auto* t = new DWORD[2 * total]; // {const char* name, value}; lives as long as the process
std::memcpy(t, reinterpret_cast<const void*>(e.table), 8 * n);
for (int i = 0; i < e.extraCount; ++i) {
t[2 * (n + i)] = reinterpret_cast<DWORD>(e.extra[i].name);
t[2 * (n + i) + 1] = e.extra[i].value;
}
table = reinterpret_cast<DWORD>(t);
} else {
auto* t = new const char*[total];
std::memcpy(t, reinterpret_cast<const void*>(e.table), 4 * n);
for (int i = 0; i < e.extraCount; ++i) t[n + i] = e.extra[i].name;
table = reinterpret_cast<DWORD>(t);
}
char what[96];
sprintf_s(what, "%s table (+%d Broken World names)", e.name, e.extraCount);
const BYTE count8 = static_cast<BYTE>(n), total8 = static_cast<BYTE>(total);
bool ok = e.countSize == 1 ? ds2::PatchBytes(e.countImm, &count8, &total8, 1, what)
: ds2::PatchBytes(e.countImm, &n, &total, 4, what);
ok = ok && ds2::PatchBytes(e.tableImm, &e.table, &table, 4, what);
all = all && ok;
}
return all;
}
// --- [Toolkit] SkipArchives: archives the editor does not mount ----------------------------------------------------
// The editor mounts every *.ds2res / *.ds2map / *.ds2mod in its res_paths / map_paths / mod_paths folders, found with
// FindFirstFile/FindNextFile. Third-party archives dropped into the game's folders come along - e.g. the Widescreen/FoV
// mod's "Widescreen Fix.ds2res" (KillerGremal, 2015) redefines template party_humanoid, and the editor reports
// "Duplicate template detected: 'party_humanoid' is already in content database" at every start. Names listed in
// SkipArchives (separated by ';', case-insensitive, file names without folder) are hidden from those searches.
std::string g_skipArchives; // ";name1;name2;" lower case
bool SkipFound(const char* name) {
if (g_skipArchives.empty() || !name) return false;
std::string k = std::string(";") + name + ";";
for (char& c : k) c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
if (g_skipArchives.find(k) == std::string::npos) return false;
char line[400];
sprintf_s(line, "SkipArchives: not mounting %s", name);
LogOnce(line);
return true;
}
bool SkipFoundW(const wchar_t* name) {
char a[MAX_PATH] = {};
WideCharToMultiByte(CP_ACP, 0, name, -1, a, sizeof(a), nullptr, nullptr);
return SkipFound(a);
}
using FindFirstFileA_t = HANDLE(WINAPI*)(LPCSTR, LPWIN32_FIND_DATAA);
using FindNextFileA_t = BOOL(WINAPI*)(HANDLE, LPWIN32_FIND_DATAA);
using FindFirstFileW_t = HANDLE(WINAPI*)(LPCWSTR, LPWIN32_FIND_DATAW);
using FindNextFileW_t = BOOL(WINAPI*)(HANDLE, LPWIN32_FIND_DATAW);
FindFirstFileA_t g_FindFirstFileA;
FindNextFileA_t g_FindNextFileA;
FindFirstFileW_t g_FindFirstFileW;
FindNextFileW_t g_FindNextFileW;
BOOL WINAPI HookFindNextFileA(HANDLE h, LPWIN32_FIND_DATAA d) {
BOOL r;
while ((r = g_FindNextFileA(h, d)) && SkipFound(d->cFileName)) {}
return r;
}
HANDLE WINAPI HookFindFirstFileA(LPCSTR pattern, LPWIN32_FIND_DATAA d) {
HANDLE h = g_FindFirstFileA(pattern, d);
if (h == INVALID_HANDLE_VALUE || !SkipFound(d->cFileName) || HookFindNextFileA(h, d)) return h;
FindClose(h); // the only match was skipped
SetLastError(ERROR_FILE_NOT_FOUND);
return INVALID_HANDLE_VALUE;
}
BOOL WINAPI HookFindNextFileW(HANDLE h, LPWIN32_FIND_DATAW d) {
BOOL r;
while ((r = g_FindNextFileW(h, d)) && SkipFoundW(d->cFileName)) {}
return r;
}
HANDLE WINAPI HookFindFirstFileW(LPCWSTR pattern, LPWIN32_FIND_DATAW d) {
HANDLE h = g_FindFirstFileW(pattern, d);
if (h == INVALID_HANDLE_VALUE || !SkipFoundW(d->cFileName) || HookFindNextFileW(h, d)) return h;
FindClose(h);
SetLastError(ERROR_FILE_NOT_FOUND);
return INVALID_HANDLE_VALUE;
}
void InstallSkipArchives(const ds2::Ini& ini) {
char v[1024];
ini.String("Toolkit", "SkipArchives", "", v, sizeof(v));
if (!v[0]) return;
g_skipArchives = std::string(";") + v + ";";
for (char& c : g_skipArchives) c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
for (size_t at; (at = g_skipArchives.find("; ")) != std::string::npos;) g_skipArchives.erase(at + 1, 1);
for (size_t at; (at = g_skipArchives.find(" ;")) != std::string::npos;) g_skipArchives.erase(at, 1);
ds2::log().Write("SkipArchives: %s", v);
g_FindFirstFileA = static_cast<FindFirstFileA_t>(ds2::HookImport("KERNEL32.dll", "FindFirstFileA", reinterpret_cast<void*>(HookFindFirstFileA), "FindFirstFileA (SkipArchives)"));
g_FindNextFileA = static_cast<FindNextFileA_t>(ds2::HookImport("KERNEL32.dll", "FindNextFileA", reinterpret_cast<void*>(HookFindNextFileA), "FindNextFileA (SkipArchives)"));
g_FindFirstFileW = static_cast<FindFirstFileW_t>(ds2::HookImport("KERNEL32.dll", "FindFirstFileW", reinterpret_cast<void*>(HookFindFirstFileW), "FindFirstFileW (SkipArchives)"));
g_FindNextFileW = static_cast<FindNextFileW_t>(ds2::HookImport("KERNEL32.dll", "FindNextFileW", reinterpret_cast<void*>(HookFindNextFileW), "FindNextFileW (SkipArchives)"));
if (!g_FindNextFileA) g_FindNextFileA = FindNextFileA; // not hooked: the First hook still needs the original
if (!g_FindNextFileW) g_FindNextFileW = FindNextFileW;
}
// --- clothmap_rollover in info/start_positions.gas -----------------------------------------------------------------
// Every start group in the retail start_positions.gas (Dungeon Siege II and Broken World) names its journal map
// label: "clothmap_rollover = journal_map_location_...;". The editor's start-position writer (the function that writes
// clothmap_pos_x/y/z with the other start-group keys, string ref at 0x44cbe4) has no such field, so File > Save
// rewrites the map's start_positions.gas without any clothmap_rollover line (56 lost in ds2_world, 20 in ds2x_world).
// When the editor opens that file for writing, the plugin first reads the old file's rollovers per start group; when
// the editor closes the handle, it adds them back to the groups that still exist and lack one (after clothmap_pos_z).
struct RolloverWatch {
std::wstring path;
std::string groups; // "\n<group>\t<value>" per start group of the old file with a rollover
};
CRITICAL_SECTION g_watchLock;
volatile LONG g_watchCount = 0;
HANDLE g_watchHandle[8];
RolloverWatch g_watch[8];
std::string ReadWholeW(const std::wstring& path) {
std::string out;
HANDLE h = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, 0, nullptr);
if (h == INVALID_HANDLE_VALUE) return out;
char buf[65536];
DWORD n;
while (ReadFile(h, buf, sizeof(buf), &n, nullptr) && n) out.append(buf, n);
CloseHandle(h);
return out;
}
// Replace path with text via a temporary sibling, so a failed write leaves the existing file as it was.
bool WriteWholeW(const std::wstring& path, const std::string& text) {
const std::wstring tmp = path + L".rollover.tmp";
HANDLE h = CreateFileW(tmp.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
if (h == INVALID_HANDLE_VALUE) return false;
DWORD n = 0;
bool ok = WriteFile(h, text.data(), static_cast<DWORD>(text.size()), &n, nullptr) && n == text.size() && FlushFileBuffers(h);
CloseHandle(h);
if (ok) ok = MoveFileExW(tmp.c_str(), path.c_str(), MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0;
if (!ok) DeleteFileW(tmp.c_str());
return ok;
}
bool IsStartPositions(const std::wstring& path) {
static const wchar_t kTail[] = L"\\info\\start_positions.gas";
const size_t n = sizeof(kTail) / sizeof(wchar_t) - 1;
if (path.size() < n) return false;
std::wstring tail = path.substr(path.size() - n);
for (wchar_t& c : tail) c = c == L'/' ? L'\\' : towlower(c);
return tail == kTail;
}
std::string Trim(const std::string& s) {
size_t a = s.find_first_not_of(" \t\r\n"), b = s.find_last_not_of(" \t\r\n");
return a == std::string::npos ? std::string() : s.substr(a, b - a + 1);
}
// Walk the start groups ([t:start_group,n:NAME] blocks) of a start_positions.gas text: f(line index, group name in
// lower case, kind) for every line inside one. Level = a line of the group's own block (not of a nested
// [start_position] block).
enum class GroupLine { Open, Level, Nested, Close };
template <class F>
void ForEachGroupLine(const std::vector<std::string>& lines, F f) {
int depth = 0, groupDepth = -1;
std::string group, pending;
static const std::string kHead = "[t:start_group,n:";
for (size_t i = 0; i < lines.size(); ++i) {
std::string t = Trim(lines[i].substr(0, lines[i].find("//")));
std::string flat;
for (char c : t)
if (!isspace(static_cast<unsigned char>(c))) flat += static_cast<char>(tolower(static_cast<unsigned char>(c)));
if (flat.compare(0, kHead.size(), kHead) == 0 && flat.back() == ']') {
pending = flat.substr(kHead.size(), flat.size() - kHead.size() - 1);
continue;
}
const int depthBefore = depth;
bool opened = false, closed = false;
for (char c : t) {
if (c == '{') {
++depth;
if (!pending.empty() && group.empty()) {
group = pending;
groupDepth = depth;
pending.clear();
opened = true;
}
} else if (c == '}') {
if (depth == groupDepth) closed = true;
--depth;
}
}
if (group.empty()) continue;
if (opened) {
f(i, group, GroupLine::Open);
} else if (closed) {
f(i, group, GroupLine::Close);
group.clear();
groupDepth = -1;
} else {
f(i, group, depthBefore == groupDepth && depth == groupDepth ? GroupLine::Level : GroupLine::Nested);
}
}
}
std::vector<std::string> SplitLines(const std::string& text) {
std::vector<std::string> lines;
size_t at = 0;
while (at < text.size()) {
size_t e = text.find('\n', at);
lines.push_back(text.substr(at, e == std::string::npos ? std::string::npos : e - at + 1));
at = e == std::string::npos ? text.size() : e + 1;
}
return lines;
}
// key of a "key = value;" line (lower case, type prefix dropped) and its value, or false.
bool KeyValue(const std::string& line, std::string& key, std::string& value) {
std::string t = Trim(line);
size_t eq = t.find('='), semi = t.rfind(';');
if (eq == std::string::npos || semi == std::string::npos || semi < eq || t[0] == '[') return false;
std::string k = Trim(t.substr(0, eq));
size_t sp = k.find_last_of(" \t");
key = sp == std::string::npos ? k : k.substr(sp + 1);
for (char& c : key) c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
value = Trim(t.substr(eq + 1, semi - eq - 1));
return true;
}
std::string CollectRollovers(const std::string& text) {
std::string out;
std::vector<std::string> lines = SplitLines(text);
ForEachGroupLine(lines, [&](size_t i, const std::string& group, GroupLine kind) {
std::string k, v;
if (kind == GroupLine::Level && KeyValue(lines[i], k, v) && k == "clothmap_rollover")
out += "\n" + group + "\t" + v;
});
return out;
}
std::string MergeRollovers(const std::string& text, const std::string& groups, int& added) {
std::vector<std::string> lines = SplitLines(text);
const std::string eol = text.find("\r\n") != std::string::npos ? "\r\n" : "\n";
std::vector<std::string> insertAfter(lines.size());
size_t anchor = 0;
bool has = false;
std::string indent;
ForEachGroupLine(lines, [&](size_t i, const std::string& group, GroupLine kind) {
std::string k, v;
if (kind == GroupLine::Open) {
anchor = i;
has = false;
size_t ws = lines[i].find_first_not_of(" \t");
indent = lines[i].substr(0, ws == std::string::npos ? 0 : ws) + "\t";
} else if (kind == GroupLine::Level && KeyValue(lines[i], k, v)) {
if (k == "clothmap_rollover") has = true;
if (k == "clothmap_pos_z") anchor = i;
} else if (kind == GroupLine::Close) {
size_t at = groups.find("\n" + group + "\t");
if (!has && at != std::string::npos) {
size_t b = at + group.size() + 2, e = groups.find('\n', b);
insertAfter[anchor] += indent + "clothmap_rollover = " +
groups.substr(b, e == std::string::npos ? std::string::npos : e - b) + ";" + eol;
++added;
}
}
});
std::string out;
for (size_t i = 0; i < lines.size(); ++i) {
out += lines[i];
if (!insertAfter[i].empty()) {
if (out.empty() || out.back() != '\n') out += eol;
out += insertAfter[i];
}
}
return out;
}
void WatchStartPositions(HANDLE h, const std::wstring& path, const std::string& before) {
std::string groups = CollectRollovers(before);
if (groups.empty()) return;
EnterCriticalSection(&g_watchLock);
for (int i = 0; i < 8; ++i)
if (!g_watchHandle[i]) {
g_watchHandle[i] = h;
g_watch[i].path = path;
g_watch[i].groups = groups;
InterlockedIncrement(&g_watchCount);
break;
}
LeaveCriticalSection(&g_watchLock);
}
using CreateFileA_t = HANDLE(WINAPI*)(LPCSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE);
using CreateFileW_t = HANDLE(WINAPI*)(LPCWSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE);
using CloseHandle_t = BOOL(WINAPI*)(HANDLE);
CreateFileA_t g_CreateFileA;
CreateFileW_t g_CreateFileW;
CloseHandle_t g_CloseHandle;
HANDLE WINAPI HookCreateFileW(LPCWSTR name, DWORD access, DWORD share, LPSECURITY_ATTRIBUTES sa, DWORD disp, DWORD flags, HANDLE tmpl) {
std::wstring path;
std::string before;
bool watch = name && (access & (GENERIC_WRITE | GENERIC_ALL)) && IsStartPositions(path = name);
if (watch) before = ReadWholeW(path); // before the editor truncates it
HANDLE h = g_CreateFileW(name, access, share, sa, disp, flags, tmpl);
if (watch && h != INVALID_HANDLE_VALUE) WatchStartPositions(h, path, before);
return h;
}
HANDLE WINAPI HookCreateFileA(LPCSTR name, DWORD access, DWORD share, LPSECURITY_ATTRIBUTES sa, DWORD disp, DWORD flags, HANDLE tmpl) {
std::wstring path;
std::string before;
bool watch = false;
if (name && (access & (GENERIC_WRITE | GENERIC_ALL))) {
wchar_t w[MAX_PATH * 2];
if (MultiByteToWideChar(CP_ACP, 0, name, -1, w, MAX_PATH * 2) && IsStartPositions(path = w)) {
watch = true;
before = ReadWholeW(path);
}
}
HANDLE h = g_CreateFileA(name, access, share, sa, disp, flags, tmpl);
if (watch && h != INVALID_HANDLE_VALUE) WatchStartPositions(h, path, before);
return h;
}
BOOL WINAPI HookCloseHandle(HANDLE h) {
BOOL r = g_CloseHandle(h);
if (!g_watchCount) return r;
RolloverWatch w;
bool found = false;
EnterCriticalSection(&g_watchLock);
for (int i = 0; i < 8; ++i)
if (g_watchHandle[i] == h) {
g_watchHandle[i] = nullptr;
w = g_watch[i];
g_watch[i] = RolloverWatch();
InterlockedDecrement(&g_watchCount);
found = true;
break;
}
LeaveCriticalSection(&g_watchLock);
if (found) {
int added = 0;
std::string text = ReadWholeW(w.path), merged = MergeRollovers(text, w.groups, added);
bool ok = !added || WriteWholeW(w.path, merged);
char path[MAX_PATH * 2] = {};
WideCharToMultiByte(CP_ACP, 0, w.path.c_str(), -1, path, sizeof(path), nullptr, nullptr);
ds2::log().Write("start_positions.gas saved: %d clothmap_rollover line(s) put back%s (%s)", added, ok ? "" : " - WRITE FAILED", path);
}
return r;
}
void InstallRolloverKeeper() {
InitializeCriticalSection(&g_watchLock);
g_CreateFileA = static_cast<CreateFileA_t>(ds2::HookImport("KERNEL32.dll", "CreateFileA", reinterpret_cast<void*>(HookCreateFileA), "CreateFileA (clothmap_rollover)"));
g_CreateFileW = static_cast<CreateFileW_t>(ds2::HookImport("KERNEL32.dll", "CreateFileW", reinterpret_cast<void*>(HookCreateFileW), "CreateFileW (clothmap_rollover)"));
g_CloseHandle = static_cast<CloseHandle_t>(ds2::HookImport("KERNEL32.dll", "CloseHandle", reinterpret_cast<void*>(HookCloseHandle), "CloseHandle (clothmap_rollover)"));
if (!g_CloseHandle) g_CloseHandle = CloseHandle;
if (!g_CreateFileA) g_CreateFileA = CreateFileA;
if (!g_CreateFileW) g_CreateFileW = CreateFileW;
}
// The start-up question: which game to edit. A task dialog with two command links and "Remember my choice" (saved as
// [Toolkit] Game=ds2/bw); a Yes/No message box where task dialogs are not available.
void AskGame() {
ds2::Ini ini(g_iniPath);
using TaskDialogIndirect_t = HRESULT(WINAPI*)(const TASKDIALOGCONFIG*, int*, int*, BOOL*);
auto td = reinterpret_cast<TaskDialogIndirect_t>(GetProcAddress(LoadLibraryA("comctl32.dll"), "TaskDialogIndirect"));
bool bw = false, remember = false;
if (td) {
TASKDIALOG_BUTTON buttons[] = {
{100, L"Dungeon Siege II\nThe base game's maps and content"},
{101, L"Broken World\nThe expansion's maps, with both games' content loaded"},
};
TASKDIALOGCONFIG c = {sizeof(c)};
c.dwFlags = TDF_USE_COMMAND_LINKS | TDF_POSITION_RELATIVE_TO_WINDOW;
c.pszWindowTitle = L"Siege Editor 2";
c.pszMainIcon = TD_INFORMATION_ICON;
c.pszMainInstruction = L"Which game do you want to edit?";
c.pszContent = L"Hold Shift while Siege Editor 2 starts to see this question again.";
c.cButtons = 2;
c.pButtons = buttons;
c.nDefaultButton = 100;
c.pszVerificationText = L"Remember my choice";
int pressed = 100;
BOOL verified = FALSE;
if (SUCCEEDED(td(&c, &pressed, nullptr, &verified))) {
bw = pressed == 101;
remember = verified != FALSE;
}
} else {
bw = MessageBoxA(nullptr, "Edit Broken World?\n\nYes = Broken World (both games' content)\nNo = Dungeon Siege II",
"Siege Editor 2", MB_YESNO | MB_ICONQUESTION) == IDYES;
}
if (remember) ini.Set("Toolkit", "Game", bw ? "bw" : "ds2");
ds2::log().Write("start-up choice: %s%s", bw ? "Broken World" : "Dungeon Siege II", remember ? " (remembered)" : "");
if (bw) InstallBrokenWorldMode(ini);
g_quietErrors = g_bwMode && ini.Bool("Toolkit", "QuietErrors", false);
}
bool IsToolKitExe() {
static const char* const kTools[] = {"SiegeEditor2.exe", "DS2AnimViewer.exe", "DS2TankViewer.exe", "DungeonSiege2Mod.exe"};
char path[MAX_PATH];
GetModuleFileNameA(nullptr, path, MAX_PATH);
const char* base = std::strrchr(path, '\\');
base = base ? base + 1 : path;
for (const char* t : kTools)
if (_stricmp(base, t) == 0) return true;
return false;
}
} // namespace
BOOL WINAPI DllMain(HMODULE self, DWORD reason, LPVOID) {
if (reason != DLL_PROCESS_ATTACH) return TRUE;
DisableThreadLibraryCalls(self);
if (!IsToolKitExe()) return TRUE; // e.g. the loader also sits next to the game: nothing to do there
char path[MAX_PATH];
ds2::SiblingPath(self, ".log", path, sizeof(path));
ds2::log().Open(path);
ds2::log().SetDetail(true); // the tool kit plugin keeps its own small log next to itself: everything goes in
ds2::SiblingPath(self, ".ini", path, sizeof(path));
ds2::Ini ini(path);
strcpy_s(g_iniPath, path);
GetModuleFileNameA(nullptr, g_toolDir, MAX_PATH); // the tool kit's folder ("dstk_path")
if (char* slash = std::strrchr(g_toolDir, '\\')) *slash = '\0';
ini.String("Toolkit", "GameDir", "", g_gameDir, sizeof(g_gameDir));
std::string dir = g_gameDir;
if (dir.empty()) {
std::string up = g_toolDir;
up = up.substr(0, up.find_last_of('\\')); // the folder above the tool kit's
if (HasGame(up)) dir = up;
else FindSteamGame(dir);
}
strncpy_s(g_gameDir, dir.c_str(), _TRUNCATE);
ds2::log().Write("tool kit folder %s", g_toolDir);
if (g_gameDir[0])
ds2::log().Write("game folder %s", g_gameDir);
else
ds2::log().Write("no Dungeon Siege 2 folder found - set [Toolkit] GameDir= in DS2Toolkit.ini");
g_RegOpenKeyExA = static_cast<RegOpenKeyExA_t>(ds2::HookImport("ADVAPI32.dll", "RegOpenKeyExA", reinterpret_cast<void*>(HookRegOpenKeyExA), "RegOpenKeyExA (tool kit keys)"));
g_RegOpenKeyA = static_cast<RegOpenKeyA_t>(ds2::HookImport("ADVAPI32.dll", "RegOpenKeyA", reinterpret_cast<void*>(HookRegOpenKeyA), "RegOpenKeyA (tool kit keys)"));
g_RegQueryValueExA = static_cast<RegQueryValueExA_t>(ds2::HookImport("ADVAPI32.dll", "RegQueryValueExA", reinterpret_cast<void*>(HookRegQueryValueExA), "RegQueryValueExA (tool kit keys)"));
g_RegQueryValueA = static_cast<RegQueryValueA_t>(ds2::HookImport("ADVAPI32.dll", "RegQueryValueA", reinterpret_cast<void*>(HookRegQueryValueA), "RegQueryValueA (tool kit keys)"));
g_RegCloseKey = static_cast<RegCloseKey_t>(ds2::HookImport("ADVAPI32.dll", "RegCloseKey", reinterpret_cast<void*>(HookRegCloseKey), "RegCloseKey (tool kit keys)"));
// Which game to edit: ds2toolkit_bw=1/0 on the command line, else [Toolkit] Game=bw/ds2, else (Broken World
// installed) a chooser at start-up - shown at the editor's first settings lookup, not here under the loader lock.
// Holding Shift while the editor starts asks again.
std::string bwArg = CommandValue("ds2toolkit_bw");
char game[16];
ini.String("Toolkit", "Game", "ask", game, sizeof(game));
bool shift = (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0;
if (!g_gameDir[0] || !BrokenWorldInstalled(ini)) {
// only the base game
} else if (bwArg == "1" || (bwArg.empty() && !shift && !_stricmp(game, "bw"))) {
InstallBrokenWorldMode(ini);
} else if (bwArg.empty() && (shift || _stricmp(game, "ds2") != 0)) {
g_askGame = true;
}
// Broken World's enum names: whenever Broken World may be edited in this session (also when the start-up question
// is still to come - it is asked after the static initialisers ran). Base-game content never uses these names.
char exe[MAX_PATH];
GetModuleFileNameA(nullptr, exe, MAX_PATH);
const char* exeName = std::strrchr(exe, '\\');
const bool editor = !_stricmp(exeName ? exeName + 1 : exe, "SiegeEditor2.exe");
if (editor && (g_bwMode || g_askGame) && ds2::ExeTimestamp() == bwenums::kEditorTimestamp) ExtendEnums();
InstallSkipArchives(ini);
if (editor) InstallRolloverKeeper();
g_quietErrors = g_bwMode && ini.Bool("Toolkit", "QuietErrors", false);
g_autoMap = CommandValue("ds2toolkit_map");
g_autoRegion = CommandValue("ds2toolkit_region");
if (!g_autoMap.empty() || !g_autoRegion.empty())
ds2::log().Write("opening map %s region %s at start-up", g_autoMap.c_str(), g_autoRegion.c_str());
return TRUE;
}