// SPDX-FileCopyrightText: 2002-2024 PCSX2 Dev Team // SPDX-License-Identifier: GPL-3.0+ #include "pcsx2/SIO/Pad/Pad.h" #include "GameSummaryWidget.h" #include "SettingsWindow.h" #include "MainWindow.h" #include "QtHost.h" #include "QtProgressCallback.h" #include "QtUtils.h" #include "pcsx2/CDVD/IsoHasher.h" #include "pcsx2/GameDatabase.h" #include "pcsx2/GameList.h" #include "common/Error.h" #include "common/MD5Digest.h" #include "common/ScopedGuard.h" #include "common/StringUtil.h" #include "fmt/format.h" #include #include #include GameSummaryWidget::GameSummaryWidget(const GameList::Entry* entry, SettingsWindow* dialog, QWidget* parent) : m_dialog(dialog) { m_ui.setupUi(this); const QString base_path(QtHost::GetResourcesBasePath()); for (int i = 0; i < m_ui.region->count(); i++) { m_ui.region->setItemIcon(i, QIcon(QStringLiteral("%1/icons/flags/%2.png").arg(base_path).arg(GameList::RegionToString(static_cast(i))))); } m_entry_path = entry->path; populateInputProfiles(); populateDetails(entry); populateDiscPath(entry); populateTrackList(entry); connect(m_ui.inputProfile, &QComboBox::currentIndexChanged, this, &GameSummaryWidget::onInputProfileChanged); connect(m_ui.verify, &QAbstractButton::clicked, this, &GameSummaryWidget::onVerifyClicked); connect(m_ui.searchHash, &QAbstractButton::clicked, this, &GameSummaryWidget::onSearchHashClicked); connect(m_ui.checkWiki, &QAbstractButton::clicked, this, [this, entry]() { onCheckWikiClicked(entry); }); bool has_custom_title = false, has_custom_region = false; GameList::CheckCustomAttributesForPath(m_entry_path, has_custom_title, has_custom_region); m_ui.restoreTitle->setEnabled(has_custom_title); m_ui.restoreRegion->setEnabled(has_custom_region); m_ui.checkWiki->setEnabled(!entry->serial.empty()); } GameSummaryWidget::~GameSummaryWidget() = default; void GameSummaryWidget::populateInputProfiles() { for (const std::string& name : Pad::GetInputProfileNames()) m_ui.inputProfile->addItem(QString::fromStdString(name)); } void GameSummaryWidget::populateDetails(const GameList::Entry* entry) { m_ui.title->setText(QString::fromStdString(entry->title)); m_ui.titleSort->setText(QString::fromStdString(entry->title_sort)); m_ui.titleEN->setText(QString::fromStdString(entry->title_en)); m_ui.path->setText(QString::fromStdString(entry->path)); m_ui.serial->setText(QString::fromStdString(entry->serial)); m_ui.crc->setText(QString::fromStdString(fmt::format("{:08X}", entry->crc))); m_ui.type->setCurrentIndex(static_cast(entry->type)); m_ui.region->setCurrentIndex(static_cast(entry->region)); //: First arg is a GameList compat; second is a string with space followed by star rating OR empty if Unknown compat m_ui.compatibility->setText(tr("%0%1") .arg(GameList::EntryCompatibilityRatingToString(entry->compatibility_rating)) .arg([entry]() { if (entry->compatibility_rating == GameList::CompatibilityRating::Unknown) return QStringLiteral(""); const qsizetype compatibility_value = static_cast(entry->compatibility_rating); //: First arg is filled-in stars for game compatibility; second is empty stars; should be swapped for RTL languages return tr(" %0%1").arg(QStringLiteral("★").repeated(compatibility_value - 1)).arg(QStringLiteral("☆").repeated(6 - compatibility_value)); }())); int row = 0; m_ui.detailsFormLayout->getWidgetPosition(m_ui.titleSort, &row, nullptr); m_ui.detailsFormLayout->setRowVisible(row, !entry->title_sort.empty()); m_ui.detailsFormLayout->getWidgetPosition(m_ui.titleEN, &row, nullptr); m_ui.detailsFormLayout->setRowVisible(row, !entry->title_en.empty()); std::optional profile(m_dialog->getStringValue("EmuCore", "InputProfileName", std::nullopt)); if (profile.has_value()) m_ui.inputProfile->setCurrentIndex(m_ui.inputProfile->findText(QString::fromStdString(profile.value()))); else m_ui.inputProfile->setCurrentIndex(0); connect(m_ui.title, &QLineEdit::editingFinished, this, [this]() { if (m_ui.title->isModified()) { setCustomTitle(m_ui.title->text().toStdString()); m_ui.title->setModified(false); } }); connect(m_ui.restoreTitle, &QAbstractButton::clicked, this, [this]() { setCustomTitle(""); }); connect(m_ui.region, &QComboBox::currentIndexChanged, this, [this](int index) { setCustomRegion(index); }); connect(m_ui.restoreRegion, &QAbstractButton::clicked, this, [this]() { setCustomRegion(-1); }); } void GameSummaryWidget::populateDiscPath(const GameList::Entry* entry) { if (entry->type == GameList::EntryType::ELF) { std::optional iso_path(m_dialog->getStringValue("EmuCore", "DiscPath", std::nullopt)); if (iso_path.has_value() && !iso_path->empty()) m_ui.discPath->setText(QString::fromStdString(iso_path.value())); connect(m_ui.discPath, &QLineEdit::textChanged, this, &GameSummaryWidget::onDiscPathChanged); connect(m_ui.discPathBrowse, &QPushButton::clicked, this, &GameSummaryWidget::onDiscPathBrowseClicked); connect(m_ui.discPathClear, &QPushButton::clicked, m_ui.discPath, &QLineEdit::clear); } else { // Makes no sense to have disc override for a disc. int row = 0; m_ui.detailsFormLayout->getWidgetPosition(m_ui.label_discPath, &row, nullptr); m_ui.detailsFormLayout->removeRow(row); m_ui.discPath = nullptr; m_ui.discPathBrowse = nullptr; m_ui.discPathClear = nullptr; } } void GameSummaryWidget::onInputProfileChanged(int index) { if (index == 0) m_dialog->setStringSettingValue("EmuCore", "InputProfileName", std::nullopt); else m_dialog->setStringSettingValue("EmuCore", "InputProfileName", m_ui.inputProfile->itemText(index).toUtf8()); } void GameSummaryWidget::onDiscPathChanged(const QString& value) { if (value.isEmpty()) m_dialog->removeSettingValue("EmuCore", "DiscPath"); else m_dialog->setStringSettingValue("EmuCore", "DiscPath", value.toStdString().c_str()); // force rescan of elf to update the serial g_main_window->rescanFile(m_entry_path); repopulateCurrentDetails(); } void GameSummaryWidget::onDiscPathBrowseClicked() { const QString filename(QFileDialog::getOpenFileName( QtUtils::GetRootWidget(this), tr("Select Disc Path"), QString(), qApp->translate("MainWindow", MainWindow::DISC_IMAGE_FILTER))); if (filename.isEmpty()) return; // let the signal take care of it m_ui.discPath->setText(QDir::toNativeSeparators(filename)); } void GameSummaryWidget::populateTrackList(const GameList::Entry* entry) { if (entry->type != GameList::EntryType::PS1Disc && entry->type != GameList::EntryType::PS2Disc) { m_ui.verify->setEnabled(false); m_ui.verifyResult->setPlainText(tr("Game is not a CD/DVD.")); return; } if (QtHost::IsVMValid()) { m_ui.verify->setEnabled(false); m_ui.verifyResult->setPlainText(tr("Track list unavailable while virtual machine is running.")); return; } IsoHasher hasher; Error error; if (!hasher.Open(m_entry_path, &error)) { m_ui.verify->setEnabled(false); m_ui.verifyResult->setPlainText(QString::fromStdString(error.GetDescription())); return; } const auto AddColumn = [this](const QString& text) { QTableWidgetItem* item = new QTableWidgetItem(text); const int column = m_ui.tracks->columnCount(); m_ui.tracks->insertColumn(column); m_ui.tracks->setHorizontalHeaderItem(column, item); }; const auto SetColumn = [this](int row, int column, const QString& text) { QTableWidgetItem* item = new QTableWidgetItem(text); m_ui.tracks->setItem(row, column, item); }; // columns depend on CD vs DVD. AddColumn(tr("#")); if (hasher.IsCD()) { AddColumn(tr("Mode")); AddColumn(tr("Start")); AddColumn(tr("Sectors")); AddColumn(tr("Size")); AddColumn(tr("MD5")); AddColumn(tr("Status")); } else { AddColumn(tr("Start")); AddColumn(tr("Sectors")); AddColumn(tr("Size")); AddColumn(tr("MD5")); AddColumn(tr("Status")); } for (const IsoHasher::Track& track : hasher.GetTracks()) { const int row = m_ui.tracks->rowCount(); m_ui.tracks->insertRow(row); SetColumn(row, 0, tr("%1").arg(track.number)); if (hasher.IsCD()) { SetColumn(row, 1, QtUtils::StringViewToQString(IsoHasher::GetTrackTypeString(track.type))); SetColumn(row, 2, tr("%1").arg(track.start_lsn)); SetColumn(row, 3, tr("%1").arg(track.sectors)); SetColumn(row, 4, tr("%1").arg(track.size)); SetColumn(row, 5, tr("")); SetColumn(row, 6, QString()); } else { SetColumn(row, 1, tr("%1").arg(track.start_lsn)); SetColumn(row, 2, tr("%1").arg(track.sectors)); SetColumn(row, 3, tr("%1").arg(track.size)); SetColumn(row, 4, tr("")); SetColumn(row, 5, QString()); } } if (hasher.IsCD()) QtUtils::ResizeColumnsForTableView(m_ui.tracks, {20, 60, 70, 70, 100, 220, 40}); else QtUtils::ResizeColumnsForTableView(m_ui.tracks, {20, 100, 100, 100, 220, 40}); } void GameSummaryWidget::onVerifyClicked() { // Can't do this while a VM is running because of stupid CDVD. if (QtHost::IsVMValid()) { QMessageBox::critical(QtUtils::GetRootWidget(this), tr("Error"), tr("Cannot verify image while a game is running.")); return; } IsoHasher hasher; Error error; if (!hasher.Open(m_entry_path, &error)) { setVerifyResult(QString::fromStdString(error.GetDescription())); return; } QtModalProgressCallback callback(this); hasher.ComputeHashes(&callback); if (callback.IsCancelled()) return; const int hash_column = hasher.IsCD() ? 5 : 4; int row = 0; // convert to database format std::vector thashes; thashes.reserve(hasher.GetTrackCount()); for (const IsoHasher::Track& track : hasher.GetTracks()) { GameDatabase::TrackHash thash; thash.size = track.size; if (track.hash.empty() || !thash.parseHash(track.hash)) { m_ui.verify->setEnabled(false); m_ui.verifyResult->setPlainText(tr("One or more tracks is missing.")); return; } // Use the first track's hash as the redump search term. if (m_redump_search_keyword.empty()) m_redump_search_keyword = thash.toString(); thashes.push_back(thash); } // match the hashes. can't use vector here because it's not an actual array std::unique_ptr val_results = std::make_unique(hasher.GetTrackCount()); std::string match_error; const GameDatabase::HashDatabaseEntry* hentry = GameDatabase::lookupHash(thashes.data(), thashes.size(), val_results.get(), &match_error); // fill the UI with both the hashes and validation results for (u32 i = 0; i < hasher.GetTrackCount(); i++) { QTableWidgetItem* const hash_item = m_ui.tracks->item(row, hash_column); QTableWidgetItem* const status_item = m_ui.tracks->item(row, hash_column + 1); const bool result = val_results[i]; const QBrush brush(result ? QColor(0, 200, 0) : QColor(200, 0, 0)); hash_item->setText(QString::fromStdString(hasher.GetTrack(i).hash)); hash_item->setForeground(brush); status_item->setText(result ? QStringLiteral("\u2713") : QStringLiteral("\u2715")); status_item->setForeground(brush); row++; } if (hentry) { if (!hentry->version.empty()) { setVerifyResult(tr("Verified as %1 [%2] (Version %3).") .arg(QString::fromStdString(hentry->name)) .arg(QString::fromStdString(hentry->serial)) .arg(QString::fromStdString(hentry->version))); } else { setVerifyResult(tr("Verified as %1 [%2].") .arg(QString::fromStdString(hentry->name)) .arg(QString::fromStdString(hentry->serial))); } } else { setVerifyResult(QString::fromStdString(match_error)); } } void GameSummaryWidget::onSearchHashClicked() { if (m_redump_search_keyword.empty()) return; QtUtils::OpenURL(this, fmt::format("http://redump.org/discs/quicksearch/{}", m_redump_search_keyword).c_str()); } void GameSummaryWidget::onCheckWikiClicked(const GameList::Entry* entry) { QtUtils::OpenURL(this, fmt::format("https://wiki.pcsx2.net/{}", entry->serial).c_str()); } void GameSummaryWidget::setVerifyResult(QString error) { m_ui.verify->setVisible(false); m_ui.verifyButtonLayout->removeWidget(m_ui.verify); m_ui.verify->deleteLater(); m_ui.verify = nullptr; m_ui.verifyButtonLayout->removeItem(m_ui.verifyButtonSpacer); delete m_ui.verifyButtonSpacer; m_ui.verifyButtonSpacer = nullptr; m_ui.verifyLayout->removeItem(m_ui.verifyButtonLayout); m_ui.verifyButtonLayout->deleteLater(); m_ui.verifyButtonLayout = nullptr; m_ui.verifyLayout->update(); updateGeometry(); m_ui.verifyResult->setPlainText(error); m_ui.verifyResult->setVisible(true); m_ui.searchHash->setVisible(true); } void GameSummaryWidget::repopulateCurrentDetails() { auto lock = GameList::GetLock(); const GameList::Entry* entry = GameList::GetEntryForPath(m_entry_path.c_str()); if (entry) { populateDetails(entry); m_dialog->setWindowTitle(QString::fromStdString(entry->title)); } } void GameSummaryWidget::setCustomTitle(const std::string& text) { m_ui.restoreTitle->setEnabled(!text.empty()); GameList::SaveCustomTitleForPath(m_entry_path, text); repopulateCurrentDetails(); } void GameSummaryWidget::setCustomRegion(int region) { m_ui.restoreRegion->setEnabled(region >= 0); GameList::SaveCustomRegionForPath(m_entry_path, region); repopulateCurrentDetails(); }