diff options
| author | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-12 17:54:45 -0500 |
|---|---|---|
| committer | SulfurNitride <SulfurNitride@users.noreply.github.com> | 2026-04-12 17:54:45 -0500 |
| commit | a0355c9b897281d0bfea48bec9d490724ecabd96 (patch) | |
| tree | 42c56ea4f4829a6115f688956858ada3e59fd9ef /libs/preview_nif/src/TextureManager.cpp | |
| parent | fa43a71f9d05b3673d42296a80decb2228d68f83 (diff) | |
Add NIF previewer plugin with Linux-specific fixes
Vendored from github.com/Parapets/mo2-preview_nif and adapted for Linux:
- BSA texture resolution via MO2 virtual tree with case-insensitive
fallback walk (Linux FS is case-sensitive; NIFs reference textures
with arbitrary case against files on disk)
- Process-wide cached BSA candidate list, keyed by profile path,
priority-sorted, filtering disabled mods
- Path backslash normalization before loose-file lookup
- Polygon offset on SLSF1_Decal-flagged shapes to break z-ties with
coincident base meshes (road decals, moss overlays)
- Opaque framebuffer via glColorMask alpha lock so alpha-blended shapes
can't bleed the dialog background through the preview area
- QMouseEvent::globalPosition().toPoint() for Qt6 compatibility
Preview dialog behavior (organizercore.cpp):
- Switch previewFile and previewFileWithAlternatives from stack-allocated
exec() to heap + ApplicationModal + show() + WA_DeleteOnClose.
- Parent passed as nullptr so preview lifetime is decoupled from the
stack-allocated ModInfoDialog that spawns it. Nested exec() inside
the enclosing dialog's exec() was softlocking on close.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'libs/preview_nif/src/TextureManager.cpp')
| -rw-r--r-- | libs/preview_nif/src/TextureManager.cpp | 448 |
1 files changed, 448 insertions, 0 deletions
diff --git a/libs/preview_nif/src/TextureManager.cpp b/libs/preview_nif/src/TextureManager.cpp new file mode 100644 index 0000000..2135ec6 --- /dev/null +++ b/libs/preview_nif/src/TextureManager.cpp @@ -0,0 +1,448 @@ +#include "TextureManager.h" +#include "PreviewNif.h" + +#include <dataarchives.h> +#include <igamefeatures.h> +#include <imodinterface.h> +#include <imodlist.h> +#include <imoinfo.h> +#include <ipluginlist.h> +#include <iplugingame.h> + +#include <gli/gli.hpp> +#include <libbsarch.h> + +#include <QDir> +#include <QDirIterator> +#include <QFileInfo> +#include <QSet> + +#include <QOpenGLContext> +#include <QOpenGLFunctions_2_1> +#include <QOpenGLVersionFunctionsFactory> +#include <QVector4D> + +#include <exception> +#include <memory> + +TextureManager::TextureManager(MOBase::IOrganizer* moInfo) : m_MOInfo{moInfo} {} + +void TextureManager::cleanup() +{ + for (auto it = m_Textures.cbegin(); it != m_Textures.cend();) { + auto texture = it->second; + m_Textures.erase(it++); + delete texture; + } + + if (m_ErrorTexture) { + delete m_ErrorTexture; + m_ErrorTexture = nullptr; + } + + if (m_BlackTexture) { + delete m_BlackTexture; + m_BlackTexture = nullptr; + } + + if (m_WhiteTexture) { + delete m_WhiteTexture; + m_WhiteTexture = nullptr; + } + + if (m_FlatNormalTexture) { + delete m_FlatNormalTexture; + m_FlatNormalTexture = nullptr; + } +} + +QOpenGLTexture* TextureManager::getTexture(const std::string& texturePath) +{ + return getTexture(QString::fromStdString(texturePath)); +} + +QOpenGLTexture* TextureManager::getTexture(QString texturePath) +{ + if (texturePath.isEmpty()) { + return nullptr; + } + + auto key = texturePath.toLower().toStdWString(); + + auto cached = m_Textures.find(key); + if (cached != m_Textures.end()) { + return cached->second; + } + + auto texture = loadTexture(texturePath); + + m_Textures[key] = texture; + return texture; +} + +QOpenGLTexture* TextureManager::getErrorTexture() +{ + if (!m_ErrorTexture) { + m_ErrorTexture = makeSolidColor({1.0f, 0.0f, 1.0f, 1.0f}); + } + + return m_ErrorTexture; +} + +QOpenGLTexture* TextureManager::getBlackTexture() +{ + if (!m_BlackTexture) { + m_BlackTexture = makeSolidColor({0.0f, 0.0f, 0.0f, 1.0f}); + } + + return m_BlackTexture; +} + +QOpenGLTexture* TextureManager::getWhiteTexture() +{ + if (!m_WhiteTexture) { + m_WhiteTexture = makeSolidColor({1.0f, 1.0f, 1.0f, 1.0f}); + } + + return m_WhiteTexture; +} + +QOpenGLTexture* TextureManager::getFlatNormalTexture() +{ + if (!m_FlatNormalTexture) { + m_FlatNormalTexture = makeSolidColor({0.5f, 0.5f, 1.0f, 1.0f}); + } + + return m_FlatNormalTexture; +} + +QOpenGLTexture* TextureManager::loadTexture(QString texturePath) +{ + if (texturePath.isEmpty()) { + return nullptr; + } + + auto game = m_MOInfo->managedGame(); + + if (!game) { + return nullptr; + } + + // 1) Loose file via MO2's virtual tree (honors mod priority + Overwrite). + auto realPath = resolvePath(game, texturePath); + if (!realPath.isEmpty()) { + return makeTexture(gli::load(realPath.toStdString())); + } + + // 2) BSA fallback. Walk cached priority-sorted candidate list. + auto tryExtract = [&](const QString& bsaPath) -> QOpenGLTexture* { + using bsa_ptr = std::unique_ptr<void, decltype(&bsa_free)>; + auto bsa = bsa_ptr(bsa_create(), bsa_free); + + const std::wstring bsaPathW = bsaPath.toStdWString(); + const std::wstring texturePathW = texturePath.toStdWString(); + + bsa_result_message_t result = + bsa_load_from_file(bsa.get(), bsaPathW.c_str()); + if (result.code == BSA_RESULT_EXCEPTION) { + return nullptr; + } + + auto result_buffer = + bsa_extract_file_data_by_filename(bsa.get(), texturePathW.c_str()); + if (result_buffer.message.code == BSA_RESULT_EXCEPTION || + !result_buffer.buffer.data) { + return nullptr; + } + + auto buffer_free = [&bsa](bsa_result_buffer_t* buffer) { + bsa_file_data_free(bsa.get(), *buffer); + }; + using buffer_ptr = + std::unique_ptr<bsa_result_buffer_t, decltype(buffer_free)>; + auto buffer = buffer_ptr(&result_buffer.buffer, buffer_free); + + return makeTexture( + gli::load(static_cast<char*>(buffer->data), buffer->size)); + }; + + for (const QString& bsaPath : bsaCandidates()) { + if (auto* tex = tryExtract(bsaPath)) { + return tex; + } + } + + return nullptr; +} + +// Process-wide BSA candidate cache. Keyed by profile directory path so +// switching instances invalidates automatically. Built once, reused across +// every NIF preview until the profile changes. +namespace { + QString g_cachedProfileKey; + QStringList g_cachedBsaCandidates; +} + +const QStringList& TextureManager::bsaCandidates() +{ + QString profileKey; + if (auto profile = m_MOInfo->profile()) { + profileKey = profile->absolutePath(); + } + + if (profileKey == g_cachedProfileKey && !g_cachedBsaCandidates.isEmpty()) { + return g_cachedBsaCandidates; + } + + g_cachedBsaCandidates.clear(); + rebuildBsaCandidates(); + g_cachedProfileKey = profileKey; + return g_cachedBsaCandidates; +} + +void TextureManager::rebuildBsaCandidates() +{ + QSet<QString> seen; + + auto addCandidate = [&](const QString& p) { + QString norm = QDir::cleanPath(p); + if (!norm.isEmpty() && !seen.contains(norm)) { + seen.insert(norm); + g_cachedBsaCandidates.append(norm); + } + }; + + try { + auto game = m_MOInfo->managedGame(); + + if (auto* modList = m_MOInfo->modList()) { + auto profile = m_MOInfo->profile(); + QStringList modNames = + modList->allModsByProfilePriority(profile.get()); + + for (auto it = modNames.rbegin(); it != modNames.rend(); ++it) { + if (!(modList->state(*it) & MOBase::IModList::STATE_ACTIVE)) + continue; + + MOBase::IModInterface* mod = modList->getMod(*it); + if (!mod) continue; + + QDir modDir(mod->absolutePath()); + if (!modDir.exists()) continue; + + const QStringList bsas = + modDir.entryList(QStringList{"*.bsa", "*.ba2"}, + QDir::Files | QDir::NoDotAndDotDot); + for (const QString& name : bsas) { + addCandidate(modDir.absoluteFilePath(name)); + } + } + } + + // Vanilla/game archives from DataArchives ini list. + if (auto* features = m_MOInfo->gameFeatures()) { + if (auto gameArchives = + features->gameFeature<MOBase::DataArchives>()) { + auto profile = m_MOInfo->profile(); + auto archives = gameArchives->archives(profile.get()); + for (auto it = archives.rbegin(); it != archives.rend(); ++it) { + QString resolved = resolvePath(game, *it); + if (!resolved.isEmpty()) addCandidate(resolved); + } + } + } + } catch (...) { + } +} + + +QOpenGLTexture* TextureManager::makeTexture(const gli::texture& texture) +{ + if (texture.empty()) { + return nullptr; + } + + gli::gl GL(gli::gl::PROFILE_GL32); + const gli::gl::format format = GL.translate(texture.format(), texture.swizzles()); + GLenum target = GL.translate(texture.target()); + + auto f = QOpenGLVersionFunctionsFactory::get<QOpenGLFunctions_2_1>( + QOpenGLContext::currentContext()); + QOpenGLTexture* glTexture = + new QOpenGLTexture(static_cast<QOpenGLTexture::Target>(target)); + + glTexture->create(); + glTexture->bind(); + glTexture->setMipLevels(texture.levels()); + glTexture->setMipBaseLevel(0); + glTexture->setMipMaxLevel(texture.levels() - 1); + glTexture->setMinMagFilters(QOpenGLTexture::LinearMipMapLinear, + QOpenGLTexture::Linear); + glTexture->setSwizzleMask( + static_cast<QOpenGLTexture::SwizzleValue>(format.Swizzles[0]), + static_cast<QOpenGLTexture::SwizzleValue>(format.Swizzles[1]), + static_cast<QOpenGLTexture::SwizzleValue>(format.Swizzles[2]), + static_cast<QOpenGLTexture::SwizzleValue>(format.Swizzles[3])); + + glTexture->setWrapMode(QOpenGLTexture::Repeat); + + auto extent = texture.extent(); + const GLsizei faceTotal = texture.layers() * texture.faces(); + + glTexture->setSize(extent.x, extent.y, extent.z); + glTexture->setFormat(static_cast<QOpenGLTexture::TextureFormat>(format.Internal)); + glTexture->allocateStorage( + static_cast<QOpenGLTexture::PixelFormat>(format.External), + static_cast<QOpenGLTexture::PixelType>(format.Type)); + + for (std::size_t layer = 0; layer < texture.layers(); layer++) + for (std::size_t face = 0; face < texture.faces(); face++) + for (std::size_t level = 0; level < texture.levels(); level++) { + auto extent = texture.extent(level); + + target = + gli::is_target_cube(texture.target()) + ? static_cast<GLenum>(GL_TEXTURE_CUBE_MAP_POSITIVE_X + face) + : target; + + // Qt's upload functions lag badly so we just use the GL API + switch (texture.target()) { + case gli::TARGET_1D: + if (gli::is_compressed(texture.format())) { + f->glCompressedTexSubImage1D( + target, level, 0, extent.x, format.Internal, + texture.size(level), texture.data(layer, face, level)); + } + else { + f->glTexSubImage1D(target, level, 0, extent.x, format.External, + format.Type, + texture.data(layer, face, level)); + } + break; + case gli::TARGET_1D_ARRAY: + case gli::TARGET_2D: + case gli::TARGET_CUBE: + if (gli::is_compressed(texture.format())) { + f->glCompressedTexSubImage2D( + target, level, 0, 0, extent.x, + texture.target() == gli::TARGET_1D_ARRAY ? layer : extent.y, + format.Internal, texture.size(level), + texture.data(layer, face, level)); + } + else { + f->glTexSubImage2D( + target, level, 0, 0, extent.x, + texture.target() == gli::TARGET_1D_ARRAY ? layer : extent.y, + format.External, format.Type, + texture.data(layer, face, level)); + } + break; + case gli::TARGET_2D_ARRAY: + case gli::TARGET_3D: + case gli::TARGET_CUBE_ARRAY: + if (gli::is_compressed(texture.format())) { + f->glCompressedTexSubImage3D( + target, level, 0, 0, 0, extent.x, extent.y, + texture.target() == gli::TARGET_3D ? extent.z : layer, + format.Internal, texture.size(level), + texture.data(layer, face, level)); + } + else { + f->glTexSubImage3D(target, level, 0, 0, 0, extent.x, extent.y, + texture.target() == gli::TARGET_3D ? extent.z + : layer, + format.External, format.Type, + texture.data(layer, face, level)); + } + break; + } + } + + glTexture->release(); + + return glTexture; +} + +QOpenGLTexture* TextureManager::makeSolidColor(QVector4D color) +{ + QOpenGLTexture* glTexture = new QOpenGLTexture(QOpenGLTexture::Target2D); + glTexture->create(); + glTexture->bind(); + + glTexture->setSize(1, 1); + glTexture->setFormat(QOpenGLTexture::RGBA32F); + glTexture->allocateStorage(QOpenGLTexture::RGBA, QOpenGLTexture::Float32); + + glTexture->setData(QOpenGLTexture::RGBA, QOpenGLTexture::Float32, &color); + + glTexture->release(); + + return glTexture; +} + +// Case-insensitive walk of a path relative to a root dir. Used because the +// Linux FS is case-sensitive but Bethesda NIFs reference textures with +// arbitrary case. We fix the case component-by-component against the real +// on-disk filenames. +static QString resolveCaseInsensitive(const QString& rootAbs, const QString& relPath) +{ + QStringList parts = QDir::cleanPath(relPath).split('/', Qt::SkipEmptyParts); + QString cursor = rootAbs; + for (const QString& part : parts) { + QDir cur(cursor); + if (!cur.exists()) return ""; + const QStringList entries = + cur.entryList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot); + QString match; + for (const QString& entry : entries) { + if (entry.compare(part, Qt::CaseInsensitive) == 0) { + match = entry; + break; + } + } + if (match.isEmpty()) return ""; + cursor = cur.absoluteFilePath(match); + } + return QFileInfo::exists(cursor) ? cursor : QString(); +} + +QString TextureManager::resolvePath(const MOBase::IPluginGame* game, QString path) +{ + // NIF texture paths use Windows backslashes. On Linux, IOrganizer's + // virtual tree keys on forward-slash paths, so normalize here before + // lookup. BSA lookups keep the raw backslash form separately. + QString normalized = path; + normalized.replace('\\', '/'); + + // MO2's virtual tree may return a path that matches case-insensitively + // against its internal index but doesn't match the real on-disk case. + // Verify existence — if the returned path doesn't actually exist, do a + // case-insensitive walk against the containing mod directory to recover + // the correct case. + auto virtPath = m_MOInfo->resolvePath(normalized); + if (!virtPath.isEmpty()) { + if (QFileInfo::exists(virtPath)) { + return virtPath; + } + // Path wasn't valid — walk up to find the mod root, then re-resolve + // case-insensitively under it. We detect the mod root by looking for + // the "textures/" segment (NIF texture paths are always rooted at + // textures/). + const int texIdx = virtPath.indexOf("/textures/", 0, Qt::CaseInsensitive); + if (texIdx > 0) { + const QString modRoot = virtPath.left(texIdx); + const QString rel = virtPath.mid(texIdx + 1); + const QString fixed = resolveCaseInsensitive(modRoot, rel); + if (!fixed.isEmpty()) return fixed; + } + } + + auto dataDir = game->dataDirectory(); + auto dataPath = dataDir.absoluteFilePath(QDir::cleanPath(normalized)); + if (QFileInfo::exists(dataPath)) { + return dataPath; + } + + // Final fallback: case-insensitive walk under the game data directory. + return resolveCaseInsensitive(dataDir.absolutePath(), normalized); +} |
