mirror of
https://github.com/Kelsidavis/WoWee.git
synced 2026-03-22 23:30:14 +00:00
Replace MPQ runtime with loose file asset system
Extract assets from MPQ archives into organized loose files indexed by manifest.json, enabling fully parallel reads without StormLib serialization. Add asset_extract and blp_convert tools, PNG texture override support.
This commit is contained in:
parent
5fda1a3157
commit
aa16a687c2
16 changed files with 1427 additions and 101 deletions
380
tools/asset_extract/extractor.cpp
Normal file
380
tools/asset_extract/extractor.cpp
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
#include "extractor.hpp"
|
||||
#include "path_mapper.hpp"
|
||||
#include "manifest_writer.hpp"
|
||||
|
||||
#include <StormLib.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#ifndef INVALID_HANDLE_VALUE
|
||||
#define INVALID_HANDLE_VALUE ((HANDLE)(long long)-1)
|
||||
#endif
|
||||
|
||||
namespace wowee {
|
||||
namespace tools {
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
// Archive descriptor for priority-based loading
|
||||
struct ArchiveDesc {
|
||||
std::string path;
|
||||
int priority;
|
||||
};
|
||||
|
||||
static std::string toLowerStr(std::string s) {
|
||||
std::transform(s.begin(), s.end(), s.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return s;
|
||||
}
|
||||
|
||||
static std::string normalizeWowPath(const std::string& path) {
|
||||
std::string n = path;
|
||||
std::replace(n.begin(), n.end(), '/', '\\');
|
||||
std::transform(n.begin(), n.end(), n.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return n;
|
||||
}
|
||||
|
||||
// Discover archive files in the same priority order as MPQManager
|
||||
static std::vector<ArchiveDesc> discoverArchives(const std::string& mpqDir) {
|
||||
std::vector<ArchiveDesc> result;
|
||||
|
||||
auto tryAdd = [&](const std::string& name, int prio) {
|
||||
std::string fullPath = mpqDir + "/" + name;
|
||||
if (fs::exists(fullPath)) {
|
||||
result.push_back({fullPath, prio});
|
||||
}
|
||||
};
|
||||
|
||||
// Base archives (priority 100)
|
||||
tryAdd("common.MPQ", 100);
|
||||
tryAdd("common-2.MPQ", 100);
|
||||
tryAdd("expansion.MPQ", 100);
|
||||
tryAdd("lichking.MPQ", 100);
|
||||
|
||||
// Patch archives (priority 150-500)
|
||||
tryAdd("patch.MPQ", 150);
|
||||
tryAdd("patch-2.MPQ", 200);
|
||||
tryAdd("patch-3.MPQ", 300);
|
||||
tryAdd("patch-4.MPQ", 400);
|
||||
tryAdd("patch-5.MPQ", 500);
|
||||
|
||||
// Letter patches (priority 800-925)
|
||||
for (char c = 'a'; c <= 'z'; ++c) {
|
||||
std::string lower = std::string("patch-") + c + ".mpq";
|
||||
std::string upper = std::string("Patch-") + static_cast<char>(std::toupper(c)) + ".mpq";
|
||||
int prioLower = 800 + (c - 'a');
|
||||
int prioUpper = 900 + (c - 'a');
|
||||
tryAdd(lower, prioLower);
|
||||
tryAdd(upper, prioUpper);
|
||||
}
|
||||
|
||||
// Locale archives
|
||||
tryAdd("enUS/backup-enUS.MPQ", 230);
|
||||
tryAdd("enUS/base-enUS.MPQ", 235);
|
||||
tryAdd("enUS/speech-enUS.MPQ", 240);
|
||||
tryAdd("enUS/expansion-speech-enUS.MPQ", 245);
|
||||
tryAdd("enUS/expansion-locale-enUS.MPQ", 246);
|
||||
tryAdd("enUS/lichking-speech-enUS.MPQ", 248);
|
||||
tryAdd("enUS/lichking-locale-enUS.MPQ", 249);
|
||||
tryAdd("enUS/locale-enUS.MPQ", 250);
|
||||
tryAdd("enUS/patch-enUS.MPQ", 450);
|
||||
tryAdd("enUS/patch-enUS-2.MPQ", 460);
|
||||
tryAdd("enUS/patch-enUS-3.MPQ", 470);
|
||||
|
||||
// Sort by priority so highest-priority archives are last
|
||||
// (we'll iterate highest-prio first when extracting)
|
||||
std::sort(result.begin(), result.end(),
|
||||
[](const ArchiveDesc& a, const ArchiveDesc& b) { return a.priority < b.priority; });
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool Extractor::enumerateFiles(const Options& opts,
|
||||
std::vector<std::string>& outFiles) {
|
||||
// Open all archives, enumerate files from highest priority to lowest.
|
||||
// Use a set to deduplicate (highest-priority version wins).
|
||||
auto archives = discoverArchives(opts.mpqDir);
|
||||
if (archives.empty()) {
|
||||
std::cerr << "No MPQ archives found in: " << opts.mpqDir << "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::cout << "Found " << archives.size() << " MPQ archives\n";
|
||||
|
||||
// Enumerate from highest priority first so first-seen files win
|
||||
std::set<std::string> seenNormalized;
|
||||
std::vector<std::pair<std::string, std::string>> fileList; // (original name, archive path)
|
||||
|
||||
for (auto it = archives.rbegin(); it != archives.rend(); ++it) {
|
||||
HANDLE hMpq = nullptr;
|
||||
if (!SFileOpenArchive(it->path.c_str(), 0, 0, &hMpq)) {
|
||||
std::cerr << " Failed to open: " << it->path << "\n";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opts.verbose) {
|
||||
std::cout << " Scanning: " << it->path << " (priority " << it->priority << ")\n";
|
||||
}
|
||||
|
||||
SFILE_FIND_DATA findData;
|
||||
HANDLE hFind = SFileFindFirstFile(hMpq, "*", &findData, nullptr);
|
||||
if (hFind) {
|
||||
do {
|
||||
std::string fileName = findData.cFileName;
|
||||
// Skip internal listfile/attributes
|
||||
if (fileName == "(listfile)" || fileName == "(attributes)" ||
|
||||
fileName == "(signature)" || fileName == "(patch_metadata)") {
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string norm = normalizeWowPath(fileName);
|
||||
if (seenNormalized.insert(norm).second) {
|
||||
// First time seeing this file — this is the highest-priority version
|
||||
outFiles.push_back(fileName);
|
||||
}
|
||||
} while (SFileFindNextFile(hFind, &findData));
|
||||
SFileFindClose(hFind);
|
||||
}
|
||||
|
||||
SFileCloseArchive(hMpq);
|
||||
}
|
||||
|
||||
std::cout << "Enumerated " << outFiles.size() << " unique files\n";
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Extractor::run(const Options& opts) {
|
||||
auto startTime = std::chrono::steady_clock::now();
|
||||
|
||||
// Enumerate all unique files across all archives
|
||||
std::vector<std::string> files;
|
||||
if (!enumerateFiles(opts, files)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (files.empty()) {
|
||||
std::cerr << "No files to extract\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
std::error_code ec;
|
||||
fs::create_directories(opts.outputDir, ec);
|
||||
if (ec) {
|
||||
std::cerr << "Failed to create output directory: " << ec.message() << "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
auto archives = discoverArchives(opts.mpqDir);
|
||||
|
||||
// Determine thread count
|
||||
int numThreads = opts.threads;
|
||||
if (numThreads <= 0) {
|
||||
numThreads = static_cast<int>(std::thread::hardware_concurrency());
|
||||
if (numThreads <= 0) numThreads = 4;
|
||||
}
|
||||
|
||||
Stats stats;
|
||||
std::mutex manifestMutex;
|
||||
std::vector<ManifestWriter::FileEntry> manifestEntries;
|
||||
|
||||
// Partition files across threads
|
||||
std::atomic<size_t> fileIndex{0};
|
||||
size_t totalFiles = files.size();
|
||||
|
||||
auto workerFn = [&]() {
|
||||
// Each thread opens ALL archives independently (StormLib is not thread-safe per handle).
|
||||
// Sorted highest-priority last, so we iterate in reverse to find the winning version.
|
||||
struct ThreadArchive {
|
||||
HANDLE handle;
|
||||
int priority;
|
||||
};
|
||||
std::vector<ThreadArchive> threadHandles;
|
||||
for (const auto& ad : archives) {
|
||||
HANDLE h = nullptr;
|
||||
if (SFileOpenArchive(ad.path.c_str(), 0, 0, &h)) {
|
||||
threadHandles.push_back({h, ad.priority});
|
||||
}
|
||||
}
|
||||
if (threadHandles.empty()) {
|
||||
std::cerr << "Worker thread: failed to open any archives\n";
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
size_t idx = fileIndex.fetch_add(1);
|
||||
if (idx >= totalFiles) break;
|
||||
|
||||
const std::string& wowPath = files[idx];
|
||||
std::string normalized = normalizeWowPath(wowPath);
|
||||
|
||||
// Map to new filesystem path
|
||||
std::string mappedPath = PathMapper::mapPath(wowPath);
|
||||
std::string fullOutputPath = opts.outputDir + "/" + mappedPath;
|
||||
|
||||
// Search archives in reverse priority order (highest priority first)
|
||||
HANDLE hFile = nullptr;
|
||||
for (auto it = threadHandles.rbegin(); it != threadHandles.rend(); ++it) {
|
||||
if (SFileOpenFileEx(it->handle, wowPath.c_str(), 0, &hFile)) {
|
||||
break;
|
||||
}
|
||||
hFile = nullptr;
|
||||
}
|
||||
if (!hFile) {
|
||||
stats.filesFailed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
DWORD fileSize = SFileGetFileSize(hFile, nullptr);
|
||||
if (fileSize == SFILE_INVALID_SIZE || fileSize == 0) {
|
||||
SFileCloseFile(hFile);
|
||||
stats.filesSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> data(fileSize);
|
||||
DWORD bytesRead = 0;
|
||||
if (!SFileReadFile(hFile, data.data(), fileSize, &bytesRead, nullptr)) {
|
||||
SFileCloseFile(hFile);
|
||||
stats.filesFailed++;
|
||||
continue;
|
||||
}
|
||||
SFileCloseFile(hFile);
|
||||
data.resize(bytesRead);
|
||||
|
||||
// Create output directory
|
||||
fs::path outPath(fullOutputPath);
|
||||
fs::create_directories(outPath.parent_path(), ec);
|
||||
|
||||
// Write file
|
||||
std::ofstream out(fullOutputPath, std::ios::binary);
|
||||
if (!out.is_open()) {
|
||||
stats.filesFailed++;
|
||||
continue;
|
||||
}
|
||||
out.write(reinterpret_cast<const char*>(data.data()), data.size());
|
||||
out.close();
|
||||
|
||||
// Compute CRC32
|
||||
uint32_t crc = ManifestWriter::computeCRC32(data.data(), data.size());
|
||||
|
||||
// Add manifest entry
|
||||
ManifestWriter::FileEntry entry;
|
||||
entry.wowPath = normalized;
|
||||
entry.filesystemPath = mappedPath;
|
||||
entry.size = data.size();
|
||||
entry.crc32 = crc;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(manifestMutex);
|
||||
manifestEntries.push_back(std::move(entry));
|
||||
}
|
||||
|
||||
stats.filesExtracted++;
|
||||
stats.bytesExtracted += data.size();
|
||||
|
||||
// Progress
|
||||
uint64_t done = stats.filesExtracted.load();
|
||||
if (done % 1000 == 0) {
|
||||
std::cout << "\r Extracted " << done << " / " << totalFiles << " files..."
|
||||
<< std::flush;
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& th : threadHandles) {
|
||||
SFileCloseArchive(th.handle);
|
||||
}
|
||||
};
|
||||
|
||||
std::cout << "Extracting " << totalFiles << " files using " << numThreads << " threads...\n";
|
||||
|
||||
std::vector<std::thread> threads;
|
||||
for (int i = 0; i < numThreads; ++i) {
|
||||
threads.emplace_back(workerFn);
|
||||
}
|
||||
for (auto& t : threads) {
|
||||
t.join();
|
||||
}
|
||||
|
||||
std::cout << "\r Extracted " << stats.filesExtracted.load() << " files ("
|
||||
<< stats.bytesExtracted.load() / (1024 * 1024) << " MB), "
|
||||
<< stats.filesSkipped.load() << " skipped, "
|
||||
<< stats.filesFailed.load() << " failed\n";
|
||||
|
||||
// Sort manifest entries for deterministic output
|
||||
std::sort(manifestEntries.begin(), manifestEntries.end(),
|
||||
[](const ManifestWriter::FileEntry& a, const ManifestWriter::FileEntry& b) {
|
||||
return a.wowPath < b.wowPath;
|
||||
});
|
||||
|
||||
// Write manifest
|
||||
std::string manifestPath = opts.outputDir + "/manifest.json";
|
||||
// basePath is "." since manifest sits inside the output directory
|
||||
if (!ManifestWriter::write(manifestPath, ".", manifestEntries)) {
|
||||
std::cerr << "Failed to write manifest: " << manifestPath << "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
std::cout << "Wrote manifest: " << manifestPath << " (" << manifestEntries.size() << " entries)\n";
|
||||
|
||||
// Verification pass
|
||||
if (opts.verify) {
|
||||
std::cout << "Verifying extracted files...\n";
|
||||
uint64_t verified = 0, verifyFailed = 0;
|
||||
for (const auto& entry : manifestEntries) {
|
||||
std::string fsPath = opts.outputDir + "/" + entry.filesystemPath;
|
||||
std::ifstream f(fsPath, std::ios::binary | std::ios::ate);
|
||||
if (!f.is_open()) {
|
||||
std::cerr << " MISSING: " << fsPath << "\n";
|
||||
verifyFailed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
auto size = f.tellg();
|
||||
if (static_cast<uint64_t>(size) != entry.size) {
|
||||
std::cerr << " SIZE MISMATCH: " << fsPath << " (expected "
|
||||
<< entry.size << ", got " << size << ")\n";
|
||||
verifyFailed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
f.seekg(0);
|
||||
std::vector<uint8_t> data(static_cast<size_t>(size));
|
||||
f.read(reinterpret_cast<char*>(data.data()), size);
|
||||
|
||||
uint32_t crc = ManifestWriter::computeCRC32(data.data(), data.size());
|
||||
if (crc != entry.crc32) {
|
||||
std::cerr << " CRC MISMATCH: " << fsPath << "\n";
|
||||
verifyFailed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
verified++;
|
||||
}
|
||||
std::cout << "Verified " << verified << " files";
|
||||
if (verifyFailed > 0) {
|
||||
std::cout << " (" << verifyFailed << " FAILED)";
|
||||
}
|
||||
std::cout << "\n";
|
||||
}
|
||||
|
||||
auto elapsed = std::chrono::steady_clock::now() - startTime;
|
||||
auto secs = std::chrono::duration_cast<std::chrono::seconds>(elapsed).count();
|
||||
std::cout << "Done in " << secs / 60 << "m " << secs % 60 << "s\n";
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace tools
|
||||
} // namespace wowee
|
||||
43
tools/asset_extract/extractor.hpp
Normal file
43
tools/asset_extract/extractor.hpp
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
namespace wowee {
|
||||
namespace tools {
|
||||
|
||||
/**
|
||||
* Extraction pipeline: MPQ archives → loose files + manifest
|
||||
*/
|
||||
class Extractor {
|
||||
public:
|
||||
struct Options {
|
||||
std::string mpqDir; // Path to WoW Data directory
|
||||
std::string outputDir; // Output directory for extracted assets
|
||||
int threads = 0; // 0 = auto-detect
|
||||
bool verify = false; // CRC32 verify after extraction
|
||||
bool verbose = false; // Verbose logging
|
||||
};
|
||||
|
||||
struct Stats {
|
||||
std::atomic<uint64_t> filesExtracted{0};
|
||||
std::atomic<uint64_t> bytesExtracted{0};
|
||||
std::atomic<uint64_t> filesSkipped{0};
|
||||
std::atomic<uint64_t> filesFailed{0};
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the extraction pipeline
|
||||
* @return true on success
|
||||
*/
|
||||
static bool run(const Options& opts);
|
||||
|
||||
private:
|
||||
static bool enumerateFiles(const Options& opts,
|
||||
std::vector<std::string>& outFiles);
|
||||
};
|
||||
|
||||
} // namespace tools
|
||||
} // namespace wowee
|
||||
62
tools/asset_extract/main.cpp
Normal file
62
tools/asset_extract/main.cpp
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
#include "extractor.hpp"
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
#include <cstring>
|
||||
|
||||
static void printUsage(const char* prog) {
|
||||
std::cout << "Usage: " << prog << " --mpq-dir <path> --output <path> [options]\n"
|
||||
<< "\n"
|
||||
<< "Extract WoW MPQ archives to organized loose files with manifest.\n"
|
||||
<< "\n"
|
||||
<< "Required:\n"
|
||||
<< " --mpq-dir <path> Path to WoW Data directory containing MPQ files\n"
|
||||
<< " --output <path> Output directory for extracted assets\n"
|
||||
<< "\n"
|
||||
<< "Options:\n"
|
||||
<< " --verify CRC32 verify all extracted files\n"
|
||||
<< " --threads <N> Number of extraction threads (default: auto)\n"
|
||||
<< " --verbose Verbose output\n"
|
||||
<< " --help Show this help\n";
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
wowee::tools::Extractor::Options opts;
|
||||
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
if (std::strcmp(argv[i], "--mpq-dir") == 0 && i + 1 < argc) {
|
||||
opts.mpqDir = argv[++i];
|
||||
} else if (std::strcmp(argv[i], "--output") == 0 && i + 1 < argc) {
|
||||
opts.outputDir = argv[++i];
|
||||
} else if (std::strcmp(argv[i], "--threads") == 0 && i + 1 < argc) {
|
||||
opts.threads = std::atoi(argv[++i]);
|
||||
} else if (std::strcmp(argv[i], "--verify") == 0) {
|
||||
opts.verify = true;
|
||||
} else if (std::strcmp(argv[i], "--verbose") == 0) {
|
||||
opts.verbose = true;
|
||||
} else if (std::strcmp(argv[i], "--help") == 0 || std::strcmp(argv[i], "-h") == 0) {
|
||||
printUsage(argv[0]);
|
||||
return 0;
|
||||
} else {
|
||||
std::cerr << "Unknown option: " << argv[i] << "\n";
|
||||
printUsage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.mpqDir.empty() || opts.outputDir.empty()) {
|
||||
std::cerr << "Error: --mpq-dir and --output are required\n\n";
|
||||
printUsage(argv[0]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << "=== Wowee Asset Extractor ===\n";
|
||||
std::cout << "MPQ directory: " << opts.mpqDir << "\n";
|
||||
std::cout << "Output: " << opts.outputDir << "\n";
|
||||
|
||||
if (!wowee::tools::Extractor::run(opts)) {
|
||||
std::cerr << "Extraction failed!\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
67
tools/asset_extract/manifest_writer.cpp
Normal file
67
tools/asset_extract/manifest_writer.cpp
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
#include "manifest_writer.hpp"
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <zlib.h>
|
||||
|
||||
namespace wowee {
|
||||
namespace tools {
|
||||
|
||||
uint32_t ManifestWriter::computeCRC32(const uint8_t* data, size_t size) {
|
||||
return static_cast<uint32_t>(::crc32(::crc32(0L, Z_NULL, 0), data, static_cast<uInt>(size)));
|
||||
}
|
||||
|
||||
bool ManifestWriter::write(const std::string& outputPath,
|
||||
const std::string& basePath,
|
||||
const std::vector<FileEntry>& entries) {
|
||||
// Write JSON manually to avoid pulling nlohmann/json into the tool
|
||||
// (though it would also work fine). This keeps the tool dependency-light.
|
||||
std::ofstream file(outputPath);
|
||||
if (!file.is_open()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
file << "{\n";
|
||||
file << " \"version\": 1,\n";
|
||||
file << " \"basePath\": \"" << basePath << "\",\n";
|
||||
file << " \"fileCount\": " << entries.size() << ",\n";
|
||||
file << " \"entries\": {\n";
|
||||
|
||||
for (size_t i = 0; i < entries.size(); ++i) {
|
||||
const auto& e = entries[i];
|
||||
|
||||
// Escape backslashes in WoW path for JSON
|
||||
std::string escapedKey;
|
||||
for (char c : e.wowPath) {
|
||||
if (c == '\\') escapedKey += "\\\\";
|
||||
else if (c == '"') escapedKey += "\\\"";
|
||||
else escapedKey += c;
|
||||
}
|
||||
|
||||
std::string escapedPath;
|
||||
for (char c : e.filesystemPath) {
|
||||
if (c == '\\') escapedPath += "\\\\";
|
||||
else if (c == '"') escapedPath += "\\\"";
|
||||
else escapedPath += c;
|
||||
}
|
||||
|
||||
// CRC32 as hex
|
||||
std::ostringstream hexCrc;
|
||||
hexCrc << std::hex << std::setfill('0') << std::setw(8) << e.crc32;
|
||||
|
||||
file << " \"" << escapedKey << "\": {\"p\": \"" << escapedPath
|
||||
<< "\", \"s\": " << e.size
|
||||
<< ", \"h\": \"" << hexCrc.str() << "\"}";
|
||||
|
||||
if (i + 1 < entries.size()) file << ",";
|
||||
file << "\n";
|
||||
}
|
||||
|
||||
file << " }\n";
|
||||
file << "}\n";
|
||||
|
||||
return file.good();
|
||||
}
|
||||
|
||||
} // namespace tools
|
||||
} // namespace wowee
|
||||
40
tools/asset_extract/manifest_writer.hpp
Normal file
40
tools/asset_extract/manifest_writer.hpp
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdint>
|
||||
|
||||
namespace wowee {
|
||||
namespace tools {
|
||||
|
||||
/**
|
||||
* Generates manifest.json from extracted file metadata.
|
||||
*/
|
||||
class ManifestWriter {
|
||||
public:
|
||||
struct FileEntry {
|
||||
std::string wowPath; // Normalized WoW virtual path (lowercase, backslash)
|
||||
std::string filesystemPath; // Relative path from basePath (forward slashes, original case)
|
||||
uint64_t size; // File size in bytes
|
||||
uint32_t crc32; // CRC32 checksum
|
||||
};
|
||||
|
||||
/**
|
||||
* Write manifest.json
|
||||
* @param outputPath Full path to manifest.json
|
||||
* @param basePath Value for basePath field (e.g., "assets")
|
||||
* @param entries All extracted file entries
|
||||
* @return true on success
|
||||
*/
|
||||
static bool write(const std::string& outputPath,
|
||||
const std::string& basePath,
|
||||
const std::vector<FileEntry>& entries);
|
||||
|
||||
/**
|
||||
* Compute CRC32 of file data
|
||||
*/
|
||||
static uint32_t computeCRC32(const uint8_t* data, size_t size);
|
||||
};
|
||||
|
||||
} // namespace tools
|
||||
} // namespace wowee
|
||||
207
tools/asset_extract/path_mapper.cpp
Normal file
207
tools/asset_extract/path_mapper.cpp
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
#include "path_mapper.hpp"
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace wowee {
|
||||
namespace tools {
|
||||
|
||||
std::string PathMapper::toLower(const std::string& str) {
|
||||
std::string result = str;
|
||||
std::transform(result.begin(), result.end(), result.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string PathMapper::toForwardSlash(const std::string& str) {
|
||||
std::string result = str;
|
||||
std::replace(result.begin(), result.end(), '\\', '/');
|
||||
return result;
|
||||
}
|
||||
|
||||
bool PathMapper::startsWithCI(const std::string& str, const std::string& prefix) {
|
||||
if (str.size() < prefix.size()) return false;
|
||||
for (size_t i = 0; i < prefix.size(); ++i) {
|
||||
if (std::tolower(static_cast<unsigned char>(str[i])) !=
|
||||
std::tolower(static_cast<unsigned char>(prefix[i]))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string PathMapper::extractAfterPrefix(const std::string& path, size_t prefixLen) {
|
||||
if (prefixLen >= path.size()) return {};
|
||||
return path.substr(prefixLen);
|
||||
}
|
||||
|
||||
std::string PathMapper::mapPath(const std::string& wowPath) {
|
||||
// Preserve original casing in the remainder for filesystem readability
|
||||
std::string rest;
|
||||
|
||||
// DBFilesClient\ → db/
|
||||
if (startsWithCI(wowPath, "DBFilesClient\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 14);
|
||||
return "db/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Character\{Race}\{Gender}\ → character/{race}/{gender}/
|
||||
if (startsWithCI(wowPath, "Character\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 10);
|
||||
std::string lowered = toLower(rest);
|
||||
return "character/" + toForwardSlash(lowered);
|
||||
}
|
||||
|
||||
// Creature\{Name}\ → creature/{name}/
|
||||
if (startsWithCI(wowPath, "Creature\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 9);
|
||||
// Keep first component lowercase for directory, preserve filename case
|
||||
std::string fwd = toForwardSlash(rest);
|
||||
auto slash = fwd.find('/');
|
||||
if (slash != std::string::npos) {
|
||||
return "creature/" + toLower(fwd.substr(0, slash)) + "/" + fwd.substr(slash + 1);
|
||||
}
|
||||
return "creature/" + fwd;
|
||||
}
|
||||
|
||||
// Item\ObjectComponents\{Type}\ → item/{type}/
|
||||
if (startsWithCI(wowPath, "Item\\ObjectComponents\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 22);
|
||||
std::string fwd = toForwardSlash(rest);
|
||||
auto slash = fwd.find('/');
|
||||
if (slash != std::string::npos) {
|
||||
return "item/" + toLower(fwd.substr(0, slash)) + "/" + fwd.substr(slash + 1);
|
||||
}
|
||||
return "item/" + fwd;
|
||||
}
|
||||
|
||||
// Item\TextureComponents\ → item/texture/
|
||||
if (startsWithCI(wowPath, "Item\\TextureComponents\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 23);
|
||||
return "item/texture/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Interface\Icons\ → interface/icons/
|
||||
if (startsWithCI(wowPath, "Interface\\Icons\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 16);
|
||||
return "interface/icons/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Interface\GossipFrame\ → interface/gossip/
|
||||
if (startsWithCI(wowPath, "Interface\\GossipFrame\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 21);
|
||||
return "interface/gossip/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Interface\{rest} → interface/{rest}/
|
||||
if (startsWithCI(wowPath, "Interface\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 10);
|
||||
return "interface/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Textures\Minimap\ → terrain/minimap/
|
||||
if (startsWithCI(wowPath, "Textures\\Minimap\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 17);
|
||||
return "terrain/minimap/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Textures\BakedNpcTextures\ → creature/baked/
|
||||
if (startsWithCI(wowPath, "Textures\\BakedNpcTextures\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 25);
|
||||
return "creature/baked/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Textures\{rest} → terrain/textures/{rest}
|
||||
if (startsWithCI(wowPath, "Textures\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 9);
|
||||
return "terrain/textures/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// World\Maps\{Map}\ → terrain/maps/{map}/
|
||||
if (startsWithCI(wowPath, "World\\Maps\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 11);
|
||||
std::string fwd = toForwardSlash(rest);
|
||||
auto slash = fwd.find('/');
|
||||
if (slash != std::string::npos) {
|
||||
return "terrain/maps/" + toLower(fwd.substr(0, slash)) + "/" + fwd.substr(slash + 1);
|
||||
}
|
||||
return "terrain/maps/" + fwd;
|
||||
}
|
||||
|
||||
// World\wmo\ → world/wmo/ (preserve subpath)
|
||||
if (startsWithCI(wowPath, "World\\wmo\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 10);
|
||||
return "world/wmo/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// World\Doodads\ → world/doodads/
|
||||
if (startsWithCI(wowPath, "World\\Doodads\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 14);
|
||||
return "world/doodads/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// World\{rest} → world/{rest}/
|
||||
if (startsWithCI(wowPath, "World\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 6);
|
||||
return "world/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Environments\ → environment/
|
||||
if (startsWithCI(wowPath, "Environments\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 13);
|
||||
return "environment/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Sound\Ambience\ → sound/ambient/
|
||||
if (startsWithCI(wowPath, "Sound\\Ambience\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 15);
|
||||
return "sound/ambient/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Sound\Character\ → sound/character/
|
||||
if (startsWithCI(wowPath, "Sound\\Character\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 16);
|
||||
return "sound/character/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Sound\Doodad\ → sound/doodad/
|
||||
if (startsWithCI(wowPath, "Sound\\Doodad\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 13);
|
||||
return "sound/doodad/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Sound\Creature\ → sound/creature/
|
||||
if (startsWithCI(wowPath, "Sound\\Creature\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 15);
|
||||
return "sound/creature/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Sound\Spells\ → sound/spell/
|
||||
if (startsWithCI(wowPath, "Sound\\Spells\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 13);
|
||||
return "sound/spell/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Sound\Music\ → sound/music/
|
||||
if (startsWithCI(wowPath, "Sound\\Music\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 12);
|
||||
return "sound/music/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Sound\{rest} → sound/{rest}/
|
||||
if (startsWithCI(wowPath, "Sound\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 6);
|
||||
return "sound/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Spells\ → spell/
|
||||
if (startsWithCI(wowPath, "Spells\\")) {
|
||||
rest = extractAfterPrefix(wowPath, 7);
|
||||
return "spell/" + toForwardSlash(rest);
|
||||
}
|
||||
|
||||
// Everything else → misc/{original_path}
|
||||
return "misc/" + toForwardSlash(wowPath);
|
||||
}
|
||||
|
||||
} // namespace tools
|
||||
} // namespace wowee
|
||||
32
tools/asset_extract/path_mapper.hpp
Normal file
32
tools/asset_extract/path_mapper.hpp
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace wowee {
|
||||
namespace tools {
|
||||
|
||||
/**
|
||||
* Maps WoW virtual paths to reorganized filesystem categories.
|
||||
*
|
||||
* Input: WoW virtual path (e.g., "Creature\\Bear\\BearSkin.blp")
|
||||
* Output: Category-based relative path (e.g., "creature/bear/BearSkin.blp")
|
||||
*/
|
||||
class PathMapper {
|
||||
public:
|
||||
/**
|
||||
* Map a WoW virtual path to a reorganized filesystem path.
|
||||
* @param wowPath Original WoW virtual path (backslash-separated)
|
||||
* @return Reorganized relative path (forward-slash separated, original casing preserved)
|
||||
*/
|
||||
static std::string mapPath(const std::string& wowPath);
|
||||
|
||||
private:
|
||||
// Helpers for prefix matching (case-insensitive)
|
||||
static bool startsWithCI(const std::string& str, const std::string& prefix);
|
||||
static std::string toLower(const std::string& str);
|
||||
static std::string toForwardSlash(const std::string& str);
|
||||
static std::string extractAfterPrefix(const std::string& path, size_t prefixLen);
|
||||
};
|
||||
|
||||
} // namespace tools
|
||||
} // namespace wowee
|
||||
Loading…
Add table
Add a link
Reference in a new issue