From 3949dcfce95af4bd305f258ff5b170d7d50435f6 Mon Sep 17 00:00:00 2001 From: SulfurNitride Date: Sun, 12 Apr 2026 21:04:15 -0500 Subject: VFS perf fixes, icon/stylesheet compat, download filename fix VFS: - Open backing fd at mo2_open time for read-only handles so mo2_read can splice without re-opening the file on every read call. - Bump RLIMIT_NOFILE at mount time to fit the resulting fd pressure when the game keeps hundreds of BSAs open. - Dedicated node_cache_mutex so resolveByInode / mo2_lookup stop racing unordered_map writes while multiple tree_mutex readers are active. - max_read=1MB + matching conn->max_read and raised max_readahead/max_write so the kernel merges Wine's small sequential reads into bigger FUSE requests. - Per-op wall-clock counters in mo2_init() logs so we can distinguish VFS latency from game-side work. Proton launch: - Write dxvk.conf to the prefix and set DXVK_CONFIG_FILE to force dxvk.enableGraphicsPipelineLibrary=False, avoiding long GPL compile stalls on first run. UI / plugin compat: - Clamp IconDelegate's per-icon width to [8, 16] so narrow content columns don't trigger QIcon::pixmap(0,0) returning null and logging "failed to load icon" every repaint. - Pre-create lowercase symlinks in the stylesheet directory so QSS files authored on Windows resolve url(foo.svg) when on-disk files are Foo.svg. - Flip content icons empty-chessboard.png and facegen.png to 8-bit RGBA so Qt's built-in PNG reader loads them uniformly. - Add mobase.Version.canonicalString shim for legacy Python plugins that still call the old VersionInfo method on appVersion()'s return. - BSPluginList: compare normalized plugin name lists instead of byte hashes so the post-run case-fix refresh stops spuriously firing the "load order changed" dialog. - BSPluginList disabled by default. Download manager: - Parse CDN URL with QUrl + QUrlQuery and read the filename from the response-content-disposition query param (RFC 6266 quoted / unquoted / ext-value forms), not QFileInfo on the raw URL. - When the API or URL gives us a CDN object key (no archive extension), prefer the actual Content-Disposition header from the live response in metaDataChanged and downloadFinished. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/BSPluginInfo/PluginRecordModel.cpp | 352 +++++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 libs/installer_bsplugins/src/BSPluginInfo/PluginRecordModel.cpp (limited to 'libs/installer_bsplugins/src/BSPluginInfo/PluginRecordModel.cpp') diff --git a/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordModel.cpp b/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordModel.cpp new file mode 100644 index 0000000..9bc554e --- /dev/null +++ b/libs/installer_bsplugins/src/BSPluginInfo/PluginRecordModel.cpp @@ -0,0 +1,352 @@ +#include "PluginRecordModel.h" +#include "TESData/BranchConflictParser.h" +#include "TESData/TypeStringNames.h" +#include "TESFile/Reader.h" + +#include + +#include +#include +#include + +using namespace Qt::Literals::StringLiterals; + +namespace BSPluginInfo +{ + +PluginRecordModel::PluginRecordModel(MOBase::IOrganizer* organizer, + TESData::PluginList* pluginList, + const QString& pluginName) + : m_PluginName{pluginName}, m_Organizer{organizer}, m_PluginList{pluginList}, + m_FileEntry{pluginList->findEntryByName(pluginName.toStdString())} +{ + if (m_FileEntry) { + m_DataRoot = m_FileEntry->dataRoot(); + } +} + +TESData::RecordPath PluginRecordModel::getPath(const QModelIndex& index) const +{ + TESData::RecordPath path; + + std::vector parents; + const auto last = static_cast(index.internalPointer()); + for (auto item = last; item->parent; item = item->parent) { + parents.push_back(item); + } + + for (const auto& item : std::ranges::reverse_view(parents)) { + if (item->group.has_value()) { + path.push(item->group.value(), m_FileEntry->files(), m_PluginName.toStdString()); + } + } + + if (last->record) { + if (last->group.has_value()) { + path.pop(); + } + path.setIdentifier(last->record->identifier(), {&last->record->file(), 1}); + } + + return path; +} + +QModelIndex PluginRecordModel::index(int row, int column, + const QModelIndex& parent) const +{ + if (row < 0 || column < 0) + return QModelIndex(); + + const auto parentItem = parent.isValid() + ? static_cast(parent.internalPointer()) + : m_DataRoot; + + if (!parentItem || row >= parentItem->children.size()) + return QModelIndex(); + + const auto item = parentItem->children.nth(row)->second.get(); + return createIndex(row, column, item); +} + +QModelIndex PluginRecordModel::parent(const QModelIndex& index) const +{ + if (!index.isValid()) + return QModelIndex(); + + const auto item = static_cast(index.internalPointer()); + if (!item->parent || !item->parent->parent) { + return QModelIndex(); + } + + const auto& siblings = item->parent->parent->children; + const int row = static_cast( + siblings.index_of(std::ranges::find(siblings, item->parent, [&](auto&& pair) { + return pair.second.get(); + }))); + + return createIndex(row, 0, item->parent); +} + +bool PluginRecordModel::hasChildren(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return m_DataRoot && !m_DataRoot->children.empty(); + } + + const auto parentItem = static_cast(parent.internalPointer()); + return parentItem ? parentItem->group.has_value() : false; +} + +int PluginRecordModel::rowCount(const QModelIndex& parent) const +{ + const auto parentItem = + parent.isValid() ? static_cast(parent.internalPointer()) : m_DataRoot; + + return parentItem ? static_cast(parentItem->children.size()) : 0; +} + +int PluginRecordModel::columnCount([[maybe_unused]] const QModelIndex& parent) const +{ + return COL_COUNT; +} + +bool PluginRecordModel::canFetchMore(const QModelIndex& parent) const +{ + if (!parent.isValid()) { + return false; + } + + const auto parentItem = static_cast(parent.internalPointer()); + return parentItem && parentItem->group.has_value() && parentItem->children.empty(); +} + +void PluginRecordModel::fetchMore(const QModelIndex& parent) +{ + const auto parentItem = + parent.isValid() ? static_cast(parent.internalPointer()) : nullptr; + + if (!parentItem || !parentItem->children.empty() || !parentItem->group.has_value()) { + return; + } + + QList names; + if (parentItem->record) { + for (const auto handle : parentItem->record->alternatives()) { + const auto entry = m_PluginList->findEntryByHandle(handle); + names.append(QString::fromStdString(entry->name())); + } + } else { + names.append(m_PluginName); + } + + for (const auto& name : names) { + const auto vfsEntry = + m_Organizer->virtualFileTree()->find(name, MOBase::FileTreeEntry::FILE); + + const auto filePath = m_Organizer->resolvePath(name); + + try { + TESData::BranchConflictParser handler{m_PluginList, name.toStdString(), + getPath(parent)}; + TESFile::Reader reader{}; + reader.parse(std::filesystem::path(filePath.toStdString()), handler); + } catch (const std::exception& e) { + MOBase::log::error("Error parsing \"{}\": {}", filePath, e.what()); + } + } + + if (parentItem->children.empty()) { + beginRemoveRows(parent, 0, 0); + parentItem->group = std::nullopt; + endRemoveRows(); + } +} + +QVariant PluginRecordModel::data(const QModelIndex& index, int role) const +{ + const auto item = static_cast(index.internalPointer()); + + switch (role) { + case Qt::DisplayRole: + case Qt::EditRole: { + switch (index.column()) { + case COL_ID: { + if (item->record) { + if (item->record->hasFormId()) { + std::uint32_t formId = item->record->formId() & 0xFFFFFFU; + + const auto plugin = + m_PluginList ? m_PluginList->getPluginByName(m_PluginName) : nullptr; + + if (plugin) { + const auto file = item->record->file(); + const auto localIndex = std::distance( + std::begin(plugin->masters()), + TESFile::find(plugin->masters(), file, &QString::toStdString)); + formId |= ((localIndex & 0xFF) << 24U); + } + + QString str = u"%1"_s.arg(formId, 8, 16, QChar(u'0')).toUpper(); + + for (const Item* p = item->parent; p; p = p->parent) { + if (p->group && p->group->hasFormType()) { + if (item->formType != p->group->formType()) { + QString suffix = TESData::getFormName(item->formType).toString(); + if (suffix.isEmpty()) { + suffix = QString::fromLocal8Bit(item->formType.data(), + item->formType.size()); + } + str = u"%1 %2"_s.arg(str).arg(suffix); + } + break; + } + } + + return str; + + } else if (item->record->hasEditorId()) { + return QString::fromStdString(item->record->editorId()); + + } else if (item->record->hasTypeId()) { + const auto type = item->record->typeId(); + const auto typestr = QString::fromLocal8Bit(type.data(), type.size()); + const auto name = TESData::getDefaultObjectName(type); + if (!name.isEmpty()) { + return u"%1 - %2"_s.arg(typestr).arg(name); + } else { + return typestr; + } + } + } + + if (item->group.has_value()) { + return makeGroupName(item->group.value()); + } + + return QVariant(); + } + + case COL_OWNER: { + if (item->record && item->record->hasFormId()) { + const auto file = item->record->file(); + return QString::fromStdString(file); + } + return QVariant(); + } + + case COL_NAME: { + if (item->record && item->record->hasFormId()) { + return QString::fromStdString(item->name); + } + return QVariant(); + } + } + + return QVariant(); + } + + case Qt::BackgroundRole: { + if (!m_PluginList || !m_FileEntry || !item->record) { + return QVariant(); + } + + const auto info = m_PluginList->getPluginByName(m_PluginName); + if (!info) { + return QVariant(); + } + + bool isConflicted = false; + for (const auto alternative : item->record->alternatives()) { + const auto altEntry = m_PluginList->findEntryByHandle(alternative); + if (!altEntry || altEntry == m_FileEntry) + continue; + + const auto altInfo = + m_PluginList->getPluginByName(QString::fromStdString(altEntry->name())); + if (!altInfo || !altInfo->enabled()) + continue; + + isConflicted = true; + if (altInfo->priority() > info->priority()) { + return QColor(255, 0, 0, 64); + } + } + + if (isConflicted) { + return QColor(0, 255, 0, 64); + } else { + return QVariant(); + } + } + + case Qt::UserRole: { + return QVariant::fromValue(item); + } + } + + return QVariant(); +} + +QString PluginRecordModel::makeGroupName(TESFile::GroupData group) +{ + using GroupType = TESFile::GroupType; + + switch (group.type()) { + case GroupType::Top: { + const auto type = group.formType(); + const auto typestr = QString::fromLocal8Bit(type.data(), type.size()); + const auto name = TESData::getFormName(type); + if (!name.isEmpty()) { + return u"%1 - %2"_s.arg(typestr).arg(name); + } else { + return typestr; + } + } + case GroupType::WorldChildren: + return tr("Children"); + case GroupType::InteriorCellBlock: + return tr("Block %1").arg(group.block()); + case GroupType::InteriorCellSubBlock: + return tr("Sub-Block %1").arg(group.block()); + case GroupType::ExteriorCellBlock: { + const auto [x, y] = group.gridCell(); + return tr("Block %1, %2").arg(x).arg(y); + } + case GroupType::ExteriorCellSubBlock: { + const auto [x, y] = group.gridCell(); + return tr("Sub-Block %1, %2").arg(x).arg(y); + } + case GroupType::CellChildren: + return tr("Children"); + case GroupType::TopicChildren: + return tr("Children"); + case GroupType::CellPersistentChildren: + return tr("Persistent"); + case GroupType::CellTemporaryChildren: + return tr("Temporary"); + case GroupType::CellVisibleDistantChildren: + return tr("Visible Distant"); + default: + return tr("Children"); + } +} + +QVariant PluginRecordModel::headerData(int section, Qt::Orientation orientation, + int role) const +{ + if (orientation != Qt::Horizontal || role != Qt::DisplayRole) + return QVariant(); + + switch (section) { + case COL_ID: + return tr("Form"); + case COL_OWNER: + return tr("Origin"); + case COL_NAME: + return tr("Editor ID"); + } + + return QVariant(); +} + +} // namespace BSPluginInfo -- cgit v1.3.1