diff --git a/src/core/game_database.cpp b/src/core/game_database.cpp index a96039f95..642a02353 100644 --- a/src/core/game_database.cpp +++ b/src/core/game_database.cpp @@ -17,6 +17,7 @@ #include "common/heterogeneous_containers.h" #include "common/log.h" #include "common/path.h" +#include "common/progress_callback.h" #include "common/ryml_helpers.h" #include "common/string_util.h" #include "common/timer.h" @@ -60,6 +61,17 @@ static bool ParseYamlCodes(UnorderedStringMap& lookup, const r static void BindDiscSetsToEntries(); static bool LoadTrackHashes(); +namespace { +struct TrackHashMatchResult +{ + std::vector track_matches; + std::string revision; + std::string serial; +}; +} // namespace + +static TrackHashMatchResult MatchTrackHashes(const CDImageHasher::TrackHashes& track_hashes); + static constexpr const std::array(CompatibilityRating::Count)> s_compatibility_rating_names = {{ "Unknown", @@ -1851,3 +1863,118 @@ const GameDatabase::TrackHashesMap& GameDatabase::GetTrackHashesMap() EnsureTrackHashesMapLoaded(); return s_state.track_hashes_map; } + +GameDatabase::TrackHashMatchResult GameDatabase::MatchTrackHashes(const CDImageHasher::TrackHashes& track_hashes) +{ + TrackHashMatchResult result = {std::vector(track_hashes.size(), false), {}, {}}; + + DebugAssert(!track_hashes.empty()); + const TrackHashesMap& hashes_map = GetTrackHashesMap(); + const auto data_track_matches = hashes_map.equal_range(track_hashes[0]); + if (data_track_matches.first != data_track_matches.second) + { + auto best_data_match = data_track_matches.second; + for (auto iter = data_track_matches.first; iter != data_track_matches.second; ++iter) + { + std::vector current_verification_results(track_hashes.size(), false); + const auto& data_track_attribs = iter->second; + current_verification_results[0] = true; // Data track already matched + + for (auto audio_tracks_iter = std::next(track_hashes.begin()); audio_tracks_iter != track_hashes.end(); + ++audio_tracks_iter) + { + auto audio_track_matches = hashes_map.equal_range(*audio_tracks_iter); + for (auto audio_iter = audio_track_matches.first; audio_iter != audio_track_matches.second; ++audio_iter) + { + // If audio track comes from the same revision and code as the data track, "pass" it + if (audio_iter->second == data_track_attribs) + { + current_verification_results[std::distance(track_hashes.begin(), audio_tracks_iter)] = true; + break; + } + } + } + + const auto old_matches_count = std::count(result.track_matches.begin(), result.track_matches.end(), true); + const auto new_matches_count = + std::count(current_verification_results.begin(), current_verification_results.end(), true); + + if (new_matches_count > old_matches_count) + { + best_data_match = iter; + result.track_matches = std::move(current_verification_results); + + // If all elements got matched, early out + { + } + } + } + + result.revision = best_data_match->second.revision_str; + result.serial = best_data_match->second.serial; + } + + return result; +} + +bool GameDatabase::VerifyImage(CDImage* image, std::string_view expected_serial, TrackVerificationResult* result, + ProgressCallback* progress, Error* error) +{ + CDImageHasher::TrackHashes track_hashes; + const u32 num_tracks = image->GetTrackCount(); + track_hashes.reserve(num_tracks); + progress->SetState({}, 0, num_tracks, true); + + for (u32 track = 0; track < num_tracks; track++) + { + progress->SetProgressValue(track); + progress->PushState(); + + CDImageHasher::Hash hash; + if (!CDImageHasher::GetTrackHash(image, static_cast(track + 1), &hash, progress, error)) + { + progress->PopState(); + return false; + } + + track_hashes.emplace_back(hash); + progress->PopState(); + } + + if (track_hashes.empty()) + { + Error::SetStringView(error, "Image contains no tracks."); + return false; + } + + TrackHashMatchResult match = MatchTrackHashes(track_hashes); + std::string summary; + if (!match.revision.empty()) + result->summary = fmt::format(TRANSLATE_FS("GameDatabase", "Revision: {}"), match.revision); + else + result->summary = {}; + + if (match.serial != expected_serial) + { + if (match.serial.empty()) + { + summary = TRANSLATE_STR("GameDatabase", "No known dump found that matches this hash."); + } + else + { + const std::string mismatch = + fmt::format(TRANSLATE_FS("GameDatabase", "Serial Mismatch: Disc {} vs Hash {}"), expected_serial, match.serial); + summary = summary.empty() ? mismatch : fmt::format("{} | {}", mismatch, summary); + } + } + + result->track_hashes = std::move(track_hashes); + result->track_matches = std::move(match.track_matches); + result->summary = std::move(summary); + return true; +} + +std::string GameDatabase::GetRedumpSearchURL(std::string_view hash) +{ + return fmt::format("https://redump.info/discs?q={}", hash); +} diff --git a/src/core/game_database.h b/src/core/game_database.h index c45ee87ae..ee897764f 100644 --- a/src/core/game_database.h +++ b/src/core/game_database.h @@ -16,6 +16,8 @@ #include class CDImage; +class Error; +class ProgressCallback; struct Settings; @@ -241,4 +243,15 @@ using TrackHashesMap = std::multimap; const TrackHashesMap& GetTrackHashesMap(); void EnsureTrackHashesMapLoaded(); +struct TrackVerificationResult +{ + CDImageHasher::TrackHashes track_hashes; + std::vector track_matches; + std::string summary; +}; + +bool VerifyImage(CDImage* image, std::string_view expected_serial, TrackVerificationResult* result, + ProgressCallback* progress, Error* error); +std::string GetRedumpSearchURL(std::string_view hash); + } // namespace GameDatabase diff --git a/src/duckstation-qt/gamesummarywidget.cpp b/src/duckstation-qt/gamesummarywidget.cpp index 5bc33b56b..0e9e362f0 100644 --- a/src/duckstation-qt/gamesummarywidget.cpp +++ b/src/duckstation-qt/gamesummarywidget.cpp @@ -448,19 +448,8 @@ void GameSummaryWidget::onCustomLanguageChanged(int language) g_main_window->getGameListWidget()->getModel()->invalidateColumnForPath(m_path, GameListModel::Column_Region); } -static QString MSFToString(const CDImage::Position& position) -{ - return QStringLiteral("%1:%2:%3") - .arg(static_cast(position.minute), 2, 10, static_cast('0')) - .arg(static_cast(position.second), 2, 10, static_cast('0')) - .arg(static_cast(position.frame), 2, 10, static_cast('0')); -} - void GameSummaryWidget::populateTracksInfo() { - static constexpr std::array track_mode_strings = { - {"Audio", "Mode 1", "Mode 1/Raw", "Mode 2", "Mode 2/Form 1", "Mode 2/Form 2", "Mode 2/Mix", "Mode 2/Raw"}}; - m_ui.tracks->clear(); QtUtils::SetColumnWidthsForTreeView(m_ui.tracks, {80, 90, 70, 70, -1, 40}); @@ -468,24 +457,20 @@ void GameSummaryWidget::populateTracksInfo() if (!image) return; - m_ui.revision->setText(tr("%n track(s) covering %1 MB (%2 MB on disk)", nullptr, image->GetTrackCount()) - .arg(((image->GetLBACount() * CDImage::RAW_SECTOR_SIZE) + 1048575) / 1048576) - .arg((image->GetSizeOnDisk() + 1048575) / 1048576)); + m_ui.revision->setText(QString::fromStdString(image->GetSummary())); const u32 num_tracks = image->GetTrackCount(); for (u32 track = 1; track <= num_tracks; track++) { - const CDImage::Position position = image->GetTrackStartMSFPosition(static_cast(track)); - const CDImage::Position length = image->GetTrackMSFLength(static_cast(track)); const CDImage::TrackMode mode = image->GetTrackMode(static_cast(track)); QTreeWidgetItem* row = new QTreeWidgetItem(m_ui.tracks); row->setIcon(0, QIcon((mode == CDImage::TrackMode::Audio) ? u":/icons/monochrome/svg/file-music-line.svg"_s : u":/icons/monochrome/svg/disc-line.svg"_s)); row->setText(0, tr("Track %1").arg(track)); - row->setText(1, QString::fromUtf8(track_mode_strings[static_cast(mode)])); - row->setText(2, MSFToString(position)); - row->setText(3, MSFToString(length)); + row->setText(1, QString::fromUtf8(CDImage::GetTrackModeDisplayName(mode))); + row->setText(2, QString::fromStdString(image->GetTrackStartMSFPosition(static_cast(track)).ToString())); + row->setText(3, QString::fromStdString(image->GetTrackMSFLength(static_cast(track)).ToString())); row->setText(4, tr("")); row->setTextAlignment(5, Qt::AlignCenter); } @@ -589,58 +574,31 @@ void GameSummaryWidget::onEditInputProfileClicked() void GameSummaryWidget::onComputeHashClicked() { // Search redump when it's already computed. - if (!m_redump_search_keyword.empty()) + QTreeWidgetItem* const first_track = m_ui.tracks->topLevelItem(0); + if (first_track && !first_track->text(5).isEmpty()) { - QtUtils::OpenURL(this, fmt::format("https://redump.info/discs?q={}", m_redump_search_keyword).c_str()); + QtUtils::OpenURL(this, GameDatabase::GetRedumpSearchURL(first_track->text(4).toStdString()).c_str()); return; } m_ui.computeHashes->setEnabled(false); - QtAsyncTaskWithProgressDialog::create(this, TRANSLATE_SV("GameSummaryWidget", "Verifying Image"), {}, false, true, 1, - 0, 0.0f, true, [this, path = m_path](ProgressCallback* progress) { - Error error; - CDImageHasher::TrackHashes track_hashes; - const bool result = computeImageHash(path, track_hashes, progress, &error); - const bool cancelled = (!result && progress->IsCancelled()); - return [this, track_hashes = std::move(track_hashes), - error = std::move(error), result, cancelled]() { - processHashResults(track_hashes, result, cancelled, error); - }; - }); + QtAsyncTaskWithProgressDialog::create( + this, TRANSLATE_SV("GameSummaryWidget", "Verifying Image"), {}, false, true, 1, 0, 0.0f, true, + [this, path = m_path, serial = m_dialog->getGameSerial()](ProgressCallback* progress) { + Error error; + GameDatabase::TrackVerificationResult verification; + std::unique_ptr image = CDImage::Open(path.c_str(), false, &error); + const bool result = image && GameDatabase::VerifyImage(image.get(), serial, &verification, progress, &error); + const bool cancelled = (!result && progress->IsCancelled()); + return [this, verification = std::move(verification), error = std::move(error), result, cancelled]() { + processHashResults(verification, result, cancelled, error); + }; + }); } -bool GameSummaryWidget::computeImageHash(const std::string& path, CDImageHasher::TrackHashes& track_hashes, - ProgressCallback* const progress, Error* const error) const -{ - std::unique_ptr image = CDImage::Open(path.c_str(), false, error); - if (!image) - return false; - - track_hashes.reserve(image->GetTrackCount()); - progress->SetProgressRange(image->GetTrackCount()); - - for (u32 track = 0; track < image->GetTrackCount(); track++) - { - progress->SetProgressValue(track); - progress->PushState(); - - CDImageHasher::Hash hash; - if (!CDImageHasher::GetTrackHash(image.get(), static_cast(track + 1), &hash, progress, error)) - { - progress->PopState(); - return false; - } - - track_hashes.emplace_back(hash); - progress->PopState(); - } - - return true; -} - -void GameSummaryWidget::processHashResults(const CDImageHasher::TrackHashes& track_hashes, bool result, bool cancelled, - const Error& error) +void GameSummaryWidget::processHashResults(const GameDatabase::TrackVerificationResult& verification, bool result, + bool cancelled, const Error& error) { m_ui.computeHashes->setEnabled(true); @@ -655,100 +613,17 @@ void GameSummaryWidget::processHashResults(const CDImageHasher::TrackHashes& tra return; } - // Verify hashes against gamedb - std::vector verification_results(track_hashes.size(), false); - - std::string found_revision; - std::string found_serial; - m_redump_search_keyword = CDImageHasher::HashToString(track_hashes.front()); - - // Verification strategy used: - // 1. First, find all matches for the data track - // If none are found, fail verification for all tracks - // 2. For each data track match, try to match all audio tracks - // If all match, assume this revision. Else, try other revisions, - // and accept the one with the most matches. - const GameDatabase::TrackHashesMap& hashes_map = GameDatabase::GetTrackHashesMap(); - - auto data_track_matches = hashes_map.equal_range(track_hashes[0]); - if (data_track_matches.first != data_track_matches.second) - { - auto best_data_match = data_track_matches.second; - for (auto iter = data_track_matches.first; iter != data_track_matches.second; ++iter) - { - std::vector current_verification_results(track_hashes.size(), false); - const auto& data_track_attribs = iter->second; - current_verification_results[0] = true; // Data track already matched - - for (auto audio_tracks_iter = std::next(track_hashes.begin()); audio_tracks_iter != track_hashes.end(); - ++audio_tracks_iter) - { - auto audio_track_matches = hashes_map.equal_range(*audio_tracks_iter); - for (auto audio_iter = audio_track_matches.first; audio_iter != audio_track_matches.second; ++audio_iter) - { - // If audio track comes from the same revision and code as the data track, "pass" it - if (audio_iter->second == data_track_attribs) - { - current_verification_results[std::distance(track_hashes.begin(), audio_tracks_iter)] = true; - break; - } - } - } - - const auto old_matches_count = std::count(verification_results.begin(), verification_results.end(), true); - const auto new_matches_count = - std::count(current_verification_results.begin(), current_verification_results.end(), true); - - if (new_matches_count > old_matches_count) - { - best_data_match = iter; - verification_results = current_verification_results; - // If all elements got matched, early out - if (new_matches_count >= static_cast(verification_results.size())) - { - break; - } - } - } - - found_revision = best_data_match->second.revision_str; - found_serial = best_data_match->second.serial; - } - - QString text; - - if (!found_revision.empty()) - text = tr("Revision: %1").arg(QString::fromStdString(found_revision)); - - if (found_serial != m_dialog->getGameSerial()) - { - if (found_serial.empty()) - { - text = tr("No known dump found that matches this hash."); - } - else - { - const QString mismatch_str = tr("Serial Mismatch: Disc %1 vs Hash %2") - .arg(QString::fromStdString(m_dialog->getGameSerial())) - .arg(QString::fromStdString(found_serial)); - if (!text.isEmpty()) - text = QStringLiteral("%1 | %2").arg(mismatch_str).arg(text); - else - text = mismatch_str; - } - } - - if (!text.isEmpty()) - m_ui.revision->setText(text); + if (!verification.summary.empty()) + m_ui.revision->setText(QString::fromStdString(verification.summary)); // update in ui - for (size_t i = 0; i < track_hashes.size(); i++) + for (size_t i = 0; i < verification.track_hashes.size(); i++) { QTreeWidgetItem* const row = m_ui.tracks->topLevelItem(static_cast(i)); - row->setText(4, QString::fromStdString(CDImageHasher::HashToString(track_hashes[i]))); + row->setText(4, QString::fromStdString(CDImageHasher::HashToString(verification.track_hashes[i]))); QBrush brush; - if (verification_results[i]) + if (verification.track_matches[i]) { brush = QColor(0, 200, 0); row->setText(5, QString::fromUtf8(u8"\u2713")); @@ -762,11 +637,6 @@ void GameSummaryWidget::processHashResults(const CDImageHasher::TrackHashes& tra row->setForeground(5, brush); } - if (!m_redump_search_keyword.empty()) - { - m_ui.computeHashes->setIcon(QIcon(u":/icons/monochrome/svg/mag-line.svg"_s)); - m_ui.computeHashes->setText(tr("Search on redump.info")); - } - else - m_ui.computeHashes->setEnabled(false); + m_ui.computeHashes->setIcon(QIcon(u":/icons/monochrome/svg/mag-line.svg"_s)); + m_ui.computeHashes->setText(tr("Search on redump.info")); } diff --git a/src/duckstation-qt/gamesummarywidget.h b/src/duckstation-qt/gamesummarywidget.h index ca7ce3db1..5aef92120 100644 --- a/src/duckstation-qt/gamesummarywidget.h +++ b/src/duckstation-qt/gamesummarywidget.h @@ -17,6 +17,10 @@ namespace GameList { struct Entry; } +namespace GameDatabase { +struct TrackVerificationResult; +} + class ProgressCallback; class SettingsWindow; @@ -47,15 +51,12 @@ private: void onEditInputProfileClicked(); void onComputeHashClicked(); - bool computeImageHash(const std::string& path, CDImageHasher::TrackHashes& track_hashes, - ProgressCallback* const progress, Error* const error) const; - void processHashResults(const CDImageHasher::TrackHashes& track_hashes, bool result, bool cancelled, + void processHashResults(const GameDatabase::TrackVerificationResult& verification, bool result, bool cancelled, const Error& error); Ui::GameSummaryWidget m_ui; SettingsWindow* m_dialog; std::string m_path; - std::string m_redump_search_keyword; QString m_compatibility_comments; }; diff --git a/src/util/cd_image_hasher.cpp b/src/util/cd_image_hasher.cpp index edd2e0462..fe6800a18 100644 --- a/src/util/cd_image_hasher.cpp +++ b/src/util/cd_image_hasher.cpp @@ -101,6 +101,13 @@ std::string CDImageHasher::HashToString(const Hash& hash) hash[11], hash[12], hash[13], hash[14], hash[15]); } +void CDImageHasher::HashToString(const Hash& hash, SmallStringBase* out_str) +{ + out_str->format("{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", + hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7], hash[8], hash[9], hash[10], + hash[11], hash[12], hash[13], hash[14], hash[15]); +} + std::optional CDImageHasher::HashFromString(std::string_view str) { auto decoded = StringUtil::DecodeHex(str); diff --git a/src/util/cd_image_hasher.h b/src/util/cd_image_hasher.h index 932f539c8..87d6d036c 100644 --- a/src/util/cd_image_hasher.h +++ b/src/util/cd_image_hasher.h @@ -13,6 +13,7 @@ class CDImage; class Error; class ProgressCallback; +class SmallStringBase; namespace CDImageHasher { @@ -20,6 +21,7 @@ using Hash = std::array; using TrackHashes = std::vector; std::string HashToString(const Hash& hash); +void HashToString(const Hash& hash, SmallStringBase* out_str); std::optional HashFromString(std::string_view str); bool GetImageHash(CDImage* image, Hash* out_hash, ProgressCallback* progress_callback, Error* error);