Qt: Move image verification logic to GameDatabase

This commit is contained in:
Stenzek
2026-07-03 00:13:17 +10:00
parent 3441574be6
commit c3af062bf0
6 changed files with 182 additions and 162 deletions
+127
View File
@@ -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<std::string_view>& lookup, const r
static void BindDiscSetsToEntries();
static bool LoadTrackHashes();
namespace {
struct TrackHashMatchResult
{
std::vector<bool> track_matches;
std::string revision;
std::string serial;
};
} // namespace
static TrackHashMatchResult MatchTrackHashes(const CDImageHasher::TrackHashes& track_hashes);
static constexpr const std::array<const char*, static_cast<int>(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<bool>(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<bool> 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<u8>(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);
}
+13
View File
@@ -16,6 +16,8 @@
#include <vector>
class CDImage;
class Error;
class ProgressCallback;
struct Settings;
@@ -241,4 +243,15 @@ using TrackHashesMap = std::multimap<CDImageHasher::Hash, TrackData>;
const TrackHashesMap& GetTrackHashesMap();
void EnsureTrackHashesMapLoaded();
struct TrackVerificationResult
{
CDImageHasher::TrackHashes track_hashes;
std::vector<bool> 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
+28 -158
View File
@@ -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<uint>(position.minute), 2, 10, static_cast<QChar>('0'))
.arg(static_cast<uint>(position.second), 2, 10, static_cast<QChar>('0'))
.arg(static_cast<uint>(position.frame), 2, 10, static_cast<QChar>('0'));
}
void GameSummaryWidget::populateTracksInfo()
{
static constexpr std::array<const char*, 8> 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<u8>(track));
const CDImage::Position length = image->GetTrackMSFLength(static_cast<u8>(track));
const CDImage::TrackMode mode = image->GetTrackMode(static_cast<u8>(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<u32>(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<u8>(track)).ToString()));
row->setText(3, QString::fromStdString(image->GetTrackMSFLength(static_cast<u8>(track)).ToString()));
row->setText(4, tr("<not computed>"));
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<CDImage> 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<CDImage> 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<u8>(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<bool> 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<bool> 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<ptrdiff_t>(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<int>(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"));
}
+5 -4
View File
@@ -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;
};
+7
View File
@@ -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::Hash> CDImageHasher::HashFromString(std::string_view str)
{
auto decoded = StringUtil::DecodeHex(str);
+2
View File
@@ -13,6 +13,7 @@
class CDImage;
class Error;
class ProgressCallback;
class SmallStringBase;
namespace CDImageHasher {
@@ -20,6 +21,7 @@ using Hash = std::array<u8, 16>;
using TrackHashes = std::vector<Hash>;
std::string HashToString(const Hash& hash);
void HashToString(const Hash& hash, SmallStringBase* out_str);
std::optional<Hash> HashFromString(std::string_view str);
bool GetImageHash(CDImage* image, Hash* out_hash, ProgressCallback* progress_callback, Error* error);