721 lines
33 KiB
C++
721 lines
33 KiB
C++
// glTF files in mods -> a per-mod cache of .asp / .prs / .dds that DS2ModLoader mounts (gltf_mods.h).
|
|
// Rules and evidence: docs/architecture/gltf-loading.md.
|
|
//
|
|
// <mod>\art\...\NAME.glb|.gltf -> <cache>\art\...\NAME.asp (or NAME.prs if NAME starts with "a_"), i.e. the glTF
|
|
// stands in for the asset at the same path
|
|
// <mod>\NAME.glb (outside art\) -> the asset NAME at the path the game's naming key gives it
|
|
// textures of NAME (m_xxx) -> b_xxx_gltf (one material) or b_xxx_gltf_01, _02 ... at their naming-key path;
|
|
// a material named b_... keeps that name (to fill a template's [textures])
|
|
// animations named a_... in a skinned mesh's glTF -> a_....prs at their naming-key path
|
|
//
|
|
// Cache: <Mods>\.cache\gltf\<mod>\ (or %LOCALAPPDATA%\DS2Revival\gltf-cache\... if Mods is read-only) with a
|
|
// manifest <mod>.txt: each source's size + modification time (and those of the external files it read), and what it
|
|
// produced. Unchanged sources are not converted again; files no longer produced are deleted. A glTF that fails to
|
|
// convert (any error, or a crash inside the converter, which is caught) produces nothing, so the original asset stays.
|
|
#include "gltf_mods.h"
|
|
|
|
#include "../common/patch.h"
|
|
#include "gltf.h"
|
|
|
|
#include <algorithm>
|
|
#include <cwctype>
|
|
#include <map>
|
|
#include <set>
|
|
|
|
namespace gltfmods {
|
|
namespace {
|
|
|
|
const char kCacheVersion[] = "DS2ModLoader glTF cache 1"; // bump when the converter's output changes
|
|
|
|
// --- strings and files -------------------------------------------------------------------------------------------------
|
|
std::string Narrow(const std::wstring& w, UINT cp) {
|
|
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::string U8(const std::wstring& w) { return Narrow(w, CP_UTF8); }
|
|
|
|
std::wstring Widen(const std::string& s, UINT cp) {
|
|
if (s.empty()) return std::wstring();
|
|
int n = MultiByteToWideChar(cp, 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::wstring Lower(std::wstring s) {
|
|
for (auto& c : s) c = static_cast<wchar_t>(towlower(c));
|
|
return s;
|
|
}
|
|
|
|
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 IsGltf(const std::wstring& name) { return IEndsWith(name, L".glb") || IEndsWith(name, L".gltf"); }
|
|
|
|
struct Entry {
|
|
std::wstring name;
|
|
bool dir;
|
|
bool link;
|
|
};
|
|
|
|
std::vector<Entry> List(const std::wstring& dir) {
|
|
std::vector<Entry> 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.cFileName, (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0,
|
|
(fd.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0});
|
|
} while (FindNextFileW(h, &fd));
|
|
FindClose(h);
|
|
std::sort(out.begin(), out.end(), [](const Entry& a, const Entry& b) { return _wcsicmp(a.name.c_str(), b.name.c_str()) < 0; });
|
|
return out;
|
|
}
|
|
|
|
// "size:mtime" of a file, or "missing".
|
|
std::wstring Stamp(const std::wstring& path) {
|
|
WIN32_FILE_ATTRIBUTE_DATA a;
|
|
if (!GetFileAttributesExW(path.c_str(), GetFileExInfoStandard, &a) || (a.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY))
|
|
return L"missing";
|
|
wchar_t buf[64];
|
|
swprintf_s(buf, L"%llu:%llu", (static_cast<unsigned long long>(a.nFileSizeHigh) << 32) | a.nFileSizeLow,
|
|
(static_cast<unsigned long long>(a.ftLastWriteTime.dwHighDateTime) << 32) | a.ftLastWriteTime.dwLowDateTime);
|
|
return buf;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
bool WriteFileAll(const std::wstring& path, const void* data, size_t size) {
|
|
size_t s = path.find_last_of(L'\\');
|
|
if (s != std::wstring::npos && !MakeDirs(path.substr(0, s))) return false;
|
|
HANDLE h = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
|
if (h == INVALID_HANDLE_VALUE) return false;
|
|
DWORD put = 0;
|
|
bool ok = size == 0 || (WriteFile(h, data, static_cast<DWORD>(size), &put, nullptr) && put == size);
|
|
CloseHandle(h);
|
|
if (!ok) DeleteFileW(path.c_str());
|
|
return ok;
|
|
}
|
|
|
|
bool ReadFileAll(const std::wstring& path, std::string& out, size_t limit) {
|
|
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;
|
|
LARGE_INTEGER size{};
|
|
GetFileSizeEx(h, &size);
|
|
bool ok = size.QuadPart >= 0 && static_cast<ULONGLONG>(size.QuadPart) <= limit;
|
|
if (ok) {
|
|
out.resize(static_cast<size_t>(size.QuadPart));
|
|
DWORD got = 0;
|
|
ok = out.empty() || (ReadFile(h, &out[0], static_cast<DWORD>(out.size()), &got, nullptr) && got == out.size());
|
|
}
|
|
CloseHandle(h);
|
|
return ok;
|
|
}
|
|
|
|
// Deletes a folder and everything in it. Links (junctions, symlinks) are removed, never followed.
|
|
void DeleteTree(const std::wstring& dir) {
|
|
for (const Entry& e : List(dir)) {
|
|
std::wstring p = dir + L"\\" + e.name;
|
|
if (e.dir && !e.link) DeleteTree(p);
|
|
else if (e.dir) RemoveDirectoryW(p.c_str());
|
|
else DeleteFileW(p.c_str());
|
|
}
|
|
RemoveDirectoryW(dir.c_str());
|
|
}
|
|
|
|
// --- the naming key (art/namingkey*.nnk in the game's tanks) -------------------------------------------------------
|
|
// Content names map to folders by their '_'-separated prefixes (TREE = M_I_GLB, Global ...); animations add their
|
|
// fighting-stance class (CANIMCLASS = FS0 ...) as a folder. The rule was checked against the retail tanks: it gives the
|
|
// stored path of every b_c_* / b_i_* bitmap but 3, every m_c_* / a_c_* file, and 98% of all 31697 .asp/.prs/.dds/.sno.
|
|
struct NamingKey {
|
|
std::map<std::string, std::string> tree; // upper-case key -> folder
|
|
std::set<std::string> animClasses; // upper-case
|
|
std::string source; // where it was read, for the log
|
|
unsigned hash = 0;
|
|
|
|
void Parse(const std::string& text) {
|
|
size_t at = 0;
|
|
while (at < text.size()) {
|
|
size_t end = text.find('\n', at);
|
|
std::string line = text.substr(at, end == std::string::npos ? std::string::npos : end - at);
|
|
at = end == std::string::npos ? text.size() : end + 1;
|
|
size_t cut = std::min(line.find('#'), line.find("//"));
|
|
if (cut != std::string::npos) line = line.substr(0, cut);
|
|
size_t eq = line.find('=');
|
|
if (eq == std::string::npos) continue;
|
|
auto trim = [](std::string s) {
|
|
size_t a = s.find_first_not_of(" \t\r"), b = s.find_last_not_of(" \t\r");
|
|
return a == std::string::npos ? std::string() : s.substr(a, b - a + 1);
|
|
};
|
|
std::string key = trim(line.substr(0, eq));
|
|
for (char& c : key) c = static_cast<char>(toupper(static_cast<unsigned char>(c)));
|
|
std::vector<std::string> parts;
|
|
std::string rest = line.substr(eq + 1);
|
|
size_t p = 0;
|
|
while (p <= rest.size()) {
|
|
size_t c = rest.find(',', p);
|
|
parts.push_back(trim(rest.substr(p, c == std::string::npos ? std::string::npos : c - p)));
|
|
if (c == std::string::npos) break;
|
|
p = c + 1;
|
|
}
|
|
if (parts.empty() || parts[0].empty()) continue;
|
|
std::string k = parts[0];
|
|
for (char& c : k) c = static_cast<char>(toupper(static_cast<unsigned char>(c)));
|
|
if (key == "TREE" && parts.size() >= 2 && !parts[1].empty()) tree[k] = parts[1];
|
|
else if (key == "CANIMCLASS") animClasses.insert(k);
|
|
}
|
|
for (unsigned char c : text) hash = hash * 31 + c;
|
|
}
|
|
|
|
// Folder (backslashes, lower case, below the resource root) of a content name, e.g. b_i_glb_x -> art\bitmaps\items\global.
|
|
std::wstring Folder(const std::string& name) const {
|
|
std::vector<std::string> toks;
|
|
std::string up = name;
|
|
for (char& c : up) c = static_cast<char>(toupper(static_cast<unsigned char>(c)));
|
|
size_t p = 0;
|
|
while (true) {
|
|
size_t u = up.find('_', p);
|
|
toks.push_back(up.substr(p, u == std::string::npos ? std::string::npos : u - p));
|
|
if (u == std::string::npos) break;
|
|
p = u + 1;
|
|
}
|
|
std::string path = "art";
|
|
std::string key;
|
|
for (size_t k = 0; k < toks.size(); ++k) {
|
|
key += (k ? "_" : "") + toks[k];
|
|
auto it = tree.find(key);
|
|
if (it != tree.end()) {
|
|
path += "\\" + it->second;
|
|
} else if (toks[0] == "A" && animClasses.count(toks[k])) {
|
|
path += "\\" + toks[k];
|
|
} else if (k > 0) {
|
|
break;
|
|
}
|
|
}
|
|
for (char& c : path) c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
|
|
for (char& c : path)
|
|
if (c == '/') c = '\\';
|
|
return Widen(path, CP_ACP);
|
|
}
|
|
};
|
|
|
|
// Reads art/namingkey*.nnk out of a tank (DSg2Tank, layout in tools/tank.py). False if the tank has none.
|
|
bool ReadNamingKeyFromTank(const std::wstring& path, std::string& text, 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;
|
|
auto readAt = [&](ULONGLONG off, void* buf, DWORD n) {
|
|
LARGE_INTEGER at;
|
|
at.QuadPart = static_cast<LONGLONG>(off);
|
|
DWORD got = 0;
|
|
return SetFilePointerEx(h, at, nullptr, FILE_BEGIN) && ReadFile(h, buf, n, &got, nullptr) && got == n;
|
|
};
|
|
LARGE_INTEGER size{};
|
|
GetFileSizeEx(h, &size);
|
|
BYTE head[0x60] = {};
|
|
bool found = false;
|
|
if (readAt(0, head, sizeof(head)) && !memcmp(head, "DSg2Tank", 8)) {
|
|
DWORD dirset, fileset, indexSize, dataOffset;
|
|
memcpy(&dirset, head + 0x0c, 4);
|
|
memcpy(&fileset, head + 0x10, 4);
|
|
memcpy(&indexSize, head + 0x14, 4);
|
|
memcpy(&dataOffset, head + 0x18, 4);
|
|
memcpy(&priority, head + 0x34, 4);
|
|
std::vector<BYTE> idx;
|
|
if (dirset && fileset > dirset && indexSize >= fileset - dirset && indexSize < (64u << 20) &&
|
|
static_cast<ULONGLONG>(dirset) + indexSize <= static_cast<ULONGLONG>(size.QuadPart)) {
|
|
idx.resize(indexSize);
|
|
if (!readAt(dirset, idx.data(), indexSize)) idx.clear();
|
|
}
|
|
auto u32 = [&](size_t o) -> DWORD {
|
|
DWORD v = 0;
|
|
if (o + 4 <= idx.size()) memcpy(&v, &idx[o], 4);
|
|
return v;
|
|
};
|
|
auto u16 = [&](size_t o) -> WORD {
|
|
WORD v = 0;
|
|
if (o + 2 <= idx.size()) memcpy(&v, &idx[o], 2);
|
|
return v;
|
|
};
|
|
auto str = [&](size_t o, size_t n) { return o + n <= idx.size() ? std::string(reinterpret_cast<const char*>(&idx[o]), n) : std::string(); };
|
|
size_t fs = fileset - dirset;
|
|
DWORD nfiles = idx.empty() ? 0 : u32(fs);
|
|
for (DWORD i = 0; i < nfiles && i < 1000000 && !found; ++i) {
|
|
size_t o = fs + u32(fs + 4 + 4 * i);
|
|
if (o + 30 > idx.size()) break;
|
|
std::string name = str(o + 30, u16(o + 28));
|
|
std::string lname = name;
|
|
for (char& c : lname) c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
|
|
if (lname.compare(0, 9, "namingkey") != 0 || lname.size() < 4 || lname.substr(lname.size() - 4) != ".nnk") continue;
|
|
DWORD parent = u32(o), fsize = u32(o + 4), foff = u32(o + 8);
|
|
WORD format = u16(o + 24);
|
|
// the parent directory must be "art" directly below the root
|
|
WORD dlen = u16(parent + 16);
|
|
std::string dname = str(parent + 18, dlen);
|
|
for (char& c : dname) c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
|
|
if (dname != "art" || fsize > (16u << 20)) continue;
|
|
ULONGLONG base = static_cast<ULONGLONG>(dataOffset) + foff;
|
|
if (format == 0) {
|
|
text.resize(fsize);
|
|
found = fsize == 0 || readAt(base, &text[0], fsize);
|
|
} else {
|
|
size_t p = o + 30 + name.size() + 1;
|
|
p += (4 - (p - o) % 4) % 4;
|
|
DWORD chunk = u32(p + 4);
|
|
if (!chunk) continue;
|
|
DWORD nchunks = (fsize + chunk - 1) / chunk;
|
|
text.clear();
|
|
bool ok = true;
|
|
for (DWORD k = 0; k < nchunks && ok; ++k) {
|
|
size_t r = p + 8 + 16 * static_cast<size_t>(k);
|
|
DWORD usize = u32(r), csize = u32(r + 4), extra = u32(r + 8), coff = u32(r + 12);
|
|
if (usize > chunk || static_cast<ULONGLONG>(csize) + extra > (64u << 20)) {
|
|
ok = false;
|
|
break;
|
|
}
|
|
std::vector<uint8_t> raw(static_cast<size_t>(csize) + extra);
|
|
ok = raw.empty() || readAt(base + coff, raw.data(), static_cast<DWORD>(raw.size()));
|
|
if (!ok) break;
|
|
if (usize == csize) {
|
|
text.append(reinterpret_cast<const char*>(raw.data()), usize);
|
|
} else {
|
|
gltf::Bytes plain;
|
|
ok = gltf::Inflate(raw.data(), csize, usize, plain);
|
|
plain.insert(plain.end(), raw.begin() + csize, raw.end());
|
|
if (plain.size() > usize) plain.resize(usize);
|
|
text.append(reinterpret_cast<const char*>(plain.data()), plain.size());
|
|
}
|
|
}
|
|
found = ok && text.size() == fsize;
|
|
}
|
|
}
|
|
}
|
|
CloseHandle(h);
|
|
return found;
|
|
}
|
|
|
|
NamingKey LoadNamingKey(const std::vector<std::wstring>& resourceDirs) {
|
|
NamingKey nk;
|
|
std::string best;
|
|
int bestPrio = -1;
|
|
std::wstring bestPath;
|
|
for (int pass = 0; pass < 2 && best.empty(); ++pass) // the logic tanks first; the others only if needed
|
|
for (const std::wstring& dir : resourceDirs)
|
|
for (const Entry& e : List(dir)) {
|
|
if (e.dir || !IEndsWith(e.name, L".ds2res")) continue;
|
|
bool logic = Lower(e.name).find(L"logic") != std::wstring::npos;
|
|
if (logic != (pass == 0)) continue;
|
|
std::string text;
|
|
int prio = 0;
|
|
if (ReadNamingKeyFromTank(dir + L"\\" + e.name, text, prio) && prio > bestPrio) {
|
|
best = text;
|
|
bestPrio = prio;
|
|
bestPath = dir + L"\\" + e.name;
|
|
}
|
|
}
|
|
if (!best.empty()) {
|
|
nk.Parse(best);
|
|
nk.source = U8(bestPath);
|
|
}
|
|
return nk;
|
|
}
|
|
|
|
// --- conversion with a safety net ------------------------------------------------------------------------------------
|
|
struct Job {
|
|
const std::wstring* path;
|
|
const gltf::Options* opt;
|
|
bool animation;
|
|
const std::string* animName;
|
|
gltf::Result* out;
|
|
std::string* error;
|
|
bool ok;
|
|
};
|
|
|
|
void RunJob(Job* j) {
|
|
j->ok = j->animation ? gltf::ConvertAnimation(*j->path, *j->animName, *j->opt, *j->out, *j->error)
|
|
: gltf::ConvertMesh(*j->path, *j->opt, true, *j->out, *j->error);
|
|
}
|
|
|
|
// The converter checks its input, but a bug in it must not take the game down: structured exceptions (access
|
|
// violations ...) are caught here and the file is skipped.
|
|
bool RunJobSafely(Job* j, DWORD* code) {
|
|
__try {
|
|
RunJob(j);
|
|
return true;
|
|
} __except (*code = GetExceptionCode(), EXCEPTION_EXECUTE_HANDLER) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// --- manifest ----------------------------------------------------------------------------------------------------------
|
|
struct Record {
|
|
std::wstring key; // stamps of the source and its dependencies
|
|
std::vector<std::wstring> deps; // external files the glTF read (absolute)
|
|
std::vector<std::wstring> outs; // produced files, relative to the mod's cache folder
|
|
};
|
|
|
|
std::wstring KeyOf(const std::wstring& src, const std::vector<std::wstring>& deps) {
|
|
std::wstring k = Stamp(src);
|
|
for (const auto& d : deps) k += L"|" + Stamp(d);
|
|
return k;
|
|
}
|
|
|
|
std::map<std::wstring, Record> ReadManifest(const std::wstring& path, const std::string& header) {
|
|
std::map<std::wstring, Record> out;
|
|
std::string text;
|
|
if (!ReadFileAll(path, text, 16u << 20)) return out;
|
|
std::wstring w = Widen(text, CP_UTF8);
|
|
size_t at = 0;
|
|
bool first = true;
|
|
Record* cur = nullptr;
|
|
while (at < w.size()) {
|
|
size_t end = w.find(L'\n', at);
|
|
std::wstring line = w.substr(at, end == std::wstring::npos ? std::wstring::npos : end - at);
|
|
at = end == std::wstring::npos ? w.size() : end + 1;
|
|
if (!line.empty() && line.back() == L'\r') line.pop_back();
|
|
if (first) {
|
|
if (U8(line) != header) return out; // another converter version or naming key: rebuild everything
|
|
first = false;
|
|
continue;
|
|
}
|
|
if (line.size() < 2 || line[1] != L'\t') continue;
|
|
std::wstring rest = line.substr(2);
|
|
if (line[0] == L'S') {
|
|
size_t tab = rest.find(L'\t');
|
|
if (tab == std::wstring::npos) {
|
|
cur = nullptr;
|
|
continue;
|
|
}
|
|
cur = &out[Lower(rest.substr(0, tab))];
|
|
cur->key = rest.substr(tab + 1);
|
|
} else if (cur && line[0] == L'D') {
|
|
cur->deps.push_back(rest);
|
|
} else if (cur && line[0] == L'O') {
|
|
cur->outs.push_back(rest);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
void WriteManifest(const std::wstring& path, const std::string& header, const std::map<std::wstring, Record>& recs) {
|
|
std::wstring t = Widen(header, CP_UTF8) + L"\n";
|
|
for (const auto& kv : recs) {
|
|
t += L"S\t" + kv.first + L"\t" + kv.second.key + L"\n";
|
|
for (const auto& d : kv.second.deps) t += L"D\t" + d + L"\n";
|
|
for (const auto& o : kv.second.outs) t += L"O\t" + o + L"\n";
|
|
}
|
|
std::string u = U8(t);
|
|
WriteFileAll(path, u.data(), u.size());
|
|
}
|
|
|
|
// --- one mod -------------------------------------------------------------------------------------------------------------
|
|
struct Source {
|
|
std::wstring rel; // relative to the mod folder
|
|
std::wstring path; // absolute
|
|
};
|
|
|
|
void Collect(const std::wstring& dir, const std::wstring& rel, bool recurse, std::vector<Source>& out) {
|
|
for (const Entry& e : List(dir)) {
|
|
if (out.size() > 10000) return;
|
|
std::wstring r = rel.empty() ? e.name : rel + L"\\" + e.name;
|
|
if (e.dir) {
|
|
if (recurse && !e.link && e.name[0] != L'.') Collect(dir + L"\\" + e.name, r, recurse, out);
|
|
} else if (IsGltf(e.name)) {
|
|
out.push_back({r, dir + L"\\" + e.name});
|
|
}
|
|
}
|
|
}
|
|
|
|
std::string Stem(const std::wstring& rel) {
|
|
size_t s = rel.find_last_of(L'\\');
|
|
std::wstring f = rel.substr(s == std::wstring::npos ? 0 : s + 1);
|
|
f = f.substr(0, f.rfind(L'.'));
|
|
return Narrow(Lower(f), CP_ACP);
|
|
}
|
|
|
|
std::wstring RelDir(const std::wstring& rel) {
|
|
size_t s = rel.find_last_of(L'\\');
|
|
return s == std::wstring::npos ? std::wstring() : rel.substr(0, s);
|
|
}
|
|
|
|
// A material named like a texture (b_..., letters, digits, '_', '-') gives its texture that name: the way to fill the
|
|
// texture names a template sets in its [textures] block, which win over the mesh's own. Other materials get new
|
|
// names from the mesh name: b_xxx_gltf (one texture) or b_xxx_gltf_01, _02 ... (numbered by position).
|
|
bool IsTextureName(const std::string& s) {
|
|
if (s.size() < 3 || s.size() > 100 || s.compare(0, 2, "b_") != 0) return false;
|
|
for (char c : s)
|
|
if (!(isalnum(static_cast<unsigned char>(c)) || c == '_' || c == '-')) return false;
|
|
return true;
|
|
}
|
|
|
|
std::vector<std::string> TextureNames(const std::string& stem, const std::vector<std::string>& materials) {
|
|
std::string base = stem.compare(0, 2, "m_") == 0 ? "b" + stem.substr(1) : "b_" + stem;
|
|
std::vector<std::string> out;
|
|
for (size_t i = 0; i < materials.size(); ++i) {
|
|
std::string m = materials[i];
|
|
for (char& c : m) c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
|
|
char suffix[16];
|
|
sprintf_s(suffix, materials.size() == 1 ? "_gltf" : "_gltf_%02u", static_cast<unsigned>(i + 1));
|
|
out.push_back(IsTextureName(m) ? m : base + suffix);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
struct Context {
|
|
const NamingKey* nk;
|
|
};
|
|
|
|
std::wstring Placed(const Context& cx, const std::string& name, const wchar_t* ext) {
|
|
return cx.nk->Folder(name) + L"\\" + Widen(name, CP_ACP) + ext;
|
|
}
|
|
|
|
// Converts one source; returns the files it produced (relative paths) or false (nothing written).
|
|
bool ConvertSource(const Context& cx, const std::wstring& modName, const Source& src, const std::wstring& cacheDir, Record& rec) {
|
|
std::string stem = Stem(src.rel);
|
|
std::wstring relDir = RelDir(src.rel);
|
|
bool inArt = _wcsnicmp(src.rel.c_str(), L"art\\", 4) == 0;
|
|
bool animation = stem.compare(0, 2, "a_") == 0;
|
|
std::wstring target = inArt ? relDir + L"\\" + Widen(stem, CP_ACP) + (animation ? L".prs" : L".asp")
|
|
: Placed(cx, stem, animation ? L".prs" : L".asp");
|
|
gltf::Options opt;
|
|
opt.meshName = stem;
|
|
opt.animFallbackFirst = true;
|
|
opt.textureNamer = [&stem](const std::vector<std::string>& m) { return TextureNames(stem, m); };
|
|
gltf::Result res;
|
|
std::string err;
|
|
Job job{&src.path, &opt, animation, &stem, &res, &err, false};
|
|
DWORD code = 0;
|
|
ULONGLONG t0 = GetTickCount64();
|
|
bool ran = RunJobSafely(&job, &code);
|
|
rec.deps = res.dependencies;
|
|
if (!ran) {
|
|
ds2::log().Write("glTF %s\\%s: SKIPPED - the converter crashed (exception %08lX); the original asset stays",
|
|
U8(modName).c_str(), U8(src.rel).c_str(), code);
|
|
return false;
|
|
}
|
|
if (!job.ok) {
|
|
ds2::log().Write("glTF %s\\%s: SKIPPED - %s; the original asset stays", U8(modName).c_str(), U8(src.rel).c_str(), err.c_str());
|
|
return false;
|
|
}
|
|
std::vector<std::pair<std::wstring, const gltf::Bytes*>> files;
|
|
if (animation) {
|
|
files.push_back({target, &res.anims.at(0).data});
|
|
} else {
|
|
files.push_back({target, &res.asp});
|
|
for (const auto& t : res.textures) files.push_back({Placed(cx, t.name, L".dds"), &t.data});
|
|
for (const auto& a : res.anims) {
|
|
std::string n = a.name;
|
|
for (char& c : n) c = static_cast<char>(tolower(static_cast<unsigned char>(c)));
|
|
if (n.compare(0, 2, "a_") != 0) {
|
|
ds2::log().Write(" animation \"%s\" not installed: name it after the .prs it replaces (a_...)", a.name.c_str());
|
|
continue;
|
|
}
|
|
files.push_back({Placed(cx, n, L".prs"), &a.data});
|
|
}
|
|
}
|
|
for (const auto& f : files) {
|
|
if (!WriteFileAll(cacheDir + L"\\" + f.first, f.second->data(), f.second->size())) {
|
|
ds2::log().Write("glTF %s\\%s: cannot write %s (error %lu) - skipped", U8(modName).c_str(), U8(src.rel).c_str(),
|
|
U8(cacheDir + L"\\" + f.first).c_str(), GetLastError());
|
|
for (const auto& g : files) DeleteFileW((cacheDir + L"\\" + g.first).c_str());
|
|
return false;
|
|
}
|
|
rec.outs.push_back(f.first);
|
|
}
|
|
ds2::log().Write("glTF %s\\%s -> %s (%s, %u texture(s), %u animation(s), %llu ms)", U8(modName).c_str(), U8(src.rel).c_str(),
|
|
U8(target).c_str(), animation ? "animation" : res.skinned ? "skinned mesh" : "static mesh",
|
|
static_cast<unsigned>(res.textures.size()), static_cast<unsigned>(animation ? 1 : res.anims.size()),
|
|
GetTickCount64() - t0);
|
|
for (size_t i = 1; i < files.size(); ++i) ds2::log().Write(" + %s", U8(files[i].first).c_str());
|
|
for (const auto& w : res.warnings) ds2::log().Write(" note: %s", w.c_str());
|
|
return true;
|
|
}
|
|
|
|
// Deletes every file below dir that is not in keep (lower-case relative paths); removes empty folders.
|
|
void Sweep(const std::wstring& dir, const std::wstring& rel, const std::set<std::wstring>& keep) {
|
|
for (const Entry& e : List(dir)) {
|
|
std::wstring p = dir + L"\\" + e.name, r = rel.empty() ? e.name : rel + L"\\" + e.name;
|
|
if (e.dir && !e.link) {
|
|
Sweep(p, r, keep);
|
|
RemoveDirectoryW(p.c_str()); // only succeeds when empty
|
|
} else if (e.dir) {
|
|
RemoveDirectoryW(p.c_str());
|
|
} else if (!keep.count(Lower(r))) {
|
|
DeleteFileW(p.c_str());
|
|
}
|
|
}
|
|
}
|
|
|
|
// The cache root of one Mods folder: <Mods>\.cache\gltf, or a folder in %LOCALAPPDATA% if Mods cannot be written.
|
|
std::wstring CacheRoot(const std::wstring& modsRoot) {
|
|
std::wstring root = modsRoot + L"\\.cache\\gltf";
|
|
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\\gltf-cache\\" + sub;
|
|
return MakeDirs(root) ? root : std::wstring();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::vector<std::string> Prepare(const std::vector<ModInfo>& mods, const std::vector<std::wstring>& resourceDirs,
|
|
const std::vector<std::wstring>& keepCaches) {
|
|
std::set<std::wstring> keepNames; // lower-case cache names (folder and manifest) of mods left out on purpose
|
|
for (const auto& n : keepCaches) {
|
|
keepNames.insert(Lower(n));
|
|
keepNames.insert(Lower(n + L".txt"));
|
|
}
|
|
std::vector<std::string> result(mods.size());
|
|
// sources per mod: everything below a mod folder; for the Mods folder itself (tanks only) its top-level files
|
|
std::vector<std::vector<Source>> sources(mods.size());
|
|
bool any = false;
|
|
for (size_t i = 0; i < mods.size(); ++i) {
|
|
Collect(mods[i].dir, L"", !mods[i].modsFolder, sources[i]);
|
|
// animation files (a_*.glb) last: what they make wins over an animation of the same name inside a mesh's glTF
|
|
std::stable_partition(sources[i].begin(), sources[i].end(), [](const Source& s) { return Stem(s.rel).compare(0, 2, "a_") != 0; });
|
|
any = any || !sources[i].empty();
|
|
}
|
|
// group the mods by their Mods folder (Broken World has two); the "(Mods folder)" entry (tanks and glTF files
|
|
// straight in Mods\) has no subfolders to mount and its dir is the Mods folder itself
|
|
std::map<std::wstring, std::pair<std::wstring, std::vector<size_t>>> byRoot;
|
|
for (size_t i = 0; i < mods.size(); ++i) {
|
|
const std::wstring& dir = mods[i].dir;
|
|
std::wstring root = mods[i].modsFolder ? dir : dir.substr(0, dir.find_last_of(L'\\'));
|
|
auto& g = byRoot[Lower(root)];
|
|
g.first = root;
|
|
g.second.push_back(i);
|
|
}
|
|
NamingKey nk;
|
|
if (any) {
|
|
nk = LoadNamingKey(resourceDirs);
|
|
if (nk.tree.empty()) {
|
|
ds2::log().Write("glTF: no naming key (art\\namingkey*.nnk) found in the game's tanks - glTF files skipped");
|
|
return result;
|
|
}
|
|
ds2::log().Write("glTF: naming key from %s (%u folders)", nk.source.c_str(), static_cast<unsigned>(nk.tree.size()));
|
|
}
|
|
char header[96];
|
|
sprintf_s(header, "%s %08x", kCacheVersion, nk.hash);
|
|
Context cx{&nk};
|
|
for (const auto& kv : byRoot) {
|
|
const std::wstring& modsRoot = kv.second.first;
|
|
const std::vector<size_t>& members = kv.second.second;
|
|
bool here = false;
|
|
for (size_t i : members) here = here || !sources[i].empty();
|
|
std::wstring root = modsRoot + L"\\.cache\\gltf";
|
|
if (!here) { // no glTF files (any more): drop an old cache
|
|
bool kept = false; // ... unless it holds the cache of a mod that is left out of this start on purpose
|
|
for (const Entry& e : List(root)) kept = kept || keepNames.count(Lower(e.name)) != 0;
|
|
if (!kept && GetFileAttributesW(root.c_str()) != INVALID_FILE_ATTRIBUTES) {
|
|
DeleteTree(root);
|
|
RemoveDirectoryW((modsRoot + L"\\.cache").c_str());
|
|
ds2::log().Write("glTF: no glTF files in %s - removed its cache", U8(modsRoot).c_str());
|
|
}
|
|
continue;
|
|
}
|
|
root = CacheRoot(modsRoot);
|
|
if (root.empty()) {
|
|
ds2::log().Write("glTF: no writable cache folder for %s - glTF files skipped", U8(modsRoot).c_str());
|
|
continue;
|
|
}
|
|
std::set<std::wstring> liveNames;
|
|
for (size_t i : members) {
|
|
if (sources[i].empty()) continue;
|
|
const ModInfo& m = mods[i];
|
|
std::wstring cacheDir = root + L"\\" + m.name, manifest = root + L"\\" + m.name + L".txt";
|
|
// The manifest is valid for the same converter, naming key and set of glTF files: when files are added or
|
|
// removed, the whole mod is converted again (two sources may make the same file; the later one wins).
|
|
unsigned setHash = 2166136261u;
|
|
for (const Source& s : sources[i])
|
|
for (wchar_t c : Lower(s.rel) + L"\n") setHash = (setHash ^ static_cast<unsigned>(c)) * 16777619u;
|
|
char modHeader[160];
|
|
sprintf_s(modHeader, "%s %08x", header, setHash);
|
|
std::map<std::wstring, Record> old = ReadManifest(manifest, modHeader), now;
|
|
std::set<std::wstring> keep;
|
|
std::map<std::wstring, std::wstring> owner; // output -> source, to report clashes
|
|
unsigned converted = 0, current = 0, failed = 0;
|
|
bool touched = false;
|
|
for (const Source& s : sources[i]) {
|
|
std::wstring lrel = Lower(s.rel);
|
|
auto it = old.find(lrel);
|
|
bool fresh = false;
|
|
if (it != old.end() && it->second.key == KeyOf(s.path, it->second.deps)) {
|
|
fresh = true;
|
|
for (const auto& o : it->second.outs)
|
|
fresh = fresh && GetFileAttributesW((cacheDir + L"\\" + o).c_str()) != INVALID_FILE_ATTRIBUTES;
|
|
}
|
|
Record rec;
|
|
if (fresh) {
|
|
rec = it->second;
|
|
++current;
|
|
} else {
|
|
if (!touched) { // a crash from here on must not leave a manifest that vouches for half-written files
|
|
DeleteFileW(manifest.c_str());
|
|
touched = true;
|
|
}
|
|
if (it != old.end())
|
|
for (const auto& o : it->second.outs) DeleteFileW((cacheDir + L"\\" + o).c_str());
|
|
if (ConvertSource(cx, m.name, s, cacheDir, rec)) ++converted;
|
|
else ++failed;
|
|
rec.key = KeyOf(s.path, rec.deps);
|
|
}
|
|
for (const auto& o : rec.outs) {
|
|
auto prev = owner.find(Lower(o));
|
|
if (prev != owner.end() && prev->second != s.rel)
|
|
ds2::log().Write("glTF %s: %s and %s both make %s - the second wins", U8(m.name).c_str(),
|
|
U8(prev->second).c_str(), U8(s.rel).c_str(), U8(o).c_str());
|
|
owner[Lower(o)] = s.rel;
|
|
keep.insert(Lower(o));
|
|
}
|
|
if (!rec.outs.empty()) now[lrel] = rec;
|
|
}
|
|
Sweep(cacheDir, L"", keep);
|
|
ds2::log().Write("glTF %s: %u converted, %u up to date, %u skipped (cache %s)", U8(m.name).c_str(), converted,
|
|
current, failed, U8(cacheDir).c_str());
|
|
if (!keep.empty()) { // otherwise the cache folder and manifest go in the clean-up below
|
|
WriteManifest(manifest, modHeader, now);
|
|
liveNames.insert(Lower(m.name));
|
|
liveNames.insert(Lower(m.name + L".txt"));
|
|
std::string a = Narrow(cacheDir, CP_ACP);
|
|
if (Widen(a, CP_ACP) != cacheDir) ds2::log().Write("glTF %s: the cache path is not ANSI - not mounted", U8(m.name).c_str());
|
|
else result[i] = a;
|
|
}
|
|
}
|
|
// caches of mods that are gone or have no glTF files any more
|
|
for (const Entry& e : List(root)) {
|
|
if (liveNames.count(Lower(e.name)) || keepNames.count(Lower(e.name)) || e.name == L"write.test") continue;
|
|
std::wstring p = root + L"\\" + e.name;
|
|
if (e.dir && !e.link) DeleteTree(p);
|
|
else if (e.dir) RemoveDirectoryW(p.c_str());
|
|
else DeleteFileW(p.c_str());
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
} // namespace gltfmods
|