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 }