mirror of
https://github.com/stenzek/duckstation.git
synced 2026-07-11 01:24:11 +02:00
Qt: Move image verification logic to GameDatabase
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user