mirror of
https://github.com/thejerf/baijiSteamVR.git
synced 2026-10-21 19:02:04 +02:00
667 lines
24 KiB
C++
667 lines
24 KiB
C++
// Copyright 2026 Dolphin Emulator Project
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
#include "DolphinQt/Config/TextureElementOverrideAddEditDialog.h"
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cstdlib>
|
|
|
|
#include <QCheckBox>
|
|
#include <QComboBox>
|
|
#include <QDialogButtonBox>
|
|
#include <QDir>
|
|
#include <QDoubleSpinBox>
|
|
#include <QFileInfo>
|
|
#include <QFormLayout>
|
|
#include <QFrame>
|
|
#include <QHBoxLayout>
|
|
#include <QLabel>
|
|
#include <QLineEdit>
|
|
#include <QMessageBox>
|
|
#include <QPixmap>
|
|
#include <QPlainTextEdit>
|
|
#include <QPushButton>
|
|
#include <QRegularExpression>
|
|
#include <QScrollArea>
|
|
#include <QSignalBlocker>
|
|
#include <QSpinBox>
|
|
#include <QStringList>
|
|
#include <QVBoxLayout>
|
|
#include <QWidget>
|
|
|
|
#include <fmt/format.h>
|
|
|
|
#include "Common/FileUtil.h"
|
|
|
|
#include "DolphinQt/Config/TextureHashBrowserDialog.h"
|
|
#include "DolphinQt/QtUtils/DolphinFileDialog.h"
|
|
|
|
namespace
|
|
{
|
|
constexpr double UPM_OVERRIDE_MIN = 0.01;
|
|
constexpr double UPM_OVERRIDE_MAX = 1000.0;
|
|
constexpr double UPM_OVERRIDE_STEP = 0.01;
|
|
constexpr int MAX_VISIBLE_TEXTURE_HASH_ROWS = 6;
|
|
constexpr int TEXTURE_HASH_PREVIEW_SIZE = 40; // Thumbnail square, in pixels.
|
|
constexpr int TEXTURE_HASH_ROW_HEIGHT = TEXTURE_HASH_PREVIEW_SIZE + 6;
|
|
|
|
// Extract the texture's XXH64 hash from a dumped texture filename such as
|
|
// "tex1_128x128_017fce12e592f4da_14.png". This mirrors ParseXXH64FromTextureName() in
|
|
// TextureCacheBase.cpp, which the override matcher uses on the bound texture, so an imported
|
|
// filename resolves to the exact same hash the game will match against at runtime.
|
|
u64 ParseHashFromDumpFilename(const std::string& filename)
|
|
{
|
|
// Skip past "tex1_WxH_" to the hash: find the second underscore, then read 16 hex chars.
|
|
size_t pos = filename.find('_');
|
|
if (pos == std::string::npos)
|
|
return 0;
|
|
pos = filename.find('_', pos + 1);
|
|
if (pos == std::string::npos || pos + 17 > filename.size())
|
|
return 0;
|
|
|
|
const std::string hex = filename.substr(pos + 1, 16);
|
|
for (char c : hex)
|
|
{
|
|
if (!std::isxdigit(static_cast<unsigned char>(c)))
|
|
return 0;
|
|
}
|
|
return std::strtoull(hex.c_str(), nullptr, 16);
|
|
}
|
|
} // namespace
|
|
|
|
using HandlingType = TextureElementManager::HandlingType;
|
|
using TextureElementOverride = TextureElementManager::TextureElementOverride;
|
|
|
|
TextureElementOverrideAddEditDialog::TextureElementOverrideAddEditDialog(
|
|
QWidget* parent, std::string game_id, const TextureElementOverride* edit_override)
|
|
: QDialog(parent), m_game_id(std::move(game_id))
|
|
{
|
|
setWindowTitle(edit_override ? tr("Edit Texture Element Override") :
|
|
tr("Add Texture Element Override"));
|
|
setMinimumWidth(350);
|
|
|
|
m_name_edit = new QLineEdit;
|
|
m_name_edit->setPlaceholderText(tr("Override name..."));
|
|
|
|
m_comments_edit = new QPlainTextEdit;
|
|
m_comments_edit->setPlaceholderText(tr("Optional notes..."));
|
|
m_comments_edit->setTabChangesFocus(true);
|
|
// m_comments_edit->setMinimumHeight(30);
|
|
|
|
m_handling_combo = new QComboBox;
|
|
m_handling_combo->addItem(tr("Skip"), static_cast<int>(HandlingType::Skip));
|
|
m_handling_combo->addItem(tr("Screen"), static_cast<int>(HandlingType::Screen));
|
|
m_handling_combo->addItem(tr("Fullscreen"), static_cast<int>(HandlingType::Fullscreen));
|
|
m_handling_combo->addItem(tr("Head Locked"), static_cast<int>(HandlingType::HeadLocked));
|
|
m_handling_combo->addItem(tr("Units per Meter"), static_cast<int>(HandlingType::UnitsPerMeter));
|
|
m_handling_combo->addItem(tr("Passthrough"), static_cast<int>(HandlingType::Passthrough));
|
|
m_handling_combo->setToolTip(
|
|
tr("How every draw that binds a listed texture is handled in VR.\n"
|
|
"Skip = hide, Screen = world-fixed, Head Locked = follows head, Fullscreen = no VR,\n"
|
|
"Passthrough = pixels become a see-through window to the headset camera."));
|
|
|
|
m_layer_label = new QLabel(tr("Layer:"));
|
|
m_layer_spin = new QSpinBox;
|
|
m_layer_spin->setRange(-1, 999);
|
|
m_layer_spin->setSpecialValueText(tr("Auto"));
|
|
m_layer_spin->setValue(-1);
|
|
m_layer_spin->setToolTip(tr("Manual depth layer for Screen/Head Locked handling.\n"
|
|
"-1 (Auto) = automatic per-draw counter.\n"
|
|
"Higher values are closer to camera."));
|
|
|
|
m_element_depth_label = new QLabel(tr("Element Depth:"));
|
|
m_element_depth_spin = new QDoubleSpinBox;
|
|
m_element_depth_spin->setRange(-1.0, 0.01);
|
|
m_element_depth_spin->setDecimals(4);
|
|
m_element_depth_spin->setSingleStep(0.0001);
|
|
m_element_depth_spin->setSpecialValueText(tr("Global"));
|
|
m_element_depth_spin->setValue(-1.0);
|
|
m_element_depth_spin->setToolTip(tr("Within-element depth range for these textures.\n"
|
|
"-1 (Global) = use the global Element Depth setting."));
|
|
|
|
m_units_per_meter_label = new QLabel(tr("Units per Meter:"));
|
|
m_units_per_meter_spin = new QDoubleSpinBox;
|
|
m_units_per_meter_spin->setRange(UPM_OVERRIDE_MIN, UPM_OVERRIDE_MAX);
|
|
m_units_per_meter_spin->setDecimals(2);
|
|
m_units_per_meter_spin->setSingleStep(UPM_OVERRIDE_STEP);
|
|
m_units_per_meter_spin->setValue(1.0);
|
|
m_units_per_meter_spin->setToolTip(tr("Temporary per-texture scale override for VR.\n"
|
|
"Higher values make these textures appear larger."));
|
|
|
|
m_passthrough_opacity_label = new QLabel(tr("Opacity:"));
|
|
m_passthrough_opacity_spin = new QDoubleSpinBox;
|
|
m_passthrough_opacity_spin->setRange(0.0, 1.0);
|
|
m_passthrough_opacity_spin->setDecimals(2);
|
|
m_passthrough_opacity_spin->setSingleStep(0.05);
|
|
m_passthrough_opacity_spin->setValue(0.0);
|
|
m_passthrough_opacity_spin->setToolTip(
|
|
tr("How opaque these textures stay over the headset camera feed.\n"
|
|
"0.00 = fully see-through (pure passthrough window).\n"
|
|
"1.00 = fully opaque (no passthrough).\n"
|
|
"Requires the VR Passthrough setting to be enabled."));
|
|
|
|
m_view_textures_button = new QPushButton(tr("Texture Hunter"));
|
|
m_view_textures_button->setToolTip(
|
|
tr("Browse currently-loaded textures and check the ones to add to this override."));
|
|
|
|
m_import_textures_button = new QPushButton(tr("Import Texture"));
|
|
m_import_textures_button->setToolTip(
|
|
tr("Add textures by picking dumped texture files. Their hashes are read from the file names\n"
|
|
"and merged into the list below. Opens the game's texture dump folder by default."));
|
|
|
|
m_texture_hash_container = new QWidget;
|
|
m_texture_hash_layout = new QVBoxLayout(m_texture_hash_container);
|
|
m_texture_hash_layout->setContentsMargins(0, 0, 0, 0);
|
|
m_texture_hash_layout->setSpacing(4);
|
|
m_texture_hash_scroll = new QScrollArea;
|
|
m_texture_hash_scroll->setWidgetResizable(true);
|
|
m_texture_hash_scroll->setFrameShape(QFrame::NoFrame);
|
|
m_texture_hash_scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
m_texture_hash_scroll->setWidget(m_texture_hash_container);
|
|
AddTextureHashField(QString());
|
|
|
|
// Pre-fill fields in edit mode.
|
|
if (edit_override)
|
|
{
|
|
m_name_edit->setText(QString::fromStdString(edit_override->name));
|
|
m_comments_edit->setPlainText(QString::fromStdString(edit_override->comments));
|
|
|
|
const int handling_idx = m_handling_combo->findData(static_cast<int>(edit_override->handling));
|
|
if (handling_idx >= 0)
|
|
m_handling_combo->setCurrentIndex(handling_idx);
|
|
|
|
m_layer_spin->setValue(edit_override->layer);
|
|
m_element_depth_spin->setValue(edit_override->element_depth);
|
|
if (edit_override->units_per_meter > 0.0f)
|
|
m_units_per_meter_spin->setValue(edit_override->units_per_meter);
|
|
m_passthrough_opacity_spin->setValue(edit_override->passthrough_opacity);
|
|
|
|
m_updating_texture_hash_fields = true;
|
|
while (m_texture_hash_edits.size() < edit_override->texture_hashes.size())
|
|
AddTextureHashField(QString());
|
|
for (size_t i = 0; i < edit_override->texture_hashes.size(); i++)
|
|
{
|
|
m_texture_hash_edits[i]->setText(
|
|
QString::fromStdString(fmt::format("{:016x}", edit_override->texture_hashes[i])));
|
|
}
|
|
for (size_t i = edit_override->texture_hashes.size(); i < m_texture_hash_edits.size(); i++)
|
|
m_texture_hash_edits[i]->clear();
|
|
m_updating_texture_hash_fields = false;
|
|
EnsureTextureHashFieldRows();
|
|
}
|
|
|
|
auto* form = new QFormLayout;
|
|
form->addRow(tr("Name:"), m_name_edit);
|
|
form->addRow(tr("Handling:"), m_handling_combo);
|
|
form->addRow(m_layer_label, m_layer_spin);
|
|
form->addRow(m_element_depth_label, m_element_depth_spin);
|
|
form->addRow(m_units_per_meter_label, m_units_per_meter_spin);
|
|
form->addRow(m_passthrough_opacity_label, m_passthrough_opacity_spin);
|
|
form->addRow(QString(), m_view_textures_button);
|
|
form->addRow(QString(), m_import_textures_button);
|
|
form->addRow(tr("Texture Hashes:"), m_texture_hash_scroll);
|
|
form->addRow(tr("Comments:"), m_comments_edit);
|
|
|
|
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
|
|
connect(buttons, &QDialogButtonBox::accepted, this,
|
|
&TextureElementOverrideAddEditDialog::OnAccept);
|
|
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
|
|
connect(m_handling_combo, QOverload<int>::of(&QComboBox::currentIndexChanged), this,
|
|
&TextureElementOverrideAddEditDialog::OnHandlingChanged);
|
|
connect(m_view_textures_button, &QPushButton::clicked, this,
|
|
&TextureElementOverrideAddEditDialog::ShowTextureBrowser);
|
|
connect(m_import_textures_button, &QPushButton::clicked, this,
|
|
&TextureElementOverrideAddEditDialog::OnImportTextures);
|
|
|
|
auto* layout = new QVBoxLayout;
|
|
layout->addLayout(form);
|
|
layout->addWidget(buttons);
|
|
setLayout(layout);
|
|
|
|
OnHandlingChanged();
|
|
}
|
|
|
|
TextureElementOverride TextureElementOverrideAddEditDialog::GetResult() const
|
|
{
|
|
TextureElementOverride result;
|
|
result.name = m_name_edit->text().toStdString();
|
|
result.comments = m_comments_edit->toPlainText().trimmed().toStdString();
|
|
result.handling = static_cast<HandlingType>(m_handling_combo->currentData().toInt());
|
|
result.layer = (result.handling == HandlingType::Screen ||
|
|
result.handling == HandlingType::HeadLocked) ?
|
|
m_layer_spin->value() :
|
|
-1;
|
|
result.element_depth = (result.handling == HandlingType::Screen ||
|
|
result.handling == HandlingType::HeadLocked) ?
|
|
static_cast<float>(m_element_depth_spin->value()) :
|
|
-1.0f;
|
|
result.units_per_meter = result.handling == HandlingType::UnitsPerMeter ?
|
|
static_cast<float>(m_units_per_meter_spin->value()) :
|
|
-1.0f;
|
|
result.passthrough_opacity = result.handling == HandlingType::Passthrough ?
|
|
static_cast<float>(m_passthrough_opacity_spin->value()) :
|
|
0.0f;
|
|
|
|
for (const std::string& token : CollectTextureHashTokens())
|
|
{
|
|
const u64 parsed = std::strtoull(token.c_str(), nullptr, 16);
|
|
if (parsed != 0)
|
|
result.texture_hashes.push_back(parsed);
|
|
}
|
|
std::sort(result.texture_hashes.begin(), result.texture_hashes.end());
|
|
result.texture_hashes.erase(
|
|
std::unique(result.texture_hashes.begin(), result.texture_hashes.end()),
|
|
result.texture_hashes.end());
|
|
|
|
result.enabled = true;
|
|
return result;
|
|
}
|
|
|
|
void TextureElementOverrideAddEditDialog::OnAccept()
|
|
{
|
|
if (m_name_edit->text().trimmed().isEmpty())
|
|
{
|
|
QMessageBox::warning(this, tr("Validation Error"), tr("Name cannot be empty."));
|
|
return;
|
|
}
|
|
|
|
const auto texture_tokens = CollectTextureHashTokens();
|
|
if (texture_tokens.empty())
|
|
{
|
|
QMessageBox::warning(this, tr("Validation Error"),
|
|
tr("Add at least one texture hash."));
|
|
return;
|
|
}
|
|
for (const std::string& token : texture_tokens)
|
|
{
|
|
for (char c : token)
|
|
{
|
|
if (!std::isxdigit(static_cast<unsigned char>(c)))
|
|
{
|
|
QMessageBox::warning(this, tr("Validation Error"),
|
|
tr("Texture hashes must contain only hexadecimal characters "
|
|
"(0-9, a-f)."));
|
|
return;
|
|
}
|
|
}
|
|
if (token.size() > 16)
|
|
{
|
|
QMessageBox::warning(this, tr("Validation Error"),
|
|
tr("Each texture hash must be at most 16 hex digits."));
|
|
return;
|
|
}
|
|
}
|
|
|
|
const auto handling = static_cast<HandlingType>(m_handling_combo->currentData().toInt());
|
|
if (handling == HandlingType::UnitsPerMeter && m_units_per_meter_spin->value() <= 0.0)
|
|
{
|
|
QMessageBox::warning(this, tr("Validation Error"),
|
|
tr("Units per Meter must be greater than 0."));
|
|
return;
|
|
}
|
|
|
|
accept();
|
|
}
|
|
|
|
void TextureElementOverrideAddEditDialog::OnHandlingChanged()
|
|
{
|
|
const auto handling = static_cast<HandlingType>(m_handling_combo->currentData().toInt());
|
|
const bool show_layer =
|
|
(handling == HandlingType::Screen || handling == HandlingType::HeadLocked);
|
|
const bool show_units_per_meter = (handling == HandlingType::UnitsPerMeter);
|
|
const bool show_passthrough = (handling == HandlingType::Passthrough);
|
|
|
|
m_layer_label->setVisible(show_layer);
|
|
m_layer_spin->setVisible(show_layer);
|
|
m_element_depth_label->setVisible(show_layer);
|
|
m_element_depth_spin->setVisible(show_layer);
|
|
m_units_per_meter_label->setVisible(show_units_per_meter);
|
|
m_units_per_meter_spin->setVisible(show_units_per_meter);
|
|
m_passthrough_opacity_label->setVisible(show_passthrough);
|
|
m_passthrough_opacity_spin->setVisible(show_passthrough);
|
|
}
|
|
|
|
void TextureElementOverrideAddEditDialog::ShowTextureBrowser()
|
|
{
|
|
TextureHashBrowserConfig browser_config;
|
|
browser_config.title = tr("Texture Hunter");
|
|
browser_config.empty_info_text =
|
|
tr("No textures captured yet.\n"
|
|
"Start a game in VR so its textures can be enumerated.");
|
|
browser_config.current_label = tr("currently loaded");
|
|
browser_config.initial_selected_hashes = CollectTextureHashValues();
|
|
browser_config.fetch_current_entries = []() {
|
|
std::vector<TextureHashBrowserEntry> entries;
|
|
for (const auto& tex : TextureElementManager::GetInstance().GetCurrentTextures())
|
|
entries.push_back(TextureHashBrowserEntry{.hash = tex.hash, .name = tex.name});
|
|
return entries;
|
|
};
|
|
browser_config.apply_selected_hashes = [this](const std::vector<u64>& hashes) {
|
|
SetTextureHashFields(hashes);
|
|
};
|
|
// Live preview: as textures are checked, skip/pink-highlight their draws in-game.
|
|
browser_config.live_selection_changed = [](const std::vector<u64>& hashes) {
|
|
TextureElementManager::GetInstance().SetPreviewTextures(hashes);
|
|
};
|
|
// Preview mode (Skip/Pink) toggle shown in the browser window.
|
|
browser_config.preview_mode_changed = [](bool pink) {
|
|
TextureElementManager::GetInstance().SetPreviewPink(pink);
|
|
};
|
|
|
|
// Enable global texture capture while the browser is open; restore on close.
|
|
TextureElementManager::GetInstance().SetHunterActive(true);
|
|
auto* dlg = ShowTextureHashBrowserDialog(this, browser_config);
|
|
connect(dlg, &QObject::destroyed, this,
|
|
[]() { TextureElementManager::GetInstance().SetHunterActive(false); });
|
|
}
|
|
|
|
void TextureElementOverrideAddEditDialog::OnImportTextures()
|
|
{
|
|
// Default to the game's texture dump folder (…/Dump/Textures/<GameID>) when it exists, falling
|
|
// back to the base dump folder otherwise.
|
|
QString start_dir;
|
|
if (!m_game_id.empty())
|
|
{
|
|
const std::string game_dump_dir = File::GetUserPath(D_DUMPTEXTURES_IDX) + m_game_id;
|
|
if (File::IsDirectory(game_dump_dir))
|
|
start_dir = QString::fromStdString(game_dump_dir);
|
|
}
|
|
if (start_dir.isEmpty())
|
|
start_dir = QString::fromStdString(File::GetUserPath(D_DUMPTEXTURES_IDX));
|
|
|
|
const QStringList files = DolphinFileDialog::getOpenFileNames(
|
|
this, tr("Import Textures"), start_dir,
|
|
QStringLiteral("%1 (*.png *.dds);;%2 (*)").arg(tr("Texture Files")).arg(tr("All Files")));
|
|
if (files.isEmpty())
|
|
return;
|
|
|
|
std::vector<u64> imported_hashes;
|
|
QStringList unparsed_files;
|
|
for (const QString& path : files)
|
|
{
|
|
const std::string filename = QFileInfo(path).fileName().toStdString();
|
|
const u64 hash = ParseHashFromDumpFilename(filename);
|
|
if (hash != 0)
|
|
imported_hashes.push_back(hash);
|
|
else
|
|
unparsed_files.append(QFileInfo(path).fileName());
|
|
}
|
|
|
|
// Merge the imported hashes into whatever is already in the fields, then de-duplicate.
|
|
std::vector<u64> merged = CollectTextureHashValues();
|
|
merged.insert(merged.end(), imported_hashes.begin(), imported_hashes.end());
|
|
std::sort(merged.begin(), merged.end());
|
|
merged.erase(std::unique(merged.begin(), merged.end()), merged.end());
|
|
SetTextureHashFields(merged);
|
|
|
|
if (!unparsed_files.isEmpty())
|
|
{
|
|
QMessageBox::warning(
|
|
this, tr("Import Textures"),
|
|
tr("Could not read a texture hash from the following file name(s):\n\n%1")
|
|
.arg(unparsed_files.join(QLatin1Char('\n'))));
|
|
}
|
|
}
|
|
|
|
std::vector<std::string> TextureElementOverrideAddEditDialog::CollectTextureHashTokens() const
|
|
{
|
|
std::vector<std::string> tokens;
|
|
const QRegularExpression separators(QStringLiteral("[,;\\s]+"));
|
|
const QRegularExpression hex_exact(QStringLiteral("^[0-9A-Fa-f]{1,16}$"));
|
|
const QRegularExpression hex_16_anywhere(QStringLiteral("([0-9A-Fa-f]{16})"));
|
|
for (QLineEdit* edit : m_texture_hash_edits)
|
|
{
|
|
if (edit == nullptr)
|
|
continue;
|
|
const auto parts = edit->text().trimmed().split(separators, Qt::SkipEmptyParts);
|
|
for (const QString& part : parts)
|
|
{
|
|
if (hex_exact.match(part).hasMatch())
|
|
{
|
|
tokens.push_back(part.toLower().toStdString());
|
|
continue;
|
|
}
|
|
|
|
const auto match = hex_16_anywhere.match(part);
|
|
if (match.hasMatch())
|
|
{
|
|
tokens.push_back(match.captured(1).toLower().toStdString());
|
|
continue;
|
|
}
|
|
|
|
tokens.push_back(part.toStdString());
|
|
}
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
std::vector<u64> TextureElementOverrideAddEditDialog::CollectTextureHashValues() const
|
|
{
|
|
std::vector<u64> hashes;
|
|
for (const std::string& token : CollectTextureHashTokens())
|
|
{
|
|
if (token.empty() || token.size() > 16)
|
|
continue;
|
|
bool valid = true;
|
|
for (char c : token)
|
|
{
|
|
if (!std::isxdigit(static_cast<unsigned char>(c)))
|
|
{
|
|
valid = false;
|
|
break;
|
|
}
|
|
}
|
|
if (!valid)
|
|
continue;
|
|
const u64 parsed = std::strtoull(token.c_str(), nullptr, 16);
|
|
if (parsed != 0)
|
|
hashes.push_back(parsed);
|
|
}
|
|
std::sort(hashes.begin(), hashes.end());
|
|
hashes.erase(std::unique(hashes.begin(), hashes.end()), hashes.end());
|
|
return hashes;
|
|
}
|
|
|
|
void TextureElementOverrideAddEditDialog::SetTextureHashFields(const std::vector<u64>& hashes)
|
|
{
|
|
m_updating_texture_hash_fields = true;
|
|
while (m_texture_hash_edits.size() < hashes.size())
|
|
AddTextureHashField(QString());
|
|
|
|
for (size_t i = 0; i < hashes.size(); i++)
|
|
{
|
|
m_texture_hash_edits[i]->setText(QString::fromStdString(fmt::format("{:016x}", hashes[i])));
|
|
}
|
|
for (size_t i = hashes.size(); i < m_texture_hash_edits.size(); i++)
|
|
m_texture_hash_edits[i]->clear();
|
|
|
|
m_updating_texture_hash_fields = false;
|
|
EnsureTextureHashFieldRows();
|
|
}
|
|
|
|
void TextureElementOverrideAddEditDialog::AddTextureHashField(const QString& text)
|
|
{
|
|
auto* edit = new QLineEdit;
|
|
edit->setPlaceholderText(tr("Hex hash"));
|
|
edit->setToolTip(
|
|
tr("Texture hash to match.\n"
|
|
"Enter one hash per line; a new line is added automatically.\n"
|
|
"You can also paste multiple hashes separated by comma, semicolon, or spaces."));
|
|
edit->setText(text);
|
|
|
|
// Thumbnail of the matching dumped texture, shown beside the hash.
|
|
auto* preview = new QLabel;
|
|
preview->setFixedSize(TEXTURE_HASH_PREVIEW_SIZE, TEXTURE_HASH_PREVIEW_SIZE);
|
|
preview->setAlignment(Qt::AlignCenter);
|
|
|
|
auto* row = new QWidget;
|
|
auto* row_layout = new QHBoxLayout(row);
|
|
row_layout->setContentsMargins(0, 0, 0, 0);
|
|
row_layout->setSpacing(6);
|
|
row_layout->addWidget(edit, 1);
|
|
row_layout->addWidget(preview);
|
|
|
|
m_texture_hash_layout->addWidget(row);
|
|
m_texture_hash_edits.push_back(edit);
|
|
m_texture_hash_previews.push_back(preview);
|
|
m_texture_hash_rows.push_back(row);
|
|
|
|
connect(edit, &QLineEdit::textChanged, this, [this, edit, preview](const QString&) {
|
|
SanitizeTextureHashField(edit);
|
|
EnsureTextureHashFieldRows();
|
|
UpdateTextureHashPreview(edit, preview);
|
|
});
|
|
|
|
UpdateTextureHashPreview(edit, preview);
|
|
}
|
|
|
|
void TextureElementOverrideAddEditDialog::EnsureTextureHashFieldRows()
|
|
{
|
|
if (m_updating_texture_hash_fields)
|
|
return;
|
|
|
|
m_updating_texture_hash_fields = true;
|
|
|
|
int last_non_empty = -1;
|
|
for (int i = 0; i < static_cast<int>(m_texture_hash_edits.size()); i++)
|
|
{
|
|
if (!m_texture_hash_edits[i]->text().trimmed().isEmpty())
|
|
last_non_empty = i;
|
|
}
|
|
|
|
const int desired_rows = std::max(1, last_non_empty + 2);
|
|
|
|
while (static_cast<int>(m_texture_hash_edits.size()) < desired_rows)
|
|
AddTextureHashField(QString());
|
|
|
|
while (static_cast<int>(m_texture_hash_edits.size()) > desired_rows)
|
|
{
|
|
QWidget* row = m_texture_hash_rows.back();
|
|
m_texture_hash_rows.pop_back();
|
|
m_texture_hash_edits.pop_back();
|
|
m_texture_hash_previews.pop_back();
|
|
m_texture_hash_layout->removeWidget(row);
|
|
delete row; // Deletes its child edit and preview label too.
|
|
}
|
|
|
|
const int visible_rows = std::min(desired_rows, MAX_VISIBLE_TEXTURE_HASH_ROWS);
|
|
const int viewport_height = visible_rows * TEXTURE_HASH_ROW_HEIGHT;
|
|
m_texture_hash_scroll->setMinimumHeight(viewport_height);
|
|
m_texture_hash_scroll->setMaximumHeight(viewport_height);
|
|
|
|
m_updating_texture_hash_fields = false;
|
|
}
|
|
|
|
void TextureElementOverrideAddEditDialog::EnsureDumpIndex()
|
|
{
|
|
if (m_dump_index_built)
|
|
return;
|
|
m_dump_index_built = true; // Build once; keep true even on failure so we don't rescan.
|
|
|
|
if (m_game_id.empty())
|
|
return;
|
|
|
|
const QString dump_dir =
|
|
QString::fromStdString(File::GetUserPath(D_DUMPTEXTURES_IDX) + m_game_id);
|
|
QDir dir(dump_dir);
|
|
if (!dir.exists())
|
|
return;
|
|
|
|
static const QStringList filters{QStringLiteral("*.png"), QStringLiteral("*.dds")};
|
|
const QStringList files = dir.entryList(filters, QDir::Files, QDir::Name);
|
|
for (const QString& filename : files)
|
|
{
|
|
const u64 hash = ParseHashFromDumpFilename(filename.toStdString());
|
|
if (hash == 0)
|
|
continue;
|
|
// entryList is sorted by name, so the base texture ("…_14.png") is seen before its "_mip"/
|
|
// "_arb" variants; emplace keeps that first (base) match.
|
|
m_dump_hash_to_path.emplace(hash, dir.absoluteFilePath(filename).toStdString());
|
|
}
|
|
}
|
|
|
|
void TextureElementOverrideAddEditDialog::UpdateTextureHashPreview(QLineEdit* edit, QLabel* preview)
|
|
{
|
|
if (edit == nullptr || preview == nullptr)
|
|
return;
|
|
|
|
EnsureDumpIndex();
|
|
|
|
// Preview the first hash-looking token in the field.
|
|
u64 hash = 0;
|
|
const QRegularExpression hex_token(QStringLiteral("[0-9A-Fa-f]{1,16}"));
|
|
const auto match = hex_token.match(edit->text());
|
|
if (match.hasMatch())
|
|
hash = std::strtoull(match.captured(0).toLower().toStdString().c_str(), nullptr, 16);
|
|
|
|
const auto it = hash != 0 ? m_dump_hash_to_path.find(hash) : m_dump_hash_to_path.end();
|
|
if (it == m_dump_hash_to_path.end())
|
|
{
|
|
preview->clear();
|
|
preview->setToolTip(hash != 0 ? tr("No dumped texture found for this hash.") : QString());
|
|
return;
|
|
}
|
|
|
|
const QString path = QString::fromStdString(it->second);
|
|
const QPixmap pixmap(path);
|
|
if (pixmap.isNull())
|
|
{
|
|
preview->clear();
|
|
preview->setToolTip(QFileInfo(path).fileName());
|
|
return;
|
|
}
|
|
|
|
preview->setPixmap(
|
|
pixmap.scaled(preview->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
|
|
preview->setToolTip(QFileInfo(path).fileName());
|
|
}
|
|
|
|
void TextureElementOverrideAddEditDialog::SanitizeTextureHashField(QLineEdit* edit)
|
|
{
|
|
if (edit == nullptr || m_updating_texture_hash_fields)
|
|
return;
|
|
|
|
const QRegularExpression separators(QStringLiteral("[,;\\s]+"));
|
|
const QRegularExpression hex_exact(QStringLiteral("^[0-9A-Fa-f]{1,16}$"));
|
|
const QRegularExpression hex_16_anywhere(QStringLiteral("([0-9A-Fa-f]{16})"));
|
|
|
|
const auto parts = edit->text().trimmed().split(separators, Qt::SkipEmptyParts);
|
|
if (parts.isEmpty())
|
|
return;
|
|
|
|
QStringList sanitized_parts;
|
|
bool changed = false;
|
|
for (const QString& part : parts)
|
|
{
|
|
if (hex_exact.match(part).hasMatch())
|
|
{
|
|
const QString lowered = part.toLower();
|
|
sanitized_parts.push_back(lowered);
|
|
if (lowered != part)
|
|
changed = true;
|
|
continue;
|
|
}
|
|
|
|
const auto match = hex_16_anywhere.match(part);
|
|
if (match.hasMatch())
|
|
{
|
|
sanitized_parts.push_back(match.captured(1).toLower());
|
|
changed = true;
|
|
continue;
|
|
}
|
|
|
|
sanitized_parts.push_back(part);
|
|
}
|
|
|
|
if (!changed)
|
|
return;
|
|
|
|
const QString new_text = sanitized_parts.join(QStringLiteral(" "));
|
|
if (new_text == edit->text())
|
|
return;
|
|
|
|
const QSignalBlocker blocker(edit);
|
|
edit->setText(new_text);
|
|
}
|