1004 lines
52 KiB
C++
1004 lines
52 KiB
C++
// DS2ModLoader.asi - drop-in mods for Dungeon Siege II (Steam v2.3) and Broken World, no game files edited.
|
|
//
|
|
// <game>\Mods\<ModName>\*.ds2res, *.ds2map tank archives, mounted on top of the game's own
|
|
// <game>\Mods\<ModName>\<path> loose files (e.g. art\bitmaps\gui\...\x.dds,
|
|
// world\global\effects\shaders\glow.fx) override the same path
|
|
// <game>\Mods\*.ds2res, *.ds2map tanks dropped straight into Mods\ (loaded first)
|
|
// <game>\Mods\[<ModName>\]...\NAME.glb|.gltf glTF models, converted at start-up into Mods\.cache\gltf (gltf_mods.cpp,
|
|
// docs/architecture/gltf-loading.md) and mounted above the mod's loose files
|
|
// <game>\Mods\load_order.txt optional: one mod folder per line, later lines win; "-Name" turns a
|
|
// mod off; mods not listed load after the listed ones, by name
|
|
// Later mods override earlier ones; inside a mod, its loose files override its tanks; every mod overrides the game's
|
|
// own archives (base game, Broken World's overlays, priority-0x3000 patch tanks). Broken World also loads the base
|
|
// game's Mods folder first ([ModLoader] BaseGameMods=1), then its own "Broken World\Mods".
|
|
// <game>\Mods\<ModName>\modinfo.ini optional, [Mod] Games=ds2|bw|both: which game the mod is for (below)
|
|
//
|
|
// Which game a mod is for (docs/architecture/file-system.md, "Which game a mod is for"):
|
|
// * a mod in "Broken World\Mods" is for Broken World by its place and always wins there; the base game never sees it;
|
|
// * a mod in the main Mods folder is loaded by both games unless its modinfo.ini says Games=ds2 (not in Broken World)
|
|
// or Games=bw (only in Broken World);
|
|
// * protective default (Broken World only, main-Mods mod without Games=): a loose file whose path Broken World's own
|
|
// archives (x*.ds2res / x*.ds2map) also hold - the expansion changed that file - is not mounted, one log line each;
|
|
// Games=both in modinfo.ini opts out. The mod is mounted through a folder of hard links without those files
|
|
// (<Mods>\.cache\bw-filter), so the engine never sees them. Tanks are all or nothing and are not filtered.
|
|
//
|
|
// How (docs/architecture/file-system.md): the engine mounts its archives in TankMgr's mount function (Steam
|
|
// sub_81f9d3, Broken World sub_84bfdf - the same code): it expands the path variable %res_paths% / %map_paths% into a
|
|
// ';'-separated folder list, opens every *.ds2res / *.ds2map found there as a TankFileMgr, and adds each to the
|
|
// MasterFileMgr with priority 0x3fffffff - (tank header priority at +0x34); lower values are searched first. Three
|
|
// call sites in that function are redirected:
|
|
// 1. the path expansion - our mod folders are appended to the list, so the engine finds and opens their tanks
|
|
// itself (maps in mods are listed like the game's own);
|
|
// 2. TankFileMgr::Open - remembers which tank objects come from a mod (and refuses files that are not tanks);
|
|
// 3. MasterFileMgr::Add - mod tanks get a priority above every game tank, in load order; the first call also adds
|
|
// one PathFileMgr per mod with loose files (the engine's own loose-folder file manager,
|
|
// used retail for "system_detail://" and "player://"), unnamed so it serves the main
|
|
// namespace, at a priority above that mod's tanks.
|
|
// Everything is found by byte signatures, so both exes are handled by the same code. DS2ModLoader.log lists the mods,
|
|
// their files, cross-mod overrides and what the engine mounted.
|
|
#include "../common/plugin.h"
|
|
#include "gltf_mods.h" // glTF files in mods: converted into a cache mounted above the mod's loose files
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cwctype>
|
|
#include <map>
|
|
#include <set>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
enum class Games { Unset, Ds2, Bw, Both }; // modinfo.ini [Mod] Games=
|
|
|
|
struct Mod {
|
|
std::wstring name; // folder name ("(Mods folder)" for tanks directly in Mods\)
|
|
std::wstring dir; // absolute folder, no trailing backslash
|
|
std::string dirA; // the same in the ANSI code page (the engine's narrow paths)
|
|
std::string looseA; // the folder mounted for the loose files: dirA, or the filtered copy (Broken World); "" = none
|
|
Games games = Games::Unset;
|
|
std::wstring root; // the Mods folder it was found in
|
|
bool bwOwn = false; // found in "Broken World\Mods" (Broken World only, by place)
|
|
bool protect = false; // Broken World, main Mods folder, no Games=: files Broken World's archives hold are not mounted
|
|
bool res = false, map = false, loose = false;
|
|
bool gltf = false; // .glb/.gltf at its top level (gltf_mods.cpp places them by the naming key)
|
|
bool modsFolder = false; // the "(Mods folder)" entry
|
|
std::string gltfCache; // converted glTF files to mount above the loose files ("" = none)
|
|
};
|
|
|
|
std::vector<Mod> g_mods; // load order: later entries win
|
|
|
|
// Engine functions, resolved from the call sites (thiscall, called as fastcall with an unused EDX).
|
|
using Expand_t = void*(__fastcall*)(void* config, void* edx, void* out, const wchar_t* in, int flags);
|
|
using TankOpen_t = bool(__fastcall*)(void* tank, void* edx, const char* path, int report, int verify);
|
|
using AddFileMgr_t = DWORD(__fastcall*)(void* master, void* edx, void* mgr, int owned, int priority, const char* prefix);
|
|
using PathCtor_t = void*(__fastcall*)(void* self, void* edx);
|
|
using SetRoot_t = bool(__fastcall*)(void* self, void* edx, const char* path, int flag);
|
|
Expand_t g_expand;
|
|
TankOpen_t g_tankOpen;
|
|
AddFileMgr_t g_addFileMgr;
|
|
PathCtor_t g_pathCtor;
|
|
SetRoot_t g_setRoot;
|
|
bool g_looseFiles = true;
|
|
|
|
std::map<void*, int> g_tankMod; // TankFileMgr object -> mod index (last successful open of that object)
|
|
std::set<void*> g_masters; // MasterFileMgrs that already have our loose-file managers
|
|
|
|
// --- priorities ----------------------------------------------------------------------------------------------------
|
|
// The engine gives a tank 0x3fffffff - header priority (base 0 -> 0x3fffffff, Broken World 0x2000, patches 0x3000)
|
|
// and searches lower values first. Mod i gets a band below 0x20000000 that later mods undercut: its tanks keep their
|
|
// own header priority order inside the band, its loose files sit below all of its tanks.
|
|
int ModBand(int i) { return 0x20000000 - (i + 1) * 0x20000; }
|
|
int ModTankPriority(int i, int header) { return ModBand(i) - std::min(header < 0 ? 0 : header, 0xffff); }
|
|
int ModLoosePriority(int i) { return ModBand(i) - 0x18000; }
|
|
int ModGltfPriority(int i) { return ModBand(i) - 0x1c000; } // converted glTF files win over the mod's own loose files
|
|
|
|
// --- helpers -------------------------------------------------------------------------------------------------------
|
|
std::string Narrow(const std::wstring& w, UINT cp = CP_ACP) {
|
|
if (w.empty()) return std::string();
|
|
int n = WideCharToMultiByte(cp, 0, w.c_str(), static_cast<int>(w.size()), nullptr, 0, nullptr, nullptr);
|
|
std::string s(n, '\0');
|
|
WideCharToMultiByte(cp, 0, w.c_str(), static_cast<int>(w.size()), &s[0], n, nullptr, nullptr);
|
|
return s;
|
|
}
|
|
|
|
std::wstring Widen(const std::string& s, UINT cp) {
|
|
if (s.empty()) return std::wstring();
|
|
int n = MultiByteToWideChar(cp, cp == CP_UTF8 ? MB_ERR_INVALID_CHARS : 0, s.c_str(), static_cast<int>(s.size()), nullptr, 0);
|
|
if (n <= 0) return std::wstring();
|
|
std::wstring w(n, L'\0');
|
|
MultiByteToWideChar(cp, 0, s.c_str(), static_cast<int>(s.size()), &w[0], n);
|
|
return w;
|
|
}
|
|
|
|
std::string U8(const std::wstring& w) { return Narrow(w, CP_UTF8); }
|
|
|
|
bool IEndsWith(const std::wstring& s, const wchar_t* tail) {
|
|
size_t n = wcslen(tail);
|
|
return s.size() >= n && _wcsicmp(s.c_str() + s.size() - n, tail) == 0;
|
|
}
|
|
|
|
bool IsDir(const std::wstring& p) {
|
|
DWORD a = GetFileAttributesW(p.c_str());
|
|
return a != INVALID_FILE_ATTRIBUTES && (a & FILE_ATTRIBUTE_DIRECTORY);
|
|
}
|
|
|
|
std::vector<WIN32_FIND_DATAW> List(const std::wstring& dir) {
|
|
std::vector<WIN32_FIND_DATAW> out;
|
|
WIN32_FIND_DATAW fd;
|
|
HANDLE h = FindFirstFileW((dir + L"\\*").c_str(), &fd);
|
|
if (h == INVALID_HANDLE_VALUE) return out;
|
|
do {
|
|
if (wcscmp(fd.cFileName, L".") && wcscmp(fd.cFileName, L"..")) out.push_back(fd);
|
|
} while (FindNextFileW(h, &fd));
|
|
FindClose(h);
|
|
std::sort(out.begin(), out.end(), [](const WIN32_FIND_DATAW& a, const WIN32_FIND_DATAW& b) {
|
|
return _wcsicmp(a.cFileName, b.cFileName) < 0;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
bool IsTankName(const std::wstring& n) { return IEndsWith(n, L".ds2res") || IEndsWith(n, L".ds2map"); }
|
|
|
|
// Lower-case path with '/' separators: how the engine names files inside its namespace.
|
|
std::string ResPath(const std::wstring& rel) {
|
|
std::string s = U8(rel);
|
|
for (char& c : s) c = c == '\\' ? '/' : static_cast<char>(tolower(static_cast<unsigned char>(c)));
|
|
return s;
|
|
}
|
|
|
|
// --- tank index (DSg2Tank, see tools/tank.py): the file paths inside, for the log ------------------------------------
|
|
bool ReadTankIndex(const std::wstring& path, std::vector<std::string>& files, int& priority) {
|
|
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 false;
|
|
BYTE head[0x60] = {};
|
|
DWORD got = 0;
|
|
LARGE_INTEGER size{};
|
|
GetFileSizeEx(h, &size);
|
|
bool ok = ReadFile(h, head, sizeof(head), &got, nullptr) && got == sizeof(head) && !std::memcmp(head, "DSg2Tank", 8);
|
|
DWORD dirset = 0, fileset = 0, indexSize = 0;
|
|
if (ok) {
|
|
std::memcpy(&dirset, head + 0x0c, 4);
|
|
std::memcpy(&fileset, head + 0x10, 4);
|
|
std::memcpy(&indexSize, head + 0x14, 4);
|
|
std::memcpy(&priority, head + 0x34, 4);
|
|
ok = dirset && fileset > dirset && indexSize >= fileset - dirset && indexSize < (64u << 20) &&
|
|
static_cast<ULONGLONG>(dirset) + indexSize <= static_cast<ULONGLONG>(size.QuadPart);
|
|
}
|
|
std::vector<BYTE> idx;
|
|
if (ok) {
|
|
idx.resize(indexSize);
|
|
LARGE_INTEGER at;
|
|
at.QuadPart = dirset;
|
|
ok = SetFilePointerEx(h, at, nullptr, FILE_BEGIN) && ReadFile(h, idx.data(), indexSize, &got, nullptr) && got == indexSize;
|
|
}
|
|
CloseHandle(h);
|
|
if (!ok) return false;
|
|
// Sanity checks: a damaged index makes the engine stop with an error dialog, so such tanks are not offered to it.
|
|
// Every read is bounded by the set it belongs to (64-bit offsets: no wrap-around) and a read outside sets 'bad';
|
|
// names must be followed by their NUL, parents must be 0 or a directory entry, and every file's stored bytes (for
|
|
// zlib entries the chunk table and the chunks inside the compressed size) must lie inside the tank. These hold for
|
|
// every retail Dungeon Siege II / Broken World tank and for tools/tank.py's.
|
|
bool bad = false;
|
|
auto u32 = [&](ULONGLONG o, size_t end) -> DWORD {
|
|
DWORD v = 0;
|
|
if (o + 4 <= end) std::memcpy(&v, &idx[static_cast<size_t>(o)], 4);
|
|
else bad = true;
|
|
return v;
|
|
};
|
|
auto u16 = [&](ULONGLONG o, size_t end) -> WORD {
|
|
WORD v = 0;
|
|
if (o + 2 <= end) std::memcpy(&v, &idx[static_cast<size_t>(o)], 2);
|
|
else bad = true;
|
|
return v;
|
|
};
|
|
auto name = [&](ULONGLONG o, size_t len, size_t end) {
|
|
if (o + len + 1 > end || idx[static_cast<size_t>(o + len)]) {
|
|
bad = true;
|
|
return std::string();
|
|
}
|
|
return std::string(reinterpret_cast<const char*>(&idx[static_cast<size_t>(o)]), len);
|
|
};
|
|
auto pad4 = [](ULONGLONG n) { return (n + 3) & ~3ull; };
|
|
DWORD dataOffset = 0;
|
|
std::memcpy(&dataOffset, head + 0x18, 4);
|
|
const size_t fs = fileset - dirset, end = idx.size();
|
|
// directories: offset (relative to the dir set) -> (parent, name). Entry: parent, child count, FILETIME, u16 name
|
|
// length, name + NUL padded to 4, u32 child offsets (relative to the dir set; files' point into the file set).
|
|
std::map<DWORD, std::pair<DWORD, std::string>> dirs;
|
|
DWORD ndirs = u32(0, fs);
|
|
if (bad || dataOffset > dirset || static_cast<ULONGLONG>(ndirs) * 4 + 4 > fs) return false;
|
|
for (DWORD i = 0; i < ndirs; ++i) {
|
|
DWORD rel = u32(4 + 4ull * i, fs), kids = u32(rel + 4ull, fs);
|
|
WORD len = u16(rel + 16ull, fs);
|
|
std::string n = name(rel + 18ull, len, fs);
|
|
ULONGLONG kid = rel + pad4(18 + len + 1);
|
|
if (bad || rel < 4 + 4ull * ndirs || kid + 4ull * kids > fs) return false;
|
|
for (DWORD k = 0; k < kids; ++k)
|
|
if (u32(kid + 4ull * k, fs) >= end) return false;
|
|
dirs[rel] = {u32(rel, fs), n};
|
|
}
|
|
for (const auto& d : dirs)
|
|
if (d.second.first && !dirs.count(d.second.first)) return false;
|
|
auto dirPath = [&](DWORD rel) {
|
|
std::string out;
|
|
for (int guard = 0; rel && guard < 64; ++guard) {
|
|
auto it = dirs.find(rel);
|
|
if (it == dirs.end() || it->second.second.empty()) break;
|
|
out = it->second.second + "/" + out;
|
|
rel = it->second.first;
|
|
}
|
|
return out;
|
|
};
|
|
// files. Entry: parent, size, offset (from the data offset), CRC32, FILETIME, u16 format, u16 flags, u16 name
|
|
// length, name + NUL padded to 4; zlib entries then hold compressed size, chunk size and per chunk (size,
|
|
// compressed size, extra, offset into the compressed bytes).
|
|
DWORD nfiles = u32(fs, end);
|
|
if (bad || static_cast<ULONGLONG>(nfiles) * 4 + 4 > end - fs) return false;
|
|
for (DWORD i = 0; i < nfiles; ++i) {
|
|
ULONGLONG o = fs + static_cast<ULONGLONG>(u32(fs + 4 + 4ull * i, end));
|
|
DWORD parent = u32(o, end), bytes = u32(o + 4, end), at = u32(o + 8, end);
|
|
WORD format = u16(o + 24, end), len = u16(o + 28, end);
|
|
std::string n = name(o + 30, len, end);
|
|
ULONGLONG stored = bytes;
|
|
if (format == 1) {
|
|
ULONGLONG c = o + pad4(30 + len + 1);
|
|
stored = u32(c, end);
|
|
DWORD chunk = u32(c + 4, end);
|
|
ULONGLONG chunks = chunk ? (bytes + chunk - 1ull) / chunk : 0;
|
|
if (bad || (bytes && !chunk) || c + 8 + 16 * chunks > end) return false;
|
|
for (ULONGLONG k = 0, e = c + 8; k < chunks; ++k, e += 16)
|
|
if (u32(e, end) > chunk || static_cast<ULONGLONG>(u32(e + 4, end)) + u32(e + 8, end) + u32(e + 12, end) > stored)
|
|
return false;
|
|
} else if (format != 0) {
|
|
return false;
|
|
}
|
|
if (bad || o < fs + 4 + 4ull * nfiles || (parent && !dirs.count(parent)) ||
|
|
static_cast<ULONGLONG>(dataOffset) + at + stored > static_cast<ULONGLONG>(size.QuadPart))
|
|
return false;
|
|
std::string p = dirPath(parent) + n;
|
|
for (char& ch : p) ch = static_cast<char>(tolower(static_cast<unsigned char>(ch)));
|
|
files.push_back(p);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// --- discovering the mods ------------------------------------------------------------------------------------------
|
|
bool g_bwRun = false; // this is the Broken World exe (the per-game rules only apply there)
|
|
|
|
std::wstring Lower(std::wstring s) {
|
|
for (auto& c : s) c = static_cast<wchar_t>(towlower(c));
|
|
return s;
|
|
}
|
|
|
|
// A small text file as UTF-16 (UTF-8 with or without BOM, else the ANSI code page).
|
|
std::wstring ReadText(const std::wstring& file) {
|
|
HANDLE h = CreateFileW(file.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, 0, nullptr);
|
|
if (h == INVALID_HANDLE_VALUE) return std::wstring();
|
|
std::string text;
|
|
char buf[4096];
|
|
DWORD got;
|
|
while (ReadFile(h, buf, sizeof(buf), &got, nullptr) && got && text.size() < (1u << 20)) text.append(buf, got);
|
|
CloseHandle(h);
|
|
if (text.size() >= 3 && !std::memcmp(text.data(), "\xEF\xBB\xBF", 3)) text.erase(0, 3);
|
|
std::wstring w = Widen(text, CP_UTF8);
|
|
if (w.empty() && !text.empty()) w = Widen(text, CP_ACP);
|
|
return w;
|
|
}
|
|
|
|
// Calls fn(line) for every non-blank line, trimmed.
|
|
template <class Fn>
|
|
void EachLine(const std::wstring& w, Fn fn) {
|
|
size_t at = 0;
|
|
while (at <= w.size()) {
|
|
size_t end = w.find_first_of(L"\r\n", at);
|
|
std::wstring line = w.substr(at, end == std::wstring::npos ? std::wstring::npos : end - at);
|
|
at = end == std::wstring::npos ? w.size() + 1 : end + 1;
|
|
size_t a = line.find_first_not_of(L" \t"), b = line.find_last_not_of(L" \t");
|
|
if (a == std::wstring::npos) continue;
|
|
fn(line.substr(a, b - a + 1));
|
|
}
|
|
}
|
|
|
|
std::vector<std::pair<std::wstring, bool>> ReadLoadOrder(const std::wstring& file) {
|
|
std::vector<std::pair<std::wstring, bool>> out;
|
|
EachLine(ReadText(file), [&](std::wstring line) {
|
|
if (line[0] == L'#' || line[0] == L';') return;
|
|
bool on = line[0] != L'-';
|
|
if (!on) line = line.substr(line.find_first_not_of(L" \t-") == std::wstring::npos ? line.size() : line.find_first_not_of(L" \t-"));
|
|
while (!line.empty() && (line.back() == L'\\' || line.back() == L'/')) line.pop_back();
|
|
if (!line.empty()) out.push_back({line, on});
|
|
});
|
|
return out;
|
|
}
|
|
|
|
// <mod>\modinfo.ini: [Mod] Games=ds2 | bw | both (a missing file or key = not set; an unknown word is logged, not set)
|
|
Games ReadModInfo(const Mod& m) {
|
|
bool inMod = false;
|
|
Games g = Games::Unset;
|
|
EachLine(ReadText(m.dir + L"\\modinfo.ini"), [&](const std::wstring& line) {
|
|
if (line[0] == L'#' || line[0] == L';') return;
|
|
if (line[0] == L'[') {
|
|
inMod = Lower(line.substr(0, line.find(L']') + 1)) == L"[mod]";
|
|
return;
|
|
}
|
|
size_t eq = line.find(L'=');
|
|
if (!inMod || eq == std::wstring::npos) return;
|
|
std::wstring key = Lower(line.substr(0, eq)), val = line.substr(eq + 1);
|
|
while (!key.empty() && (key.back() == L' ' || key.back() == L'\t')) key.pop_back();
|
|
size_t semi = val.find(L';'); // a trailing comment
|
|
if (semi != std::wstring::npos) val.erase(semi);
|
|
size_t a = val.find_first_not_of(L" \t"), b = val.find_last_not_of(L" \t");
|
|
val = a == std::wstring::npos ? std::wstring() : Lower(val.substr(a, b - a + 1));
|
|
if (key != L"games") return;
|
|
if (val == L"ds2" || val == L"steam") g = Games::Ds2;
|
|
else if (val == L"bw" || val == L"brokenworld") g = Games::Bw;
|
|
else if (val == L"both" || val == L"all") g = Games::Both;
|
|
else ds2::log().Write(" %s: modinfo.ini: unknown Games=%s (use ds2, bw or both) - ignored", U8(m.name).c_str(), U8(val).c_str());
|
|
});
|
|
return g;
|
|
}
|
|
|
|
const char* GamesName(Games g) { return g == Games::Ds2 ? "ds2" : g == Games::Bw ? "bw" : g == Games::Both ? "both" : "not set"; }
|
|
|
|
Mod Describe(const std::wstring& name, const std::wstring& dir, bool tanksOnly) {
|
|
Mod m;
|
|
m.name = name;
|
|
m.dir = dir;
|
|
m.dirA = Narrow(dir);
|
|
for (const auto& f : List(dir)) {
|
|
if (f.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
|
|
if (!tanksOnly) m.loose = true;
|
|
} else if (IEndsWith(f.cFileName, L".ds2res")) {
|
|
m.res = true;
|
|
} else if (IEndsWith(f.cFileName, L".ds2map")) {
|
|
m.map = true;
|
|
} else if (IEndsWith(f.cFileName, L".glb") || IEndsWith(f.cFileName, L".gltf")) {
|
|
m.gltf = true;
|
|
}
|
|
}
|
|
m.looseA = m.dirA;
|
|
return m;
|
|
}
|
|
|
|
std::vector<std::wstring> g_scanned; // the Mods folders that were scanned
|
|
std::vector<std::wstring> g_otherGameMods; // mods left out because of their Games= (their glTF caches are kept)
|
|
|
|
// The mods of one Mods folder, in load order. own: it is "Broken World\Mods", the expansion's own folder.
|
|
void ScanModsFolder(const std::wstring& root, bool own) {
|
|
if (!IsDir(root)) {
|
|
ds2::log().Write("no mods folder %s", U8(root).c_str());
|
|
return;
|
|
}
|
|
g_scanned.push_back(root);
|
|
std::vector<std::wstring> names;
|
|
for (const auto& f : List(root))
|
|
if ((f.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) && f.cFileName[0] != L'.' && f.cFileName[0] != L'_')
|
|
names.push_back(f.cFileName);
|
|
std::vector<std::wstring> order;
|
|
std::set<std::wstring> placed; // lower-case names already ordered or turned off
|
|
std::wstring lo = root + L"\\load_order.txt";
|
|
auto listed = ReadLoadOrder(lo);
|
|
if (!listed.empty()) ds2::log().Write("load order from %s (%u lines)", U8(lo).c_str(), static_cast<unsigned>(listed.size()));
|
|
for (const auto& e : listed) {
|
|
auto it = std::find_if(names.begin(), names.end(), [&](const std::wstring& n) { return !_wcsicmp(n.c_str(), e.first.c_str()); });
|
|
if (it == names.end()) {
|
|
ds2::log().Write(" load_order.txt: no folder \"%s\" - ignored", U8(e.first).c_str());
|
|
continue;
|
|
}
|
|
if (!placed.insert(Lower(*it)).second) continue;
|
|
if (e.second)
|
|
order.push_back(*it);
|
|
else
|
|
ds2::log().Write(" %s: turned off in load_order.txt", U8(*it).c_str());
|
|
}
|
|
for (const auto& n : names)
|
|
if (placed.insert(Lower(n)).second) order.push_back(n); // not listed: after the listed ones, by name
|
|
// Bare files straight in Mods\ (the "(Mods folder)" entry) have no modinfo.ini: they count as "not set" (their
|
|
// tanks always load; their glTF files get the Broken World default of a main-Mods mod).
|
|
Mod top = Describe(L"(Mods folder)", root, true);
|
|
top.modsFolder = true;
|
|
top.root = root;
|
|
top.bwOwn = own;
|
|
top.protect = g_bwRun && !own;
|
|
if (top.res || top.map || top.gltf) g_mods.push_back(top);
|
|
for (const auto& n : order) {
|
|
Mod m = Describe(n, root + L"\\" + n, false);
|
|
m.root = root;
|
|
m.bwOwn = own;
|
|
if (!(m.res || m.map || m.loose || m.gltf)) {
|
|
ds2::log().Write(" %s: no .ds2res/.ds2map and no subfolders - nothing to load", U8(n).c_str());
|
|
continue;
|
|
}
|
|
m.games = ReadModInfo(m);
|
|
if (!g_bwRun && m.games == Games::Bw) {
|
|
ds2::log().Write(" %s: modinfo.ini Games=bw - for Broken World only, not loaded here", U8(n).c_str());
|
|
g_otherGameMods.push_back(n);
|
|
continue;
|
|
}
|
|
if (g_bwRun && !own && m.games == Games::Ds2) {
|
|
ds2::log().Write(" %s: modinfo.ini Games=ds2 - for the base game only, not loaded in Broken World", U8(n).c_str());
|
|
g_otherGameMods.push_back(n);
|
|
continue;
|
|
}
|
|
if (g_bwRun && own && m.games == Games::Ds2)
|
|
ds2::log().Write(" %s: modinfo.ini says Games=ds2, but the mod is in Broken World\\Mods (Broken World's own folder) - "
|
|
"loaded here anyway", U8(n).c_str());
|
|
m.protect = g_bwRun && !own && m.games == Games::Unset;
|
|
g_mods.push_back(m);
|
|
}
|
|
}
|
|
|
|
// Every file a mod brings, for the log: loose files (relative path) and each tank's contents.
|
|
void CollectLoose(const std::wstring& dir, const std::wstring& rel, std::vector<std::wstring>& out) {
|
|
for (const auto& f : List(dir)) {
|
|
std::wstring r = rel.empty() ? std::wstring(f.cFileName) : rel + L"\\" + f.cFileName;
|
|
if (f.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
|
|
CollectLoose(dir + L"\\" + f.cFileName, r, out);
|
|
else if (!rel.empty()) // files at the mod's top level are its tanks / readme, not resources
|
|
out.push_back(r);
|
|
if (out.size() > 200000) return;
|
|
}
|
|
}
|
|
|
|
// --- Broken World: the protective default -------------------------------------------------------------------------------
|
|
// A main-Mods mod without Games= may be written for the base game. In Broken World a loose file of it whose path
|
|
// Broken World's own archives (x*.ds2res, x*.ds2map) also hold would replace the expansion's changed file with the base
|
|
// game's version, so such files are not mounted. The engine's PathFileMgr serves a whole folder, so the mod is mounted
|
|
// through <Mods>\.cache\bw-filter\<mod>\loose (or \gltf for its converted glTF files): the same files as hard links
|
|
// (copies where the file system has none) minus the skipped ones. Without conflicts the mod's own folder is mounted.
|
|
std::map<std::string, std::string> g_bwFiles; // resource path -> the Broken World archive that holds it
|
|
std::set<std::wstring> g_liveMirrors; // "<mod>" and "<mod>\<kind>" (lower-case) of the mirrors in use
|
|
int g_skipLines = 0, g_skipTotal = 0;
|
|
const int kSkipLogCap = 60;
|
|
|
|
void LoadBwFiles(const std::wstring& bwDir) {
|
|
for (const wchar_t* sub : {L"Resources", L"Maps"}) {
|
|
for (const auto& f : List(bwDir + L"\\" + sub)) {
|
|
if ((f.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) || !IsTankName(f.cFileName) || towlower(f.cFileName[0]) != L'x') continue;
|
|
std::vector<std::string> files;
|
|
int prio = 0;
|
|
if (!ReadTankIndex(bwDir + L"\\" + sub + L"\\" + f.cFileName, files, prio)) {
|
|
ds2::log().Write("Broken World archive %s is not readable - files it holds are not protected", U8(f.cFileName).c_str());
|
|
continue;
|
|
}
|
|
for (const auto& p : files) g_bwFiles.insert({p, U8(f.cFileName)});
|
|
}
|
|
}
|
|
ds2::log().Write("Broken World's own archives hold %u files (the protective default skips a main-Mods mod's loose file "
|
|
"with the same path)", static_cast<unsigned>(g_bwFiles.size()));
|
|
}
|
|
|
|
bool MakeDirs(const std::wstring& dir) {
|
|
if (dir.empty()) return false;
|
|
DWORD a = GetFileAttributesW(dir.c_str());
|
|
if (a != INVALID_FILE_ATTRIBUTES) return (a & FILE_ATTRIBUTE_DIRECTORY) != 0;
|
|
size_t s = dir.find_last_of(L'\\');
|
|
if (s != std::wstring::npos && s > 2 && !MakeDirs(dir.substr(0, s))) return false;
|
|
return CreateDirectoryW(dir.c_str(), nullptr) || GetLastError() == ERROR_ALREADY_EXISTS;
|
|
}
|
|
|
|
void DeleteTree(const std::wstring& dir) {
|
|
for (const auto& f : List(dir)) {
|
|
std::wstring p = dir + L"\\" + f.cFileName;
|
|
if (!(f.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) DeleteFileW(p.c_str());
|
|
else if (f.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) RemoveDirectoryW(p.c_str()); // never follow a link
|
|
else DeleteTree(p);
|
|
}
|
|
RemoveDirectoryW(dir.c_str());
|
|
}
|
|
|
|
// The resources of a folder (relative paths): everything but the tanks and glTF files lying directly in it.
|
|
void CollectFiles(const std::wstring& dir, const std::wstring& rel, std::vector<std::wstring>& out) {
|
|
for (const auto& f : List(dir)) {
|
|
std::wstring r = rel.empty() ? std::wstring(f.cFileName) : rel + L"\\" + f.cFileName;
|
|
if (f.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
|
|
if (!(f.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) CollectFiles(dir + L"\\" + f.cFileName, r, out);
|
|
} else if (!rel.empty() || !(IsTankName(f.cFileName) || IEndsWith(f.cFileName, L".glb") || IEndsWith(f.cFileName, L".gltf"))) {
|
|
out.push_back(r);
|
|
}
|
|
if (out.size() > 200000) return;
|
|
}
|
|
}
|
|
|
|
// Deletes what is below dir and not in keep (lower-case relative paths); removes the folders left empty.
|
|
void Sweep(const std::wstring& dir, const std::wstring& rel, const std::set<std::wstring>& keep) {
|
|
for (const auto& f : List(dir)) {
|
|
std::wstring p = dir + L"\\" + f.cFileName, r = rel.empty() ? std::wstring(f.cFileName) : rel + L"\\" + f.cFileName;
|
|
if (f.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) {
|
|
if (f.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) RemoveDirectoryW(p.c_str());
|
|
else {
|
|
Sweep(p, r, keep);
|
|
RemoveDirectoryW(p.c_str()); // only succeeds when empty
|
|
}
|
|
} else if (!keep.count(Lower(r))) {
|
|
DeleteFileW(p.c_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
// A file's identity (volume serial + file index): two names of one file (hard links) have the same one.
|
|
bool FileId(const std::wstring& path, unsigned long long& id, unsigned long long& size, unsigned long long& stamp) {
|
|
HANDLE h = CreateFileW(path.c_str(), 0, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, nullptr, OPEN_EXISTING, 0, nullptr);
|
|
if (h == INVALID_HANDLE_VALUE) return false;
|
|
BY_HANDLE_FILE_INFORMATION i;
|
|
bool ok = GetFileInformationByHandle(h, &i) != 0 && !(i.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY);
|
|
CloseHandle(h);
|
|
if (!ok) return false;
|
|
id = (static_cast<unsigned long long>(i.nFileIndexHigh) << 32 | i.nFileIndexLow) ^ (static_cast<unsigned long long>(i.dwVolumeSerialNumber) << 48);
|
|
size = static_cast<unsigned long long>(i.nFileSizeHigh) << 32 | i.nFileSizeLow;
|
|
stamp = static_cast<unsigned long long>(i.ftLastWriteTime.dwHighDateTime) << 32 | i.ftLastWriteTime.dwLowDateTime;
|
|
return true;
|
|
}
|
|
|
|
// The copies a filtered folder holds (where hard links are not possible): relative path (lower case) -> the source's size
|
|
// and write time when it was copied. Kept in <mod>\<kind>.copies next to the folder, so the mounted folder holds only resources.
|
|
struct CopyRecord {
|
|
unsigned long long size = 0, stamp = 0;
|
|
};
|
|
std::map<std::wstring, CopyRecord> ReadCopies(const std::wstring& file) {
|
|
std::map<std::wstring, CopyRecord> out;
|
|
EachLine(ReadText(file), [&](const std::wstring& line) {
|
|
size_t a = line.find(L'|'), b = line.find(L'|', a + 1);
|
|
if (a == std::wstring::npos || b == std::wstring::npos) return;
|
|
CopyRecord r;
|
|
r.size = _wcstoui64(line.c_str() + a + 1, nullptr, 10);
|
|
r.stamp = _wcstoui64(line.c_str() + b + 1, nullptr, 10);
|
|
out[line.substr(0, a)] = r;
|
|
});
|
|
return out;
|
|
}
|
|
void WriteCopies(const std::wstring& file, const std::map<std::wstring, CopyRecord>& copies) {
|
|
if (copies.empty()) {
|
|
DeleteFileW(file.c_str());
|
|
return;
|
|
}
|
|
std::string text;
|
|
for (const auto& c : copies) text += U8(c.first) + "|" + std::to_string(c.second.size) + "|" + std::to_string(c.second.stamp) + "\r\n";
|
|
HANDLE h = CreateFileW(file.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
|
if (h == INVALID_HANDLE_VALUE) return;
|
|
DWORD put = 0;
|
|
WriteFile(h, text.data(), static_cast<DWORD>(text.size()), &put, nullptr);
|
|
CloseHandle(h);
|
|
}
|
|
|
|
// The filter folders of one Mods folder: <Mods>\.cache\bw-filter, or under %LOCALAPPDATA% if Mods cannot be written.
|
|
std::wstring FilterRoot(const std::wstring& modsRoot) {
|
|
std::wstring root = modsRoot + L"\\.cache\\bw-filter";
|
|
if (MakeDirs(root)) {
|
|
std::wstring probe = root + L"\\write.test";
|
|
HANDLE h = CreateFileW(probe.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_FLAG_DELETE_ON_CLOSE, nullptr);
|
|
if (h != INVALID_HANDLE_VALUE) {
|
|
CloseHandle(h);
|
|
return root;
|
|
}
|
|
}
|
|
wchar_t local[MAX_PATH];
|
|
DWORD n = GetEnvironmentVariableW(L"LOCALAPPDATA", local, MAX_PATH);
|
|
if (!n || n >= MAX_PATH) return std::wstring();
|
|
unsigned hash = 2166136261u;
|
|
for (wchar_t c : Lower(modsRoot)) hash = (hash ^ static_cast<unsigned>(c)) * 16777619u;
|
|
wchar_t sub[32];
|
|
swprintf_s(sub, L"%08x", hash);
|
|
root = std::wstring(local) + L"\\DS2Revival\\bw-filter\\" + sub;
|
|
return MakeDirs(root) ? root : std::wstring();
|
|
}
|
|
|
|
// The folder to mount for src (a mod's folder, or its converted glTF files) in Broken World: src itself when none of its
|
|
// files is held by Broken World's archives, else a folder of links to the others. "" = nothing is mounted. kind is
|
|
// "loose" or "gltf" (the mirror's folder name and the log text).
|
|
std::string FilteredFolder(const Mod& m, const std::wstring& src, const wchar_t* kind) {
|
|
std::vector<std::wstring> all, wanted;
|
|
CollectFiles(src, L"", all);
|
|
unsigned skipped = 0;
|
|
const bool gltf = wcscmp(kind, L"gltf") == 0;
|
|
for (const auto& r : all) {
|
|
std::string p = ResPath(r);
|
|
auto it = g_bwFiles.find(p);
|
|
if (it == g_bwFiles.end()) {
|
|
wanted.push_back(r);
|
|
continue;
|
|
}
|
|
++skipped;
|
|
++g_skipTotal;
|
|
if (g_skipLines++ < kSkipLogCap)
|
|
ds2::log().Write(" %s: %s %s skipped - Broken World's %s also has it (Games=both in modinfo.ini loads it anyway)",
|
|
U8(m.name).c_str(), gltf ? "converted glTF file" : "loose file", p.c_str(), it->second.c_str());
|
|
}
|
|
std::string srcA = Narrow(src);
|
|
if (!skipped) return srcA;
|
|
ds2::log().Write(" %s: %u %s file(s) not loaded (the base game's version of a file Broken World changed), %u loaded",
|
|
U8(m.name).c_str(), skipped, gltf ? "converted glTF" : "loose", static_cast<unsigned>(wanted.size()));
|
|
if (wanted.empty()) return std::string();
|
|
std::wstring root = FilterRoot(m.root);
|
|
if (root.empty()) {
|
|
ds2::log().Write(" %s: no writable folder for the filtered copy - its %s files are not loaded", U8(m.name).c_str(), gltf ? "glTF" : "loose");
|
|
return std::string();
|
|
}
|
|
std::wstring base = root + L"\\" + m.name, dst = base + L"\\" + kind;
|
|
// Bring the folder up to date with the source, every start. Per wanted file:
|
|
// - the folder's file is a hard link to the very source file (same file identity): current;
|
|
// - it is a copy made earlier (listed in <kind>.copies with the source's size and write time) and the source still has
|
|
// that size and write time: current (a large mod is copied once, then only the files that changed);
|
|
// - otherwise (new, edited, replaced by an editor that writes a new file, or not what we made): deleted and made again,
|
|
// as a hard link, else (other volume, no link support) as a copy.
|
|
// Files no longer wanted (removed from the mod, or now in Broken World's archives) are swept out.
|
|
bool ok = MakeDirs(dst);
|
|
const std::wstring copiesFile = base + L"\\" + kind + L".copies";
|
|
std::map<std::wstring, CopyRecord> oldCopies = ReadCopies(copiesFile), copies;
|
|
std::set<std::wstring> keep;
|
|
unsigned kept = 0, linked = 0, copied = 0, keptCopies = 0;
|
|
unsigned long long copiedBytes = 0, copyBytes = 0;
|
|
DWORD linkError = 0;
|
|
for (size_t i = 0; ok && i < wanted.size(); ++i) {
|
|
std::wstring s = src + L"\\" + wanted[i], d = dst + L"\\" + wanted[i], low = Lower(wanted[i]);
|
|
keep.insert(low);
|
|
unsigned long long sid = 0, ssize = 0, sstamp = 0, did = 0, dsize = 0, dstamp = 0;
|
|
if (!FileId(s, sid, ssize, sstamp)) {
|
|
ok = false;
|
|
break;
|
|
}
|
|
bool have = FileId(d, did, dsize, dstamp);
|
|
auto rec = oldCopies.find(low);
|
|
if (have && did == sid) {
|
|
++kept;
|
|
continue;
|
|
}
|
|
if (have && rec != oldCopies.end() && rec->second.size == ssize && rec->second.stamp == sstamp && dsize == ssize) {
|
|
++keptCopies;
|
|
copyBytes += ssize;
|
|
copies[low] = rec->second;
|
|
continue;
|
|
}
|
|
ok = MakeDirs(d.substr(0, d.find_last_of(L'\\')));
|
|
SetFileAttributesW(d.c_str(), FILE_ATTRIBUTE_NORMAL);
|
|
DeleteFileW(d.c_str());
|
|
if (ok && CreateHardLinkW(d.c_str(), s.c_str(), nullptr)) {
|
|
++linked;
|
|
} else if (ok) {
|
|
if (!linkError) linkError = GetLastError();
|
|
ok = CopyFileW(s.c_str(), d.c_str(), FALSE) != 0;
|
|
if (ok) {
|
|
++copied;
|
|
copiedBytes += ssize;
|
|
copyBytes += ssize;
|
|
copies[low] = CopyRecord{ssize, sstamp};
|
|
}
|
|
}
|
|
}
|
|
if (!ok) {
|
|
ds2::log().Write(" %s: cannot make the filtered folder %s (error %lu) - its %s files are not loaded", U8(m.name).c_str(),
|
|
U8(dst).c_str(), GetLastError(), gltf ? "glTF" : "loose");
|
|
DeleteTree(dst); // never leave a half-made folder behind
|
|
DeleteFileW(copiesFile.c_str());
|
|
return std::string();
|
|
}
|
|
Sweep(dst, L"", keep);
|
|
WriteCopies(copiesFile, copies);
|
|
ds2::log().Write(" %s: filtered %s folder %s: %u file(s) already current, %u linked, %u copied", U8(m.name).c_str(),
|
|
gltf ? "glTF" : "loose", U8(dst).c_str(), kept + keptCopies, linked, copied);
|
|
if (keptCopies + copied) // once per start: how much of the folder is real copies (and what this start copied)
|
|
ds2::log().Write(" %s: %u file(s) of the filtered folder are copies (%llu bytes), %llu bytes copied this start%s; a copy is made "
|
|
"again only when its source file changed", U8(m.name).c_str(), keptCopies + copied, copyBytes, copiedBytes,
|
|
linkError ? " - hard links are not possible here (e.g. another volume, error code in the next line)" : "");
|
|
if (linkError) ds2::log().Write(" %s: hard link failed with Windows error %lu", U8(m.name).c_str(), linkError);
|
|
g_liveMirrors.insert(Lower(m.name));
|
|
g_liveMirrors.insert(Lower(m.name + L"\\" + kind));
|
|
g_liveMirrors.insert(Lower(m.name + L"\\" + kind + L".copies"));
|
|
std::string a = Narrow(dst);
|
|
if (Widen(a, CP_ACP) != dst) {
|
|
ds2::log().Write(" %s: the filtered copy's path is not ANSI - its %s files are not loaded", U8(m.name).c_str(), gltf ? "glTF" : "loose");
|
|
return std::string();
|
|
}
|
|
return a;
|
|
}
|
|
|
|
// Filtered copies of earlier starts that are not needed any more (a mod gone, or now Games=both).
|
|
void CleanMirrors() {
|
|
for (const std::wstring& modsRoot : g_scanned) {
|
|
std::wstring root = modsRoot + L"\\.cache\\bw-filter";
|
|
if (!IsDir(root)) continue;
|
|
for (const auto& f : List(root)) {
|
|
std::wstring p = root + L"\\" + f.cFileName;
|
|
if (!(f.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) || (f.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) {
|
|
if (!(f.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) && wcscmp(f.cFileName, L"write.test")) DeleteFileW(p.c_str());
|
|
continue;
|
|
}
|
|
if (!g_liveMirrors.count(Lower(f.cFileName))) {
|
|
DeleteTree(p);
|
|
continue;
|
|
}
|
|
for (const auto& k : List(p))
|
|
if (!g_liveMirrors.count(Lower(f.cFileName) + L"\\" + Lower(k.cFileName))) {
|
|
if (k.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) DeleteTree(p + L"\\" + k.cFileName);
|
|
else DeleteFileW((p + L"\\" + k.cFileName).c_str());
|
|
}
|
|
}
|
|
RemoveDirectoryW(root.c_str()); // only when empty
|
|
RemoveDirectoryW((modsRoot + L"\\.cache").c_str());
|
|
}
|
|
}
|
|
|
|
// Loads the list of Broken World's files and filters the loose files of the protected mods (before they are logged and mounted).
|
|
void ProtectLooseFiles(const std::wstring& bwDir) {
|
|
bool any = false;
|
|
for (const Mod& m : g_mods) any = any || (m.protect && (m.loose || m.gltf || m.res || m.map));
|
|
if (any) LoadBwFiles(bwDir);
|
|
for (Mod& m : g_mods)
|
|
if (m.protect && m.loose && !g_bwFiles.empty()) m.looseA = FilteredFolder(m, m.dir, L"loose");
|
|
}
|
|
|
|
void LogMods() {
|
|
std::map<std::string, std::string> owner; // resource path -> "mod (where)" that provides it so far
|
|
int lines = 0, overrides = 0;
|
|
auto note = [&](const std::string& path, const std::string& who) {
|
|
auto it = owner.find(path);
|
|
if (it != owner.end()) {
|
|
++overrides;
|
|
if (lines++ < 300) ds2::log().Write(" overrides %s from %s", path.c_str(), it->second.c_str());
|
|
}
|
|
owner[path] = who;
|
|
};
|
|
for (size_t i = 0; i < g_mods.size(); ++i) {
|
|
const Mod& m = g_mods[i];
|
|
ds2::log().Write("mod %u: %s (%s)%s%s%s%s%s", static_cast<unsigned>(i + 1), U8(m.name).c_str(), U8(m.dir).c_str(),
|
|
m.res ? " [resource tanks]" : "", m.map ? " [map tanks]" : "", m.loose ? " [loose files]" : "",
|
|
m.bwOwn && g_bwRun ? " [Broken World's own Mods folder]" : "",
|
|
m.games != Games::Unset ? (std::string(" [Games=") + GamesName(m.games) + "]").c_str() : "");
|
|
for (const auto& f : List(m.dir)) {
|
|
if ((f.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) || !IsTankName(f.cFileName)) continue;
|
|
std::vector<std::string> files;
|
|
int prio = 0;
|
|
std::wstring path = m.dir + L"\\" + f.cFileName;
|
|
if (!ReadTankIndex(path, files, prio)) {
|
|
ds2::log().Write(" tank %s: not a readable DSg2Tank archive - it will be skipped", U8(f.cFileName).c_str());
|
|
continue;
|
|
}
|
|
ds2::log().Write(" tank %s: %u files, header priority 0x%X", U8(f.cFileName).c_str(), static_cast<unsigned>(files.size()), prio);
|
|
std::string who = U8(m.name) + " (" + U8(f.cFileName) + ")";
|
|
for (const auto& p : files) note(p, who);
|
|
if (m.protect && !g_bwFiles.empty()) { // tanks are loaded whole: say what they replace of Broken World's
|
|
unsigned clash = 0;
|
|
std::string first;
|
|
for (const auto& p : files)
|
|
if (g_bwFiles.count(p) && !clash++) first = p;
|
|
if (clash)
|
|
ds2::log().Write(" WARNING: tank %s holds %u file(s) that Broken World's own archives also hold (e.g. %s); a tank is "
|
|
"loaded whole, so they replace Broken World's. For the base game only, set Games=ds2 in %s\\modinfo.ini",
|
|
U8(f.cFileName).c_str(), clash, first.c_str(), U8(m.name).c_str());
|
|
}
|
|
}
|
|
if (m.loose && !m.looseA.empty()) {
|
|
std::vector<std::wstring> loose;
|
|
CollectLoose(m.looseA == m.dirA ? m.dir : Widen(m.looseA, CP_ACP), L"", loose); // the filtered copy in Broken World
|
|
ds2::log().Write(" loose files: %u", static_cast<unsigned>(loose.size()));
|
|
std::string who = U8(m.name) + " (loose)";
|
|
for (const auto& r : loose) {
|
|
std::string p = ResPath(r);
|
|
if (lines++ < 300) ds2::log().Write(" %s", p.c_str());
|
|
note(p, who);
|
|
}
|
|
}
|
|
}
|
|
if (lines > 300) ds2::log().Write("(%d more lines not shown)", lines - 300);
|
|
if (overrides) ds2::log().Write("%d file(s) are provided by more than one mod: the later mod wins", overrides);
|
|
}
|
|
|
|
// --- hooks ---------------------------------------------------------------------------------------------------------
|
|
int ModOfPath(const char* path) {
|
|
if (!path) return -1;
|
|
std::string p = path;
|
|
if (p.size() > 1 && p[1] == '\0') p = Narrow(reinterpret_cast<const wchar_t*>(path)); // wide, just in case
|
|
for (char& c : p)
|
|
if (c == '/') c = '\\';
|
|
int best = -1;
|
|
size_t bestLen = 0;
|
|
for (size_t i = 0; i < g_mods.size(); ++i) {
|
|
const std::string& d = g_mods[i].dirA;
|
|
if (p.size() > d.size() && p[d.size()] == '\\' && !_strnicmp(p.c_str(), d.c_str(), d.size()) &&
|
|
p.find('\\', d.size() + 1) == std::string::npos && d.size() > bestLen) {
|
|
best = static_cast<int>(i);
|
|
bestLen = d.size();
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
bool ValidTank(const char* path) {
|
|
std::vector<std::string> files;
|
|
int priority = 0;
|
|
return ReadTankIndex(Widen(path, CP_ACP), files, priority);
|
|
}
|
|
|
|
// 1. "%res_paths%" / "%map_paths%" -> the same followed by the mod folders that have such tanks.
|
|
void* __fastcall HookExpand(void* config, void* edx, void* out, const wchar_t* in, int flags) {
|
|
bool res = in && !_wcsicmp(in, L"%res_paths%"), map = in && !_wcsicmp(in, L"%map_paths%");
|
|
if (!res && !map) {
|
|
ds2::log().Write("tank mount: path list \"%s\" left as it is", in ? U8(in).c_str() : "(null)");
|
|
return g_expand(config, edx, out, in, flags);
|
|
}
|
|
std::wstring list = in;
|
|
int n = 0;
|
|
for (const Mod& m : g_mods) {
|
|
if (!(res ? m.res : m.map)) continue;
|
|
list += m.dir.find(L';') != std::wstring::npos ? L";\"" + m.dir + L"\"" : L";" + m.dir;
|
|
++n;
|
|
}
|
|
ds2::log().Write("tank mount: %s + %d mod folder(s)", res ? "%res_paths%" : "%map_paths%", n);
|
|
return g_expand(config, edx, out, list.c_str(), flags);
|
|
}
|
|
|
|
// 2. Opening a tank: note mod tanks; a mod file that is not a tank is refused quietly (ERROR_BAD_FORMAT is the error
|
|
// the engine does not report).
|
|
bool __fastcall HookTankOpen(void* tank, void* edx, const char* path, int report, int verify) {
|
|
int mod = ModOfPath(path);
|
|
if (mod >= 0 && !ValidTank(path)) {
|
|
ds2::log().Write("skipped %s: not a DSg2Tank archive, or its index is damaged", path);
|
|
g_tankMod.erase(tank);
|
|
SetLastError(ERROR_BAD_FORMAT);
|
|
return false;
|
|
}
|
|
bool ok = g_tankOpen(tank, edx, path, report, verify);
|
|
if (mod >= 0) {
|
|
ds2::log().Write("opened %s (%s): %s", path, U8(g_mods[mod].name).c_str(), ok ? "ok" : "FAILED");
|
|
if (ok) {
|
|
g_tankMod[tank] = mod;
|
|
return ok;
|
|
}
|
|
}
|
|
g_tankMod.erase(tank);
|
|
return ok;
|
|
}
|
|
|
|
void AddLooseFolders(void* master, void* edx) {
|
|
if (!g_looseFiles || !g_pathCtor || !g_setRoot) return;
|
|
// The engine allocates 0x88 bytes for a PathFileMgr; ours is not owned by the master (never deleted).
|
|
auto mount = [&](const Mod& m, const std::string& folder, int prio, const char* what) {
|
|
void* mgr = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, 0x200);
|
|
if (!mgr) return;
|
|
g_pathCtor(mgr, nullptr);
|
|
if (!g_setRoot(mgr, nullptr, folder.c_str(), 1)) {
|
|
ds2::log().Write("%s of %s: the engine refused the folder %s (error %lu)", what, U8(m.name).c_str(),
|
|
folder.c_str(), GetLastError());
|
|
return;
|
|
}
|
|
g_addFileMgr(master, edx, mgr, 0, prio, nullptr);
|
|
ds2::log().Write("mounted %s of %s (priority 0x%08X)", what, U8(m.name).c_str(), prio);
|
|
};
|
|
for (size_t i = 0; i < g_mods.size(); ++i) {
|
|
const Mod& m = g_mods[i];
|
|
if (m.loose && !m.looseA.empty()) mount(m, m.looseA, ModLoosePriority(static_cast<int>(i)), "loose files");
|
|
if (!m.gltfCache.empty()) mount(m, m.gltfCache, ModGltfPriority(static_cast<int>(i)), "converted glTF files");
|
|
}
|
|
}
|
|
|
|
// 3. Adding a tank to the master file manager: mod tanks above the game's, in load order. The first call per master
|
|
// also adds the loose-file folders (the master rebuilds its index after the mount, as for every unnamed manager).
|
|
DWORD __fastcall HookAddFileMgr(void* master, void* edx, void* mgr, int owned, int priority, const char* prefix) {
|
|
if (g_masters.insert(master).second) AddLooseFolders(master, edx);
|
|
auto it = g_tankMod.find(mgr);
|
|
if (it != g_tankMod.end() && !prefix) {
|
|
int header = 0x3fffffff - priority;
|
|
int mine = ModTankPriority(it->second, header);
|
|
ds2::log().Write("mounted a tank of %s: header priority 0x%X -> 0x%08X (game tanks 0x3FFFxxxx)",
|
|
U8(g_mods[it->second].name).c_str(), header, mine);
|
|
priority = mine;
|
|
}
|
|
return g_addFileMgr(master, edx, mgr, owned, priority, prefix);
|
|
}
|
|
|
|
// --- installation --------------------------------------------------------------------------------------------------
|
|
DWORD CallTarget(DWORD site) { return site + 5 + *reinterpret_cast<const LONG*>(site + 1); }
|
|
|
|
// Signatures (the same bytes in Steam v2.3 and Broken World). Inside TankMgr's mount function:
|
|
// expansion of the path variable: push 1; push [eax]; lea eax,[ebp-18h]; push eax; call -> config expand
|
|
// open: push ebx; push [ebp+0Ch]; mov ecx,esi; push [ebp-0Ch]; call TankFileMgr::Open; test al,al; jz
|
|
// add: the final loop - header (+34h) -> 0x3fffffff - priority; push 1; push edi; call MasterFileMgr::Add
|
|
// Application start-up, the "%home%" PathFileMgr ("system_detail"): new; call ctor ... expand "%home%"; push 1;
|
|
// push eax; call PathFileMgr::SetRoot.
|
|
const char kExpandSig[] = "6A 01 FF 30 8D 45 E8 50 E8 ?? ?? ?? ?? 8D 4D E4 E8";
|
|
const char kOpenSig[] = "53 FF 75 0C 8B CE FF 75 F4 E8 ?? ?? ?? ?? 84 C0 0F 84";
|
|
const char kAddSig[] = "8B 46 08 8B 78 10 8B CF E8 ?? ?? ?? ?? 8B 40 34 53 B9 FF FF FF 3F 2B C8 8B 45 E0 51 8B 08 6A 01 57 E8";
|
|
const char kPathMgrSig[] = "3B C3 59 74 0C 8B C8 E8 ?? ?? ?? ?? 89 45 B0 EB 03 89 5D B0 39 5D B0 8B 0D ?? ?? ?? ?? "
|
|
"68 84 00 00 00 68 ?? ?? ?? ?? 8D 45 AC 50 0F 95 45 ?? E8 ?? ?? ?? ?? 8B 00 8B 4D B0 6A 01 50 E8";
|
|
|
|
bool InstallHooks() {
|
|
DWORD expand = ds2::FindUnique(kExpandSig), open = ds2::FindUnique(kOpenSig), add = ds2::FindUnique(kAddSig);
|
|
if (!expand || !open || !add) {
|
|
ds2::log().Write("SKIP: TankMgr mount function not found (expand %08X open %08X add %08X) - no mods loaded", expand, open, add);
|
|
return false;
|
|
}
|
|
DWORD expandSite = expand + 8, openSite = open + 9, addSite = add + 33;
|
|
// the three sites lie in the one mount function (about 0x500 bytes)
|
|
if (openSite < expandSite || addSite < openSite || addSite - expandSite > 0x800) {
|
|
ds2::log().Write("SKIP: mount function sites out of order (%08X %08X %08X)", expandSite, openSite, addSite);
|
|
return false;
|
|
}
|
|
g_expand = reinterpret_cast<Expand_t>(CallTarget(expandSite));
|
|
g_tankOpen = reinterpret_cast<TankOpen_t>(CallTarget(openSite));
|
|
g_addFileMgr = reinterpret_cast<AddFileMgr_t>(CallTarget(addSite));
|
|
if (DWORD pm = ds2::FindUnique(kPathMgrSig)) {
|
|
g_pathCtor = reinterpret_cast<PathCtor_t>(CallTarget(pm + 7));
|
|
g_setRoot = reinterpret_cast<SetRoot_t>(CallTarget(pm + 0x3c));
|
|
ds2::log().Write("PathFileMgr ctor %08X, SetRoot %08X", reinterpret_cast<DWORD>(g_pathCtor), reinterpret_cast<DWORD>(g_setRoot));
|
|
} else {
|
|
ds2::log().Write("SKIP loose files: PathFileMgr not found");
|
|
}
|
|
bool ok = ds2::RedirectCall(expandSite, reinterpret_cast<DWORD>(g_expand), reinterpret_cast<DWORD>(&HookExpand), "mods: tank folders");
|
|
ok = ok && ds2::RedirectCall(openSite, reinterpret_cast<DWORD>(g_tankOpen), reinterpret_cast<DWORD>(&HookTankOpen), "mods: tank open");
|
|
ok = ok && ds2::RedirectCall(addSite, reinterpret_cast<DWORD>(g_addFileMgr), reinterpret_cast<DWORD>(&HookAddFileMgr), "mods: priorities + loose files");
|
|
return ok;
|
|
}
|
|
|
|
std::wstring ExeDirW() {
|
|
wchar_t p[MAX_PATH];
|
|
GetModuleFileNameW(nullptr, p, MAX_PATH);
|
|
if (wchar_t* s = wcsrchr(p, L'\\')) *s = 0;
|
|
return p;
|
|
}
|
|
|
|
std::wstring Absolute(const std::wstring& base, const std::wstring& folder) {
|
|
if (folder.size() > 1 && (folder[1] == L':' || (folder[0] == L'\\' && folder[1] == L'\\'))) return folder;
|
|
std::wstring p = base + L"\\" + folder, out(MAX_PATH, L'\0');
|
|
DWORD n = GetFullPathNameW(p.c_str(), MAX_PATH, &out[0], nullptr);
|
|
if (!n || n >= MAX_PATH) return p;
|
|
out.resize(n);
|
|
while (out.size() > 3 && out.back() == L'\\') out.pop_back();
|
|
return out;
|
|
}
|
|
|
|
} // 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, "DS2ModLoader", plugin)) return TRUE;
|
|
ds2::Ini ini(plugin.ini);
|
|
if (!ini.Bool("ModLoader", "Enabled", true)) {
|
|
ds2::log().Write("[ModLoader] Enabled=0 - off");
|
|
return TRUE;
|
|
}
|
|
g_looseFiles = ini.Bool("ModLoader", "LooseFiles", true);
|
|
char folderA[MAX_PATH];
|
|
ini.String("ModLoader", "Folder", "Mods", folderA, sizeof(folderA));
|
|
std::wstring folder = Widen(folderA, CP_ACP);
|
|
if (folder.empty()) folder = L"Mods";
|
|
std::wstring exeDir = ExeDirW();
|
|
g_bwRun = plugin.exe == ds2::Exe::BrokenWorld;
|
|
std::wstring ownRoot = Absolute(exeDir, folder);
|
|
if (g_bwRun && plugin.baseDir[0] && ini.Bool("ModLoader", "BaseGameMods", true) && ds2::LooksLikeDs2Folder(plugin.baseDir)) {
|
|
std::wstring baseRoot = Absolute(Widen(plugin.baseDir, CP_ACP), folder);
|
|
ScanModsFolder(baseRoot, false);
|
|
if (!_wcsicmp(baseRoot.c_str(), ownRoot.c_str()))
|
|
ds2::log().Write("[ModLoader] Folder is the same folder for both games - scanned once (as the main Mods folder)");
|
|
else
|
|
ScanModsFolder(ownRoot, true);
|
|
} else {
|
|
ScanModsFolder(ownRoot, g_bwRun); // Broken World without the base game's mods: its own folder only
|
|
}
|
|
if (g_mods.empty()) {
|
|
ds2::log().Write("no mods found - nothing to do");
|
|
return TRUE;
|
|
}
|
|
if (g_bwRun && g_looseFiles) ProtectLooseFiles(exeDir);
|
|
LogMods();
|
|
if (!InstallHooks()) return TRUE;
|
|
ds2::log().Write("%u mod(s) will be mounted (later ones win)", static_cast<unsigned>(g_mods.size()));
|
|
if (g_looseFiles && ini.Bool("ModLoader", "GLTF", true)) { // .glb/.gltf in mods -> .asp/.prs/.dds (gltf_mods.cpp)
|
|
std::vector<gltfmods::ModInfo> info;
|
|
for (const Mod& m : g_mods) info.push_back({m.name, m.dir, m.modsFolder});
|
|
std::vector<std::wstring> res = {exeDir + L"\\Resources"};
|
|
if (plugin.baseDir[0]) res.push_back(Widen(plugin.baseDir, CP_ACP) + L"\\Resources");
|
|
std::vector<std::string> caches = gltfmods::Prepare(info, res, g_otherGameMods);
|
|
for (size_t i = 0; i < g_mods.size() && i < caches.size(); ++i) {
|
|
g_mods[i].gltfCache = caches[i];
|
|
if (g_bwRun && g_mods[i].protect && !caches[i].empty() && !g_bwFiles.empty()) // the same default for converted files
|
|
g_mods[i].gltfCache = FilteredFolder(g_mods[i], Widen(caches[i], CP_ACP), L"gltf");
|
|
}
|
|
}
|
|
if (g_bwRun) {
|
|
if (g_skipTotal > kSkipLogCap) ds2::log().Write("(%d more skipped files not listed)", g_skipTotal - kSkipLogCap);
|
|
CleanMirrors();
|
|
}
|
|
return TRUE;
|
|
}
|