diff --git a/assets/shaders/basic.frag b/assets/shaders/basic.frag deleted file mode 100644 index 158eb776..00000000 --- a/assets/shaders/basic.frag +++ /dev/null @@ -1,38 +0,0 @@ -#version 330 core - -in vec3 FragPos; -in vec3 Normal; -in vec2 TexCoord; - -out vec4 FragColor; - -uniform vec3 uLightPos; -uniform vec3 uViewPos; -uniform vec4 uColor; -uniform sampler2D uTexture; -uniform bool uUseTexture; - -void main() { - // Ambient - vec3 ambient = 0.3 * vec3(1.0); - - // Diffuse - vec3 norm = normalize(Normal); - vec3 lightDir = normalize(uLightPos - FragPos); - float diff = max(dot(norm, lightDir), 0.0); - vec3 diffuse = diff * vec3(1.0); - - // Specular - vec3 viewDir = normalize(uViewPos - FragPos); - vec3 reflectDir = reflect(-lightDir, norm); - float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0); - vec3 specular = 0.5 * spec * vec3(1.0); - - vec3 result = (ambient + diffuse + specular); - - if (uUseTexture) { - FragColor = texture(uTexture, TexCoord) * vec4(result, 1.0); - } else { - FragColor = uColor * vec4(result, 1.0); - } -} diff --git a/assets/shaders/basic.vert b/assets/shaders/basic.vert deleted file mode 100644 index 141f2270..00000000 --- a/assets/shaders/basic.vert +++ /dev/null @@ -1,22 +0,0 @@ -#version 330 core - -layout (location = 0) in vec3 aPosition; -layout (location = 1) in vec3 aNormal; -layout (location = 2) in vec2 aTexCoord; - -out vec3 FragPos; -out vec3 Normal; -out vec2 TexCoord; - -uniform mat4 uModel; -uniform mat4 uView; -uniform mat4 uProjection; - -void main() { - FragPos = vec3(uModel * vec4(aPosition, 1.0)); - // Use mat3(uModel) directly - avoids expensive inverse() per vertex - Normal = mat3(uModel) * aNormal; - TexCoord = aTexCoord; - - gl_Position = uProjection * uView * vec4(FragPos, 1.0); -} diff --git a/assets/shaders/terrain.frag b/assets/shaders/terrain.frag deleted file mode 100644 index c7677725..00000000 --- a/assets/shaders/terrain.frag +++ /dev/null @@ -1,146 +0,0 @@ -#version 330 core - -in vec3 FragPos; -in vec3 Normal; -in vec2 TexCoord; -in vec2 LayerUV; - -out vec4 FragColor; - -// Texture layers (up to 4) -uniform sampler2D uBaseTexture; -uniform sampler2D uLayer1Texture; -uniform sampler2D uLayer2Texture; -uniform sampler2D uLayer3Texture; - -// Alpha maps for blending -uniform sampler2D uLayer1Alpha; -uniform sampler2D uLayer2Alpha; -uniform sampler2D uLayer3Alpha; - -// Layer control -uniform int uLayerCount; -uniform bool uHasLayer1; -uniform bool uHasLayer2; -uniform bool uHasLayer3; - -// Lighting -uniform vec3 uLightDir; -uniform vec3 uLightColor; -uniform vec3 uAmbientColor; - -// Camera -uniform vec3 uViewPos; - -// Fog -uniform vec3 uFogColor; -uniform float uFogStart; -uniform float uFogEnd; - -// Shadow mapping -uniform sampler2DShadow uShadowMap; -uniform mat4 uLightSpaceMatrix; -uniform bool uShadowEnabled; -uniform float uShadowStrength; - -float calcShadow() { - vec4 lsPos = uLightSpaceMatrix * vec4(FragPos, 1.0); - vec3 proj = lsPos.xyz / lsPos.w * 0.5 + 0.5; - if (proj.z > 1.0) return 1.0; - float edgeDist = max(abs(proj.x - 0.5), abs(proj.y - 0.5)); - float coverageFade = 1.0 - smoothstep(0.40, 0.49, edgeDist); - vec3 norm = normalize(Normal); - vec3 lightDir = normalize(-uLightDir); - float bias = max(0.005 * (1.0 - dot(norm, lightDir)), 0.001); - // 5-tap PCF tuned for slightly sharper detail while keeping stability. - vec2 texel = vec2(1.0 / 2048.0); - float ref = proj.z - bias; - vec2 off = texel * 0.7; - float shadow = 0.0; - shadow += texture(uShadowMap, vec3(proj.xy, ref)) * 0.55; - shadow += texture(uShadowMap, vec3(proj.xy + vec2(off.x, 0.0), ref)) * 0.1125; - shadow += texture(uShadowMap, vec3(proj.xy - vec2(off.x, 0.0), ref)) * 0.1125; - shadow += texture(uShadowMap, vec3(proj.xy + vec2(0.0, off.y), ref)) * 0.1125; - shadow += texture(uShadowMap, vec3(proj.xy - vec2(0.0, off.y), ref)) * 0.1125; - return mix(1.0, shadow, coverageFade); -} - -float sampleAlpha(sampler2D tex, vec2 uv) { - // Slight blur near alpha-map borders to hide seams between chunks. - vec2 edge = min(uv, 1.0 - uv); - float border = min(edge.x, edge.y); - float doBlur = step(border, 2.0 / 64.0); // within ~2 texels of edge - if (doBlur < 0.5) { - return texture(tex, uv).r; - } - vec2 texel = vec2(1.0 / 64.0); - float a = 0.0; - a += texture(tex, uv + vec2(-texel.x, 0.0)).r; - a += texture(tex, uv + vec2(texel.x, 0.0)).r; - a += texture(tex, uv + vec2(0.0, -texel.y)).r; - a += texture(tex, uv + vec2(0.0, texel.y)).r; - return a * 0.25; -} - -void main() { - // Sample base texture - vec4 baseColor = texture(uBaseTexture, TexCoord); - vec4 finalColor = baseColor; - - // Apply texture layers with alpha blending - // TexCoord = tiling UVs for texture sampling (repeats across chunk) - // LayerUV = 0-1 per-chunk UVs for alpha map sampling - float a1 = uHasLayer1 ? sampleAlpha(uLayer1Alpha, LayerUV) : 0.0; - float a2 = uHasLayer2 ? sampleAlpha(uLayer2Alpha, LayerUV) : 0.0; - float a3 = uHasLayer3 ? sampleAlpha(uLayer3Alpha, LayerUV) : 0.0; - - // Normalize weights to reduce quilting seams at chunk borders. - float w0 = 1.0; - float w1 = a1; - float w2 = a2; - float w3 = a3; - float sum = w0 + w1 + w2 + w3; - if (sum > 0.0) { - w0 /= sum; w1 /= sum; w2 /= sum; w3 /= sum; - } - - finalColor = baseColor * w0; - if (uHasLayer1) { - vec4 layer1Color = texture(uLayer1Texture, TexCoord); - finalColor += layer1Color * w1; - } - if (uHasLayer2) { - vec4 layer2Color = texture(uLayer2Texture, TexCoord); - finalColor += layer2Color * w2; - } - if (uHasLayer3) { - vec4 layer3Color = texture(uLayer3Texture, TexCoord); - finalColor += layer3Color * w3; - } - - // Normalize normal - vec3 norm = normalize(Normal); - vec3 lightDir = normalize(-uLightDir); - - // Ambient lighting - vec3 ambient = uAmbientColor * finalColor.rgb; - - // Diffuse lighting (two-sided for terrain hills) - float diff = abs(dot(norm, lightDir)); - diff = max(diff, 0.2); // Minimum light to prevent completely dark faces - vec3 diffuse = diff * uLightColor * finalColor.rgb; - - // Shadow - float shadow = uShadowEnabled ? calcShadow() : 1.0; - shadow = mix(1.0, shadow, clamp(uShadowStrength, 0.0, 1.0)); - - // Combine lighting (terrain is purely diffuse — no specular on ground) - vec3 result = ambient + shadow * diffuse; - - // Apply fog - float distance = length(uViewPos - FragPos); - float fogFactor = clamp((uFogEnd - distance) / (uFogEnd - uFogStart), 0.0, 1.0); - result = mix(uFogColor, result, fogFactor); - - FragColor = vec4(result, 1.0); -} diff --git a/assets/shaders/terrain.vert b/assets/shaders/terrain.vert deleted file mode 100644 index f8a57ae8..00000000 --- a/assets/shaders/terrain.vert +++ /dev/null @@ -1,28 +0,0 @@ -#version 330 core - -layout(location = 0) in vec3 aPosition; -layout(location = 1) in vec3 aNormal; -layout(location = 2) in vec2 aTexCoord; -layout(location = 3) in vec2 aLayerUV; - -out vec3 FragPos; -out vec3 Normal; -out vec2 TexCoord; -out vec2 LayerUV; - -uniform mat4 uModel; -uniform mat4 uView; -uniform mat4 uProjection; - -void main() { - vec4 worldPos = uModel * vec4(aPosition, 1.0); - FragPos = worldPos.xyz; - - // Terrain uses identity model matrix, so normal passes through directly - Normal = aNormal; - - TexCoord = aTexCoord; - LayerUV = aLayerUV; - - gl_Position = uProjection * uView * worldPos; -} diff --git a/assets/shaders/water.frag.glsl b/assets/shaders/water.frag.glsl index 5d3af519..f656b681 100644 --- a/assets/shaders/water.frag.glsl +++ b/assets/shaders/water.frag.glsl @@ -47,12 +47,16 @@ layout(location = 0) out vec4 outColor; // Dual-scroll detail normals (multi-octave ripple overlay) // ============================================================ vec3 dualScrollWaveNormal(vec2 p, float time) { - vec2 d1 = normalize(vec2(0.86, 0.51)); - vec2 d2 = normalize(vec2(-0.47, 0.88)); - vec2 d3 = normalize(vec2(0.32, -0.95)); - float f1 = 0.19, f2 = 0.43, f3 = 0.72; - float s1 = 0.95, s2 = 1.73, s3 = 2.40; - float a1 = 0.22, a2 = 0.10, a3 = 0.05; + // Three wave octaves at different angles, frequencies, and speeds. + // Directions are non-axis-aligned to prevent visible tiling patterns. + // Frequency increases and amplitude decreases per octave (standard + // multi-octave noise layering for natural water appearance). + vec2 d1 = normalize(vec2(0.86, 0.51)); // ~30° from +X + vec2 d2 = normalize(vec2(-0.47, 0.88)); // ~118° (opposing cross-wave) + vec2 d3 = normalize(vec2(0.32, -0.95)); // ~-71° (third axis for variety) + float f1 = 0.19, f2 = 0.43, f3 = 0.72; // spatial frequency (higher = tighter ripples) + float s1 = 0.95, s2 = 1.73, s3 = 2.40; // scroll speed (higher octaves move faster) + float a1 = 0.22, a2 = 0.10, a3 = 0.05; // amplitude (decreasing for natural falloff) vec2 p1 = p + d1 * (time * s1 * 4.0); vec2 p2 = p + d2 * (time * s2 * 4.0); diff --git a/include/pipeline/mpq_manager.hpp b/include/pipeline/mpq_manager.hpp deleted file mode 100644 index 2169fee4..00000000 --- a/include/pipeline/mpq_manager.hpp +++ /dev/null @@ -1,129 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// Forward declare StormLib handle -typedef void* HANDLE; - -namespace wowee { -namespace pipeline { - -/** - * MPQManager - Manages MPQ archive loading and file reading - * - * WoW 3.3.5a stores all game assets in MPQ archives. - * This manager loads multiple archives and provides unified file access. - */ -class MPQManager { -public: - MPQManager(); - ~MPQManager(); - - /** - * Initialize the MPQ system - * @param dataPath Path to WoW Data directory - * @return true if initialization succeeded - */ - bool initialize(const std::string& dataPath); - - /** - * Shutdown and close all archives - */ - void shutdown(); - - /** - * Load a single MPQ archive - * @param path Full path to MPQ file - * @param priority Priority for file resolution (higher = checked first) - * @return true if archive loaded successfully - */ - bool loadArchive(const std::string& path, int priority = 0); - - /** - * Check if a file exists in any loaded archive - * @param filename Virtual file path (e.g., "World\\Maps\\Azeroth\\Azeroth.wdt") - * @return true if file exists - */ - bool fileExists(const std::string& filename) const; - - /** - * Read a file from MPQ archives - * @param filename Virtual file path - * @return File contents as byte vector (empty if not found) - */ - std::vector readFile(const std::string& filename) const; - - /** - * Get file size without reading it - * @param filename Virtual file path - * @return File size in bytes (0 if not found) - */ - uint32_t getFileSize(const std::string& filename) const; - - /** - * Check if MPQ system is initialized - */ - bool isInitialized() const { return initialized; } - - /** - * Get list of loaded archives - */ - const std::vector& getLoadedArchives() const { return archiveNames; } - -private: - struct ArchiveEntry { - HANDLE handle; - std::string path; - int priority; - }; - - bool initialized = false; - std::string dataPath; - std::vector archives; - std::vector archiveNames; - - /** - * Find archive containing a file - * @param filename File to search for - * @return Archive handle or nullptr if not found - */ - HANDLE findFileArchive(const std::string& filename) const; - - /** - * Load patch archives (e.g., patch.MPQ, patch-2.MPQ, etc.) - */ - bool loadPatchArchives(); - - /** - * Load locale-specific archives - * @param locale Locale string (e.g., "enUS") - */ - bool loadLocaleArchives(const std::string& locale); - - void logMissingFileOnce(const std::string& filename) const; - - // Cache for mapping "virtual filename" -> archive handle (or INVALID_HANDLE_VALUE for not found). - // This avoids scanning every archive for repeated lookups, which can otherwise appear as a hang - // on screens that trigger many asset probes (character select, character preview, etc.). - // - // Important: caching misses can blow up memory if the game probes many unique non-existent filenames. - // Miss caching is disabled by default and must be explicitly enabled. - mutable std::shared_mutex fileArchiveCacheMutex_; - mutable std::unordered_map fileArchiveCache_; - size_t fileArchiveCacheMaxEntries_ = 500000; - bool fileArchiveCacheMisses_ = false; - - mutable std::mutex missingFileMutex_; - mutable std::unordered_set missingFileWarnings_; -}; - -} // namespace pipeline -} // namespace wowee diff --git a/src/pipeline/mpq_manager.cpp b/src/pipeline/mpq_manager.cpp deleted file mode 100644 index c98e372b..00000000 --- a/src/pipeline/mpq_manager.cpp +++ /dev/null @@ -1,565 +0,0 @@ -#include "pipeline/mpq_manager.hpp" -#include "core/logger.hpp" -#include -#include -#include -#include -#include -#include -#include -#include - -#ifdef HAVE_STORMLIB -#include -#endif - -// Define HANDLE and INVALID_HANDLE_VALUE for both cases -#ifndef HAVE_STORMLIB -typedef void* HANDLE; -#endif - -#ifndef INVALID_HANDLE_VALUE -#define INVALID_HANDLE_VALUE ((HANDLE)(long long)-1) -#endif - -namespace wowee { -namespace pipeline { - -namespace { -std::string toLowerCopy(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - return value; -} - -std::string normalizeVirtualFilenameForLookup(std::string value) { - // StormLib uses backslash-separated virtual paths; treat lookups as case-insensitive. - std::replace(value.begin(), value.end(), '/', '\\'); - value = toLowerCopy(std::move(value)); - while (!value.empty() && (value.front() == '\\' || value.front() == '/')) { - value.erase(value.begin()); - } - return value; -} - -bool envFlagEnabled(const char* name) { - const char* v = std::getenv(name); - if (!v || !*v) { - return false; - } - std::string s = toLowerCopy(v); - return s == "1" || s == "true" || s == "yes" || s == "on"; -} - -size_t envSizeTOrDefault(const char* name, size_t defValue) { - const char* v = std::getenv(name); - if (!v || !*v) return defValue; - char* end = nullptr; - unsigned long long value = std::strtoull(v, &end, 10); - if (end == v || value == 0) return defValue; - if (value > static_cast(std::numeric_limits::max())) return defValue; - return static_cast(value); -} -} - -MPQManager::MPQManager() = default; - -MPQManager::~MPQManager() { - shutdown(); -} - -bool MPQManager::initialize(const std::string& dataPath_) { - if (initialized) { - LOG_WARNING("MPQManager already initialized"); - return true; - } - - dataPath = dataPath_; - LOG_INFO("Initializing MPQ manager with data path: ", dataPath); - - // Guard against cache blowups from huge numbers of unique probes. - fileArchiveCacheMaxEntries_ = envSizeTOrDefault("WOWEE_MPQ_ARCHIVE_CACHE_MAX", fileArchiveCacheMaxEntries_); - fileArchiveCacheMisses_ = envFlagEnabled("WOWEE_MPQ_CACHE_MISSES"); - LOG_INFO("MPQ archive lookup cache: maxEntries=", fileArchiveCacheMaxEntries_, - " cacheMisses=", (fileArchiveCacheMisses_ ? "yes" : "no")); - - // Check if data directory exists - if (!std::filesystem::exists(dataPath)) { - LOG_ERROR("Data directory does not exist: ", dataPath); - return false; - } - -#ifdef HAVE_STORMLIB - // Load base archives (in order of priority) - std::vector baseArchives = { - "common.MPQ", - "common-2.MPQ", - "expansion.MPQ", - "lichking.MPQ", - }; - - for (const auto& archive : baseArchives) { - std::string fullPath = dataPath + "/" + archive; - if (std::filesystem::exists(fullPath)) { - loadArchive(fullPath, 100); // Base archives have priority 100 - } else { - LOG_DEBUG("Base archive not found (optional): ", archive); - } - } - - // Load patch archives (highest priority) - loadPatchArchives(); - - // Load locale archives — auto-detect from available locale directories - { - // Prefer the locale override from environment, then scan for installed ones - const char* localeEnv = std::getenv("WOWEE_LOCALE"); - std::string detectedLocale; - if (localeEnv && localeEnv[0] != '\0') { - detectedLocale = localeEnv; - LOG_INFO("Using locale from WOWEE_LOCALE env: ", detectedLocale); - } else { - // Priority order: enUS first, then other common locales - static const std::array knownLocales = { - "enUS", "enGB", "deDE", "frFR", "esES", "esMX", - "zhCN", "zhTW", "koKR", "ruRU", "ptBR", "itIT" - }; - for (const char* loc : knownLocales) { - if (std::filesystem::exists(dataPath + "/" + loc)) { - detectedLocale = loc; - LOG_INFO("Auto-detected WoW locale: ", detectedLocale); - break; - } - } - if (detectedLocale.empty()) { - detectedLocale = "enUS"; - LOG_WARNING("No locale directory found in data path; defaulting to enUS"); - } - } - loadLocaleArchives(detectedLocale); - } - - if (archives.empty()) { - LOG_WARNING("No MPQ archives loaded - will use loose file fallback"); - } else { - LOG_INFO("MPQ manager initialized with ", archives.size(), " archives"); - } -#else - LOG_WARNING("StormLib not available - using loose file fallback only"); -#endif - - initialized = true; - return true; -} - -void MPQManager::shutdown() { - if (!initialized) { - return; - } - -#ifdef HAVE_STORMLIB - LOG_INFO("Shutting down MPQ manager"); - for (auto& entry : archives) { - if (entry.handle != INVALID_HANDLE_VALUE) { - SFileCloseArchive(entry.handle); - } - } -#endif - - archives.clear(); - archiveNames.clear(); - { - std::lock_guard lock(fileArchiveCacheMutex_); - fileArchiveCache_.clear(); - } - { - std::lock_guard lock(missingFileMutex_); - missingFileWarnings_.clear(); - } - initialized = false; -} - -bool MPQManager::loadArchive(const std::string& path, int priority) { -#ifndef HAVE_STORMLIB - LOG_ERROR("Cannot load archive - StormLib not available"); - return false; -#endif - -#ifdef HAVE_STORMLIB - // Check if file exists - if (!std::filesystem::exists(path)) { - LOG_ERROR("Archive file not found: ", path); - return false; - } - - HANDLE handle = INVALID_HANDLE_VALUE; - if (!SFileOpenArchive(path.c_str(), 0, 0, &handle)) { - LOG_ERROR("Failed to open MPQ archive: ", path); - return false; - } - - ArchiveEntry entry; - entry.handle = handle; - entry.path = path; - entry.priority = priority; - - archives.push_back(entry); - archiveNames.push_back(path); - - // Sort archives by priority (highest first) - std::sort(archives.begin(), archives.end(), - [](const ArchiveEntry& a, const ArchiveEntry& b) { - return a.priority > b.priority; - }); - - // Archive set/priority changed, so cached filename -> archive mappings may be stale. - { - std::lock_guard lock(fileArchiveCacheMutex_); - fileArchiveCache_.clear(); - } - - LOG_INFO("Loaded MPQ archive: ", path, " (priority ", priority, ")"); - return true; -#endif - - return false; -} - -bool MPQManager::fileExists(const std::string& filename) const { -#ifdef HAVE_STORMLIB - // Check MPQ archives first if available - if (!archives.empty()) { - HANDLE archive = findFileArchive(filename); - if (archive != INVALID_HANDLE_VALUE) { - return true; - } - } -#endif - - // Fall back to checking for loose file - std::string loosePath = filename; - std::replace(loosePath.begin(), loosePath.end(), '\\', '/'); - std::string fullPath = dataPath + "/" + loosePath; - return std::filesystem::exists(fullPath); -} - -std::vector MPQManager::readFile(const std::string& filename) const { -#ifdef HAVE_STORMLIB - // Try MPQ archives first if available - if (!archives.empty()) { - HANDLE archive = findFileArchive(filename); - if (archive != INVALID_HANDLE_VALUE) { - std::string stormFilename = filename; - std::replace(stormFilename.begin(), stormFilename.end(), '/', '\\'); - // Open the file - HANDLE file = INVALID_HANDLE_VALUE; - if (SFileOpenFileEx(archive, stormFilename.c_str(), 0, &file)) { - // Get file size - DWORD fileSize = SFileGetFileSize(file, nullptr); - if (fileSize > 0 && fileSize != SFILE_INVALID_SIZE) { - // Read file data - std::vector data(fileSize); - DWORD bytesRead = 0; - if (SFileReadFile(file, data.data(), fileSize, &bytesRead, nullptr)) { - SFileCloseFile(file); - LOG_DEBUG("Read file from MPQ: ", filename, " (", bytesRead, " bytes)"); - return data; - } - } - SFileCloseFile(file); - } - } - } -#endif - - // Fall back to loose file loading - // Convert WoW path (backslashes) to filesystem path (forward slashes) - std::string loosePath = filename; - std::replace(loosePath.begin(), loosePath.end(), '\\', '/'); - - // Try with original case - std::string fullPath = dataPath + "/" + loosePath; - if (std::filesystem::exists(fullPath)) { - std::ifstream file(fullPath, std::ios::binary | std::ios::ate); - if (file.is_open()) { - size_t size = file.tellg(); - file.seekg(0, std::ios::beg); - std::vector data(size); - file.read(reinterpret_cast(data.data()), size); - LOG_DEBUG("Read loose file: ", loosePath, " (", size, " bytes)"); - return data; - } - } - - // Try case-insensitive search (common for Linux) - std::filesystem::path searchPath = dataPath; - std::vector pathComponents; - std::istringstream iss(loosePath); - std::string component; - while (std::getline(iss, component, '/')) { - if (!component.empty()) { - pathComponents.push_back(component); - } - } - - // Try to find file with case-insensitive matching - for (const auto& comp : pathComponents) { - bool found = false; - if (std::filesystem::exists(searchPath) && std::filesystem::is_directory(searchPath)) { - for (const auto& entry : std::filesystem::directory_iterator(searchPath)) { - std::string entryName = entry.path().filename().string(); - // Case-insensitive comparison - if (std::equal(comp.begin(), comp.end(), entryName.begin(), entryName.end(), - [](unsigned char a, unsigned char b) { return std::tolower(a) == std::tolower(b); })) { - searchPath = entry.path(); - found = true; - break; - } - } - } - if (!found) { - logMissingFileOnce(filename); - return std::vector(); - } - } - - // Try to read the found file - if (std::filesystem::exists(searchPath) && std::filesystem::is_regular_file(searchPath)) { - std::ifstream file(searchPath, std::ios::binary | std::ios::ate); - if (file.is_open()) { - size_t size = file.tellg(); - file.seekg(0, std::ios::beg); - std::vector data(size); - file.read(reinterpret_cast(data.data()), size); - LOG_DEBUG("Read loose file (case-insensitive): ", searchPath.string(), " (", size, " bytes)"); - return data; - } - } - - logMissingFileOnce(filename); - return std::vector(); -} - -void MPQManager::logMissingFileOnce(const std::string& filename) const { - std::string normalized = toLowerCopy(filename); - std::lock_guard lock(missingFileMutex_); - if (missingFileWarnings_.insert(normalized).second) { - LOG_WARNING("File not found: ", filename); - } -} - -uint32_t MPQManager::getFileSize(const std::string& filename) const { -#ifndef HAVE_STORMLIB - return 0; -#endif - -#ifdef HAVE_STORMLIB - HANDLE archive = findFileArchive(filename); - if (archive == INVALID_HANDLE_VALUE) { - return 0; - } - - std::string stormFilename = filename; - std::replace(stormFilename.begin(), stormFilename.end(), '/', '\\'); - HANDLE file = INVALID_HANDLE_VALUE; - if (!SFileOpenFileEx(archive, stormFilename.c_str(), 0, &file)) { - return 0; - } - - DWORD fileSize = SFileGetFileSize(file, nullptr); - SFileCloseFile(file); - - return (fileSize == SFILE_INVALID_SIZE) ? 0 : fileSize; -#endif - - return 0; -} - -HANDLE MPQManager::findFileArchive(const std::string& filename) const { -#ifndef HAVE_STORMLIB - return INVALID_HANDLE_VALUE; -#endif - -#ifdef HAVE_STORMLIB - std::string cacheKey = normalizeVirtualFilenameForLookup(filename); - { - std::shared_lock lock(fileArchiveCacheMutex_); - auto it = fileArchiveCache_.find(cacheKey); - if (it != fileArchiveCache_.end()) { - return it->second; - } - } - - std::string stormFilename = filename; - std::replace(stormFilename.begin(), stormFilename.end(), '/', '\\'); - - const auto start = std::chrono::steady_clock::now(); - HANDLE found = INVALID_HANDLE_VALUE; - // Search archives in priority order (already sorted) - for (const auto& entry : archives) { - if (SFileHasFile(entry.handle, stormFilename.c_str())) { - found = entry.handle; - break; - } - } - - const auto end = std::chrono::steady_clock::now(); - const auto ms = std::chrono::duration_cast(end - start).count(); - - // Avoid caching misses unless explicitly enabled; miss caching can explode memory when - // code probes many unique non-existent paths (common with HD patch sets). - if (found == INVALID_HANDLE_VALUE && !fileArchiveCacheMisses_) { - if (ms >= 100) { - LOG_WARNING("Slow MPQ lookup: '", filename, "' scanned ", archives.size(), " archives in ", ms, " ms"); - } - return found; - } - - { - std::lock_guard lock(fileArchiveCacheMutex_); - if (fileArchiveCache_.size() >= fileArchiveCacheMaxEntries_) { - // Simple safety valve: clear the cache rather than allowing an unbounded growth. - LOG_WARNING("MPQ archive lookup cache cleared (size=", fileArchiveCache_.size(), - " reached maxEntries=", fileArchiveCacheMaxEntries_, ")"); - fileArchiveCache_.clear(); - } - // Another thread may have raced to populate; if so, prefer the existing value. - auto [it, inserted] = fileArchiveCache_.emplace(std::move(cacheKey), found); - if (!inserted) { - found = it->second; - } - } - - // With caching this should only happen once per unique filename; keep threshold conservative. - if (ms >= 100) { - LOG_WARNING("Slow MPQ lookup: '", filename, "' scanned ", archives.size(), " archives in ", ms, " ms"); - } - - return found; -#endif - - return INVALID_HANDLE_VALUE; -} - -bool MPQManager::loadPatchArchives() { -#ifndef HAVE_STORMLIB - return false; -#endif - - const bool disableLetterPatches = envFlagEnabled("WOWEE_DISABLE_LETTER_PATCHES"); - const bool disableNumericPatches = envFlagEnabled("WOWEE_DISABLE_NUMERIC_PATCHES"); - - if (disableLetterPatches) { - LOG_WARNING("MPQ letter patches disabled via WOWEE_DISABLE_LETTER_PATCHES=1"); - } - if (disableNumericPatches) { - LOG_WARNING("MPQ numeric patches disabled via WOWEE_DISABLE_NUMERIC_PATCHES=1"); - } - - // WoW 3.3.5a patch archives (in order of priority, highest first) - std::vector> patchArchives = { - // Lettered patch MPQs are used by some clients/distributions (e.g. Patch-A.mpq..Patch-E.mpq). - // Treat them as higher priority than numeric patch MPQs. - // Keep priorities well above numeric patch-*.MPQ so lettered patches always win when both exist. - {"Patch-Z.mpq", 925}, {"Patch-Y.mpq", 924}, {"Patch-X.mpq", 923}, {"Patch-W.mpq", 922}, - {"Patch-V.mpq", 921}, {"Patch-U.mpq", 920}, {"Patch-T.mpq", 919}, {"Patch-S.mpq", 918}, - {"Patch-R.mpq", 917}, {"Patch-Q.mpq", 916}, {"Patch-P.mpq", 915}, {"Patch-O.mpq", 914}, - {"Patch-N.mpq", 913}, {"Patch-M.mpq", 912}, {"Patch-L.mpq", 911}, {"Patch-K.mpq", 910}, - {"Patch-J.mpq", 909}, {"Patch-I.mpq", 908}, {"Patch-H.mpq", 907}, {"Patch-G.mpq", 906}, - {"Patch-F.mpq", 905}, {"Patch-E.mpq", 904}, {"Patch-D.mpq", 903}, {"Patch-C.mpq", 902}, - {"Patch-B.mpq", 901}, {"Patch-A.mpq", 900}, - // Lowercase variants (Linux case-sensitive filesystems). - {"patch-z.mpq", 825}, {"patch-y.mpq", 824}, {"patch-x.mpq", 823}, {"patch-w.mpq", 822}, - {"patch-v.mpq", 821}, {"patch-u.mpq", 820}, {"patch-t.mpq", 819}, {"patch-s.mpq", 818}, - {"patch-r.mpq", 817}, {"patch-q.mpq", 816}, {"patch-p.mpq", 815}, {"patch-o.mpq", 814}, - {"patch-n.mpq", 813}, {"patch-m.mpq", 812}, {"patch-l.mpq", 811}, {"patch-k.mpq", 810}, - {"patch-j.mpq", 809}, {"patch-i.mpq", 808}, {"patch-h.mpq", 807}, {"patch-g.mpq", 806}, - {"patch-f.mpq", 805}, {"patch-e.mpq", 804}, {"patch-d.mpq", 803}, {"patch-c.mpq", 802}, - {"patch-b.mpq", 801}, {"patch-a.mpq", 800}, - - {"patch-5.MPQ", 500}, - {"patch-4.MPQ", 400}, - {"patch-3.MPQ", 300}, - {"patch-2.MPQ", 200}, - {"patch.MPQ", 150}, - }; - - // Build a case-insensitive lookup of files in the data directory so that - // Patch-A.MPQ, patch-a.mpq, PATCH-A.MPQ, etc. all resolve correctly on - // case-sensitive filesystems (Linux). - std::unordered_map lowerToActual; // lowercase name → actual path - if (std::filesystem::is_directory(dataPath)) { - for (const auto& entry : std::filesystem::directory_iterator(dataPath)) { - if (!entry.is_regular_file()) continue; - std::string fname = entry.path().filename().string(); - std::string lower = toLowerCopy(fname); - lowerToActual[lower] = entry.path().string(); - } - } - - int loadedPatches = 0; - for (const auto& [archive, priority] : patchArchives) { - // Classify letter vs numeric patch for the disable flags - std::string lowerArchive = toLowerCopy(archive); - const bool isLetterPatch = - (lowerArchive.size() >= 11) && // "patch-X.mpq" = 11 chars - (lowerArchive.rfind("patch-", 0) == 0) && // starts with "patch-" - (lowerArchive[6] >= 'a' && lowerArchive[6] <= 'z'); // letter after dash - if (isLetterPatch && disableLetterPatches) { - continue; - } - if (!isLetterPatch && disableNumericPatches) { - continue; - } - - // Case-insensitive file lookup - auto it = lowerToActual.find(lowerArchive); - if (it != lowerToActual.end()) { - if (loadArchive(it->second, priority)) { - loadedPatches++; - } - } - } - - LOG_INFO("Loaded ", loadedPatches, " patch archives"); - return loadedPatches > 0; -} - -bool MPQManager::loadLocaleArchives(const std::string& locale) { -#ifndef HAVE_STORMLIB - return false; -#endif - - std::string localePath = dataPath + "/" + locale; - if (!std::filesystem::exists(localePath)) { - LOG_WARNING("Locale directory not found: ", localePath); - return false; - } - - // Locale-specific archives (including speech MPQs for NPC voices) - std::vector> localeArchives = { - {"locale-" + locale + ".MPQ", 250}, - {"speech-" + locale + ".MPQ", 240}, // Base speech/NPC voices - {"expansion-speech-" + locale + ".MPQ", 245}, // TBC speech - {"lichking-speech-" + locale + ".MPQ", 248}, // WotLK speech - {"patch-" + locale + ".MPQ", 450}, - {"patch-" + locale + "-2.MPQ", 460}, - {"patch-" + locale + "-3.MPQ", 470}, - }; - - int loadedLocale = 0; - for (const auto& [archive, priority] : localeArchives) { - std::string fullPath = localePath + "/" + archive; - if (std::filesystem::exists(fullPath)) { - if (loadArchive(fullPath, priority)) { - loadedLocale++; - } - } - } - - LOG_INFO("Loaded ", loadedLocale, " locale archives for ", locale); - return loadedLocale > 0; -} - -} // namespace pipeline -} // namespace wowee