chore: remove dead code, document water shader wave parameters

- Delete 4 legacy GLSL 330 shaders (basic.vert/frag, terrain.vert/frag)
  left over from OpenGL→Vulkan migration — Vulkan equivalents exist as
  *.glsl files compiled to SPIR-V by the build system
- Delete orphaned mpq_manager.hpp/cpp (694 lines) — not in CMakeLists,
  not included by any file, unreferenced StormLib integration attempt
- Add comments to water.frag.glsl wave constants explaining the
  multi-octave noise design: non-axis-aligned directions prevent tiling,
  frequency increases and amplitude decreases per octave for natural
  water appearance
This commit is contained in:
Kelsi 2026-03-30 18:50:14 -07:00
parent 529985a961
commit 1e06ea86d7
7 changed files with 10 additions and 934 deletions

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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);

View file

@ -1,129 +0,0 @@
#pragma once
#include <string>
#include <vector>
#include <cstdint>
#include <memory>
#include <map>
#include <unordered_map>
#include <unordered_set>
#include <mutex>
#include <shared_mutex>
// 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<uint8_t> 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<std::string>& getLoadedArchives() const { return archiveNames; }
private:
struct ArchiveEntry {
HANDLE handle;
std::string path;
int priority;
};
bool initialized = false;
std::string dataPath;
std::vector<ArchiveEntry> archives;
std::vector<std::string> 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<std::string, HANDLE> fileArchiveCache_;
size_t fileArchiveCacheMaxEntries_ = 500000;
bool fileArchiveCacheMisses_ = false;
mutable std::mutex missingFileMutex_;
mutable std::unordered_set<std::string> missingFileWarnings_;
};
} // namespace pipeline
} // namespace wowee

View file

@ -1,565 +0,0 @@
#include "pipeline/mpq_manager.hpp"
#include "core/logger.hpp"
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <limits>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <cctype>
#ifdef HAVE_STORMLIB
#include <StormLib.h>
#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<char>(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<unsigned long long>(std::numeric_limits<size_t>::max())) return defValue;
return static_cast<size_t>(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<std::string> 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<const char*, 12> 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<std::shared_mutex> lock(fileArchiveCacheMutex_);
fileArchiveCache_.clear();
}
{
std::lock_guard<std::mutex> 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<std::shared_mutex> 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<uint8_t> 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<uint8_t> 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<uint8_t> data(size);
file.read(reinterpret_cast<char*>(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<std::string> 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<uint8_t>();
}
}
// 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<uint8_t> data(size);
file.read(reinterpret_cast<char*>(data.data()), size);
LOG_DEBUG("Read loose file (case-insensitive): ", searchPath.string(), " (", size, " bytes)");
return data;
}
}
logMissingFileOnce(filename);
return std::vector<uint8_t>();
}
void MPQManager::logMissingFileOnce(const std::string& filename) const {
std::string normalized = toLowerCopy(filename);
std::lock_guard<std::mutex> 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<std::shared_mutex> 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<std::chrono::milliseconds>(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<std::shared_mutex> 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<std::pair<std::string, int>> 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<std::string, std::string> 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<std::pair<std::string, int>> 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