duckstation

duckstation, but archived from the revision just before upstream changed it to a proprietary software project, this version is the libre one
git clone https://git.neptards.moe/u3shit/duckstation.git
Log | Files | Refs | README | LICENSE

gamesummarywidget.cpp (19982B)


      1 // SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin <stenzek@gmail.com>
      2 // SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0)
      3 
      4 #include "gamesummarywidget.h"
      5 #include "mainwindow.h"
      6 #include "qthost.h"
      7 #include "qtprogresscallback.h"
      8 #include "settingswindow.h"
      9 
     10 #include "core/controller.h"
     11 #include "core/game_database.h"
     12 #include "core/game_list.h"
     13 
     14 #include "common/error.h"
     15 #include "common/string_util.h"
     16 
     17 #include "fmt/format.h"
     18 
     19 #include <QtCore/QDateTime>
     20 #include <QtCore/QFuture>
     21 #include <QtCore/QStringBuilder>
     22 #include <QtWidgets/QDialog>
     23 #include <QtWidgets/QDialogButtonBox>
     24 #include <QtWidgets/QMessageBox>
     25 #include <QtWidgets/QTextBrowser>
     26 
     27 GameSummaryWidget::GameSummaryWidget(const std::string& path, const std::string& serial, DiscRegion region,
     28                                      const GameDatabase::Entry* entry, SettingsWindow* dialog, QWidget* parent)
     29   : m_dialog(dialog)
     30 {
     31   m_ui.setupUi(this);
     32   m_ui.revision->setVisible(false);
     33 
     34   for (u32 i = 0; i < static_cast<u32>(GameList::EntryType::Count); i++)
     35   {
     36     m_ui.entryType->addItem(
     37       QtUtils::GetIconForEntryType(static_cast<GameList::EntryType>(i)),
     38       qApp->translate("GameList", GameList::GetEntryTypeDisplayName(static_cast<GameList::EntryType>(i))));
     39   }
     40 
     41   for (u32 i = 0; i < static_cast<u32>(DiscRegion::Count); i++)
     42   {
     43     m_ui.region->addItem(QtUtils::GetIconForRegion(static_cast<DiscRegion>(i)),
     44                          QString::fromUtf8(Settings::GetDiscRegionDisplayName(static_cast<DiscRegion>(i))));
     45   }
     46 
     47   for (u32 i = 0; i < static_cast<u32>(GameDatabase::CompatibilityRating::Count); i++)
     48   {
     49     m_ui.compatibility->addItem(QtUtils::GetIconForCompatibility(static_cast<GameDatabase::CompatibilityRating>(i)),
     50                                 QString::fromUtf8(GameDatabase::GetCompatibilityRatingDisplayName(
     51                                   static_cast<GameDatabase::CompatibilityRating>(i))));
     52   }
     53 
     54   populateUi(path, serial, region, entry);
     55 
     56   connect(m_ui.compatibilityComments, &QToolButton::clicked, this, &GameSummaryWidget::onCompatibilityCommentsClicked);
     57   connect(m_ui.inputProfile, &QComboBox::currentIndexChanged, this, &GameSummaryWidget::onInputProfileChanged);
     58   connect(m_ui.editInputProfile, &QAbstractButton::clicked, this, &GameSummaryWidget::onEditInputProfileClicked);
     59   connect(m_ui.computeHashes, &QAbstractButton::clicked, this, &GameSummaryWidget::onComputeHashClicked);
     60 
     61   connect(m_ui.title, &QLineEdit::editingFinished, this, [this]() {
     62     if (m_ui.title->isModified())
     63     {
     64       setCustomTitle(m_ui.title->text().toStdString());
     65       m_ui.title->setModified(false);
     66     }
     67   });
     68   connect(m_ui.restoreTitle, &QAbstractButton::clicked, this, [this]() { setCustomTitle(std::string()); });
     69   connect(m_ui.region, &QComboBox::currentIndexChanged, this, [this](int index) { setCustomRegion(index); });
     70   connect(m_ui.restoreRegion, &QAbstractButton::clicked, this, [this]() { setCustomRegion(-1); });
     71 }
     72 
     73 GameSummaryWidget::~GameSummaryWidget() = default;
     74 
     75 void GameSummaryWidget::populateUi(const std::string& path, const std::string& serial, DiscRegion region,
     76                                    const GameDatabase::Entry* entry)
     77 {
     78   m_path = path;
     79 
     80   m_ui.path->setText(QString::fromStdString(path));
     81   m_ui.serial->setText(QString::fromStdString(serial));
     82   m_ui.region->setCurrentIndex(static_cast<int>(region));
     83 
     84   if (entry)
     85   {
     86     m_ui.title->setText(QString::fromStdString(entry->title));
     87     m_ui.compatibility->setCurrentIndex(static_cast<int>(entry->compatibility));
     88     m_ui.genre->setText(entry->genre.empty() ? tr("Unknown") : QString::fromStdString(entry->genre));
     89     if (!entry->developer.empty() && !entry->publisher.empty() && entry->developer != entry->publisher)
     90       m_ui.developer->setText(tr("%1 (Published by %2)")
     91                                 .arg(QString::fromStdString(entry->developer))
     92                                 .arg(QString::fromStdString(entry->publisher)));
     93     else if (!entry->developer.empty())
     94       m_ui.developer->setText(QString::fromStdString(entry->developer));
     95     else if (!entry->publisher.empty())
     96       m_ui.developer->setText(tr("Published by %1").arg(QString::fromStdString(entry->publisher)));
     97     else
     98       m_ui.developer->setText(tr("Unknown"));
     99 
    100     QString release_info;
    101     if (entry->release_date != 0)
    102       release_info =
    103         tr("Released %1").arg(QDateTime::fromSecsSinceEpoch(entry->release_date, Qt::UTC).date().toString());
    104     if (entry->min_players != 0)
    105     {
    106       if (!release_info.isEmpty())
    107         release_info.append(", ");
    108       if (entry->min_players != entry->max_players)
    109         release_info.append(tr("%1-%2 players").arg(entry->min_players).arg(entry->max_players));
    110       else
    111         release_info.append(tr("%1 players").arg(entry->min_players));
    112     }
    113     if (entry->min_blocks != 0)
    114     {
    115       if (!release_info.isEmpty())
    116         release_info.append(", ");
    117       if (entry->min_blocks != entry->max_blocks)
    118         release_info.append(tr("%1-%2 memory card blocks").arg(entry->min_blocks).arg(entry->max_blocks));
    119       else
    120         release_info.append(tr("%1 memory card blocks").arg(entry->min_blocks));
    121     }
    122     if (!release_info.isEmpty())
    123       m_ui.releaseInfo->setText(release_info);
    124     else
    125       m_ui.releaseInfo->setText(tr("Unknown"));
    126 
    127     QString controllers;
    128     if (entry->supported_controllers != 0 && entry->supported_controllers != static_cast<u16>(-1))
    129     {
    130       for (u32 i = 0; i < static_cast<u32>(ControllerType::Count); i++)
    131       {
    132         if ((entry->supported_controllers & static_cast<u16>(1u << i)) != 0)
    133         {
    134           if (!controllers.isEmpty())
    135             controllers.append(", ");
    136           controllers.append(Controller::GetControllerInfo(static_cast<ControllerType>(i))->GetDisplayName());
    137         }
    138       }
    139     }
    140     if (controllers.isEmpty())
    141       controllers = tr("Unknown");
    142     m_ui.controllers->setText(controllers);
    143 
    144     m_compatibility_comments = QString::fromStdString(entry->GenerateCompatibilityReport());
    145   }
    146   else
    147   {
    148     m_ui.title->setText(tr("Unknown"));
    149     m_ui.genre->setText(tr("Unknown"));
    150     m_ui.developer->setText(tr("Unknown"));
    151     m_ui.releaseInfo->setText(tr("Unknown"));
    152     m_ui.controllers->setText(tr("Unknown"));
    153   }
    154 
    155   {
    156     auto lock = GameList::GetLock();
    157     const GameList::Entry* gentry = GameList::GetEntryForPath(path);
    158     if (gentry)
    159       m_ui.entryType->setCurrentIndex(static_cast<int>(gentry->type));
    160   }
    161 
    162   m_ui.compatibilityComments->setVisible(!m_compatibility_comments.isEmpty());
    163 
    164   m_ui.inputProfile->addItem(QIcon::fromTheme(QStringLiteral("global-line")), tr("Use Global Settings"));
    165   m_ui.inputProfile->addItem(QIcon::fromTheme(QStringLiteral("controller-digital-line")),
    166                              tr("Game Specific Configuration"));
    167   for (const std::string& name : InputManager::GetInputProfileNames())
    168     m_ui.inputProfile->addItem(QString::fromStdString(name));
    169 
    170   if (m_dialog->getBoolValue("ControllerPorts", "UseGameSettingsForController", std::nullopt).value_or(false))
    171   {
    172     m_ui.inputProfile->setCurrentIndex(1);
    173   }
    174   else if (const std::optional<std::string> profile_name =
    175              m_dialog->getStringValue("ControllerPorts", "InputProfileName", std::nullopt);
    176            profile_name.has_value() && !profile_name->empty())
    177   {
    178     m_ui.inputProfile->setCurrentIndex(m_ui.inputProfile->findText(QString::fromStdString(profile_name.value())));
    179   }
    180   else
    181   {
    182     m_ui.inputProfile->setCurrentIndex(0);
    183   }
    184   m_ui.editInputProfile->setEnabled(m_ui.inputProfile->currentIndex() >= 1);
    185 
    186   populateCustomAttributes();
    187   populateTracksInfo();
    188   updateWindowTitle();
    189 }
    190 
    191 void GameSummaryWidget::populateCustomAttributes()
    192 {
    193   auto lock = GameList::GetLock();
    194   const GameList::Entry* entry = GameList::GetEntryForPath(m_path);
    195   if (!entry || entry->IsDiscSet())
    196     return;
    197 
    198   {
    199     QSignalBlocker sb(m_ui.title);
    200     m_ui.title->setText(QString::fromStdString(entry->title));
    201     m_ui.restoreTitle->setEnabled(entry->has_custom_title);
    202   }
    203 
    204   {
    205     QSignalBlocker sb(m_ui.region);
    206     m_ui.region->setCurrentIndex(static_cast<int>(entry->region));
    207     m_ui.restoreRegion->setEnabled(entry->has_custom_region);
    208   }
    209 }
    210 
    211 void GameSummaryWidget::updateWindowTitle()
    212 {
    213   const QString window_title = tr("%1 [%2]").arg(m_ui.title->text()).arg(m_ui.serial->text());
    214   m_dialog->setWindowTitle(window_title);
    215 }
    216 
    217 void GameSummaryWidget::setCustomTitle(const std::string& text)
    218 {
    219   m_ui.restoreTitle->setEnabled(!text.empty());
    220 
    221   GameList::SaveCustomTitleForPath(m_path, text);
    222   populateCustomAttributes();
    223   updateWindowTitle();
    224   g_main_window->refreshGameListModel();
    225 }
    226 
    227 void GameSummaryWidget::setCustomRegion(int region)
    228 {
    229   m_ui.restoreRegion->setEnabled(region >= 0);
    230 
    231   GameList::SaveCustomRegionForPath(m_path, (region >= 0) ? std::optional<DiscRegion>(static_cast<DiscRegion>(region)) :
    232                                                             std::optional<DiscRegion>());
    233   populateCustomAttributes();
    234   updateWindowTitle();
    235   g_main_window->refreshGameListModel();
    236 }
    237 
    238 void GameSummaryWidget::setRevisionText(const QString& text)
    239 {
    240   if (text.isEmpty())
    241     return;
    242 
    243   if (m_ui.verifySpacer)
    244   {
    245     m_ui.verifyLayout->removeItem(m_ui.verifySpacer);
    246     delete m_ui.verifySpacer;
    247     m_ui.verifySpacer = nullptr;
    248   }
    249   m_ui.revision->setText(text);
    250   m_ui.revision->setVisible(true);
    251 }
    252 
    253 static QString MSFTotString(const CDImage::Position& position)
    254 {
    255   return QStringLiteral("%1:%2:%3 (LBA %4)")
    256     .arg(static_cast<uint>(position.minute), 2, 10, static_cast<QChar>('0'))
    257     .arg(static_cast<uint>(position.second), 2, 10, static_cast<QChar>('0'))
    258     .arg(static_cast<uint>(position.frame), 2, 10, static_cast<QChar>('0'))
    259     .arg(static_cast<ulong>(position.ToLBA()));
    260 }
    261 
    262 void GameSummaryWidget::populateTracksInfo()
    263 {
    264   static constexpr std::array<const char*, 8> track_mode_strings = {
    265     {"Audio", "Mode 1", "Mode 1/Raw", "Mode 2", "Mode 2/Form 1", "Mode 2/Form 2", "Mode 2/Mix", "Mode 2/Raw"}};
    266 
    267   m_ui.tracks->clearContents();
    268   QtUtils::ResizeColumnsForTableView(m_ui.tracks, {70, 75, 95, 95, 215, 40});
    269 
    270   std::unique_ptr<CDImage> image = CDImage::Open(m_path.c_str(), false, nullptr);
    271   if (!image)
    272     return;
    273 
    274   setRevisionText(tr("%1 tracks covering %2 MB (%3 MB on disk)")
    275                     .arg(image->GetTrackCount())
    276                     .arg(((image->GetLBACount() * CDImage::RAW_SECTOR_SIZE) + 1048575) / 1048576)
    277                     .arg((image->GetSizeOnDisk() + 1048575) / 1048576));
    278 
    279   const u32 num_tracks = image->GetTrackCount();
    280   for (u32 track = 1; track <= num_tracks; track++)
    281   {
    282     const CDImage::Position position = image->GetTrackStartMSFPosition(static_cast<u8>(track));
    283     const CDImage::Position length = image->GetTrackMSFLength(static_cast<u8>(track));
    284     const CDImage::TrackMode mode = image->GetTrackMode(static_cast<u8>(track));
    285     const int row = static_cast<int>(track - 1u);
    286 
    287     QTableWidgetItem* num = new QTableWidgetItem(tr("Track %1").arg(track));
    288     num->setIcon(QIcon::fromTheme((mode == CDImage::TrackMode::Audio) ? QStringLiteral("file-music-line") :
    289                                                                         QStringLiteral("disc-line")));
    290     m_ui.tracks->insertRow(row);
    291     m_ui.tracks->setItem(row, 0, num);
    292     m_ui.tracks->setItem(row, 1, new QTableWidgetItem(track_mode_strings[static_cast<u32>(mode)]));
    293     m_ui.tracks->setItem(row, 2, new QTableWidgetItem(MSFTotString(position)));
    294     m_ui.tracks->setItem(row, 3, new QTableWidgetItem(MSFTotString(length)));
    295     m_ui.tracks->setItem(row, 4, new QTableWidgetItem(tr("<not computed>")));
    296 
    297     QTableWidgetItem* status = new QTableWidgetItem(QString());
    298     status->setTextAlignment(Qt::AlignCenter);
    299     m_ui.tracks->setItem(row, 5, status);
    300   }
    301 }
    302 
    303 void GameSummaryWidget::onCompatibilityCommentsClicked()
    304 {
    305   QDialog dlg(QtUtils::GetRootWidget(this));
    306   dlg.resize(QSize(700, 400));
    307   dlg.setWindowModality(Qt::WindowModal);
    308   dlg.setWindowTitle(tr("Compatibility Report"));
    309 
    310   QVBoxLayout* layout = new QVBoxLayout(&dlg);
    311 
    312   QTextBrowser* tb = new QTextBrowser(&dlg);
    313   tb->setMarkdown(m_compatibility_comments);
    314   layout->addWidget(tb, 1);
    315 
    316   QDialogButtonBox* bb = new QDialogButtonBox(QDialogButtonBox::Close, &dlg);
    317   connect(bb->button(QDialogButtonBox::Close), &QPushButton::clicked, &dlg, &QDialog::accept);
    318   layout->addWidget(bb);
    319 
    320   dlg.exec();
    321 }
    322 
    323 void GameSummaryWidget::onInputProfileChanged(int index)
    324 {
    325 
    326   SettingsInterface* sif = m_dialog->getSettingsInterface();
    327   if (index == 0)
    328   {
    329     // Use global settings.
    330     sif->DeleteValue("ControllerPorts", "InputProfileName");
    331     sif->DeleteValue("ControllerPorts", "UseGameSettingsForController");
    332   }
    333   else if (index == 1)
    334   {
    335     // Per-game configuration.
    336     sif->DeleteValue("ControllerPorts", "InputProfileName");
    337     sif->SetBoolValue("ControllerPorts", "UseGameSettingsForController", true);
    338 
    339     if (!sif->GetBoolValue("ControllerPorts", "GameSettingsInitialized", false))
    340     {
    341       sif->SetBoolValue("ControllerPorts", "GameSettingsInitialized", true);
    342 
    343       {
    344         const auto lock = Host::GetSettingsLock();
    345         SettingsInterface* base_sif = Host::Internal::GetBaseSettingsLayer();
    346         InputManager::CopyConfiguration(sif, *base_sif, true, true, false);
    347 
    348         QWidget* dlg_parent = QtUtils::GetRootWidget(this);
    349         QMessageBox::information(dlg_parent, dlg_parent->windowTitle(),
    350                                  tr("Per-game controller configuration initialized with global settings."));
    351       }
    352     }
    353   }
    354   else
    355   {
    356     // Input profile.
    357     sif->SetStringValue("ControllerPorts", "InputProfileName", m_ui.inputProfile->itemText(index).toUtf8());
    358     sif->DeleteValue("ControllerPorts", "UseGameSettingsForController");
    359   }
    360 
    361   m_dialog->saveAndReloadGameSettings();
    362   m_ui.editInputProfile->setEnabled(index > 0);
    363 }
    364 
    365 void GameSummaryWidget::onEditInputProfileClicked()
    366 {
    367   if (m_dialog->getBoolValue("ControllerPorts", "UseGameSettingsForController", std::nullopt).value_or(false))
    368   {
    369     // Edit game configuration.
    370     ControllerSettingsWindow::editControllerSettingsForGame(QtUtils::GetRootWidget(this),
    371                                                             m_dialog->getSettingsInterface());
    372   }
    373   else if (const std::optional<std::string> profile_name =
    374              m_dialog->getStringValue("ControllerPorts", "InputProfileName", std::nullopt);
    375            profile_name.has_value() && !profile_name->empty())
    376   {
    377     // Edit input profile.
    378     g_main_window->openInputProfileEditor(profile_name.value());
    379   }
    380 }
    381 
    382 void GameSummaryWidget::onComputeHashClicked()
    383 {
    384   // Search redump when it's already computed.
    385   if (!m_redump_search_keyword.empty())
    386   {
    387     QtUtils::OpenURL(this, fmt::format("http://redump.org/discs/quicksearch/{}", m_redump_search_keyword).c_str());
    388     return;
    389   }
    390 
    391   std::unique_ptr<CDImage> image = CDImage::Open(m_path.c_str(), false, nullptr);
    392   if (!image)
    393   {
    394     QMessageBox::critical(QtUtils::GetRootWidget(this), tr("Error"), tr("Failed to open CD image for hashing."));
    395     return;
    396   }
    397 
    398   QtModalProgressCallback progress_callback(this);
    399   progress_callback.SetProgressRange(image->GetTrackCount());
    400 
    401   std::vector<CDImageHasher::Hash> track_hashes;
    402   track_hashes.reserve(image->GetTrackCount());
    403 
    404   // Calculate hashes
    405   bool calculate_hash_success = true;
    406   for (u8 track = 1; track <= image->GetTrackCount(); track++)
    407   {
    408     progress_callback.SetProgressValue(track - 1);
    409     progress_callback.PushState();
    410 
    411     CDImageHasher::Hash hash;
    412     if (!CDImageHasher::GetTrackHash(image.get(), track, &hash, &progress_callback))
    413     {
    414       progress_callback.PopState();
    415       calculate_hash_success = false;
    416       break;
    417     }
    418     track_hashes.emplace_back(hash);
    419 
    420     QTableWidgetItem* item = m_ui.tracks->item(track - 1, 4);
    421     item->setText(QString::fromStdString(CDImageHasher::HashToString(hash)));
    422 
    423     progress_callback.PopState();
    424   }
    425 
    426   // Verify hashes against gamedb
    427   std::vector<bool> verification_results(image->GetTrackCount(), false);
    428   if (calculate_hash_success)
    429   {
    430     std::string found_revision;
    431     std::string found_serial;
    432     m_redump_search_keyword = CDImageHasher::HashToString(track_hashes.front());
    433 
    434     progress_callback.SetStatusText(TRANSLATE("GameSummaryWidget", "Verifying hashes..."));
    435     progress_callback.SetProgressValue(image->GetTrackCount());
    436 
    437     // Verification strategy used:
    438     // 1. First, find all matches for the data track
    439     //    If none are found, fail verification for all tracks
    440     // 2. For each data track match, try to match all audio tracks
    441     //    If all match, assume this revision. Else, try other revisions,
    442     //    and accept the one with the most matches.
    443     const GameDatabase::TrackHashesMap& hashes_map = GameDatabase::GetTrackHashesMap();
    444 
    445     auto data_track_matches = hashes_map.equal_range(track_hashes[0]);
    446     if (data_track_matches.first != data_track_matches.second)
    447     {
    448       auto best_data_match = data_track_matches.second;
    449       for (auto iter = data_track_matches.first; iter != data_track_matches.second; ++iter)
    450       {
    451         std::vector<bool> current_verification_results(image->GetTrackCount(), false);
    452         const auto& data_track_attribs = iter->second;
    453         current_verification_results[0] = true; // Data track already matched
    454 
    455         for (auto audio_tracks_iter = std::next(track_hashes.begin()); audio_tracks_iter != track_hashes.end();
    456              ++audio_tracks_iter)
    457         {
    458           auto audio_track_matches = hashes_map.equal_range(*audio_tracks_iter);
    459           for (auto audio_iter = audio_track_matches.first; audio_iter != audio_track_matches.second; ++audio_iter)
    460           {
    461             // If audio track comes from the same revision and code as the data track, "pass" it
    462             if (audio_iter->second == data_track_attribs)
    463             {
    464               current_verification_results[std::distance(track_hashes.begin(), audio_tracks_iter)] = true;
    465               break;
    466             }
    467           }
    468         }
    469 
    470         const auto old_matches_count = std::count(verification_results.begin(), verification_results.end(), true);
    471         const auto new_matches_count =
    472           std::count(current_verification_results.begin(), current_verification_results.end(), true);
    473 
    474         if (new_matches_count > old_matches_count)
    475         {
    476           best_data_match = iter;
    477           verification_results = current_verification_results;
    478           // If all elements got matched, early out
    479           if (new_matches_count >= static_cast<ptrdiff_t>(verification_results.size()))
    480           {
    481             break;
    482           }
    483         }
    484       }
    485 
    486       found_revision = best_data_match->second.revision_str;
    487       found_serial = best_data_match->second.serial;
    488     }
    489 
    490     QString text;
    491 
    492     if (!found_revision.empty())
    493       text = tr("Revision: %1").arg(found_revision.empty() ? tr("N/A") : QString::fromStdString(found_revision));
    494 
    495     if (found_serial != m_ui.serial->text().toStdString())
    496     {
    497       const QString mismatch_str =
    498         tr("Serial Mismatch: %1 vs %2").arg(QString::fromStdString(found_serial)).arg(m_ui.serial->text());
    499       if (!text.isEmpty())
    500         text = QStringLiteral("%1 | %2").arg(mismatch_str).arg(text);
    501       else
    502         text = mismatch_str;
    503     }
    504 
    505     setRevisionText(text);
    506   }
    507 
    508   for (u8 track = 0; track < image->GetTrackCount(); track++)
    509   {
    510     QTableWidgetItem* hash_text = m_ui.tracks->item(track, 4);
    511     QTableWidgetItem* status_text = m_ui.tracks->item(track, 5);
    512     QBrush brush;
    513     if (verification_results[track])
    514     {
    515       brush = QColor(0, 200, 0);
    516       status_text->setText(QString::fromUtf8(u8"\u2713"));
    517     }
    518     else
    519     {
    520       brush = QColor(200, 0, 0);
    521       status_text->setText(QString::fromUtf8(u8"\u2715"));
    522     }
    523     status_text->setForeground(brush);
    524     hash_text->setForeground(brush);
    525   }
    526 
    527   if (!m_redump_search_keyword.empty())
    528     m_ui.computeHashes->setText(tr("Search on Redump.org"));
    529   else
    530     m_ui.computeHashes->setEnabled(false);
    531 }