diff --git a/src/duckstation-qt/CMakeLists.txt b/src/duckstation-qt/CMakeLists.txt
index a7879fa1b..5355515c1 100644
--- a/src/duckstation-qt/CMakeLists.txt
+++ b/src/duckstation-qt/CMakeLists.txt
@@ -18,6 +18,9 @@ add_executable(duckstation-qt
gamelistsettingswidget.ui
gamelistwidget.cpp
gamelistwidget.h
+ gamepropertiesdialog.cpp
+ gamepropertiesdialog.h
+ gamepropertiesdialog.ui
generalsettingswidget.cpp
generalsettingswidget.h
generalsettingswidget.ui
diff --git a/src/duckstation-qt/duckstation-qt.vcxproj b/src/duckstation-qt/duckstation-qt.vcxproj
index 268a65d82..36fcc5231 100644
--- a/src/duckstation-qt/duckstation-qt.vcxproj
+++ b/src/duckstation-qt/duckstation-qt.vcxproj
@@ -46,6 +46,7 @@
+
@@ -73,6 +74,7 @@
+
@@ -128,6 +130,9 @@
Document
+
+ Document
+
@@ -140,6 +145,7 @@
+
diff --git a/src/duckstation-qt/gamepropertiesdialog.cpp b/src/duckstation-qt/gamepropertiesdialog.cpp
new file mode 100644
index 000000000..cd33243a2
--- /dev/null
+++ b/src/duckstation-qt/gamepropertiesdialog.cpp
@@ -0,0 +1,231 @@
+#include "gamepropertiesdialog.h"
+#include "common/cd_image.h"
+#include "core/game_list.h"
+#include "core/settings.h"
+#include "qthostinterface.h"
+#include "qtutils.h"
+#include "scmversion/scmversion.h"
+#include
+
+GamePropertiesDialog::GamePropertiesDialog(QtHostInterface* host_interface, QWidget* parent /* = nullptr */)
+ : QDialog(parent), m_host_interface(host_interface)
+{
+ m_ui.setupUi(this);
+ setupAdditionalUi();
+ connectUi();
+}
+
+GamePropertiesDialog::~GamePropertiesDialog() = default;
+
+void GamePropertiesDialog::clear()
+{
+ m_ui.imagePath->clear();
+ m_ui.gameCode->clear();
+ m_ui.title->clear();
+ m_ui.region->setCurrentIndex(0);
+
+ {
+ QSignalBlocker blocker(m_ui.compatibility);
+ m_ui.compatibility->setCurrentIndex(0);
+ }
+ {
+ QSignalBlocker blocker(m_ui.upscalingIssues);
+ m_ui.upscalingIssues->clear();
+ }
+
+ {
+ QSignalBlocker blocker(m_ui.comments);
+ m_ui.comments->clear();
+ }
+
+ m_ui.tracks->clearContents();
+}
+
+void GamePropertiesDialog::populate(const GameListEntry* ge)
+{
+ const QString title_qstring(QString::fromStdString(ge->title));
+
+ setWindowTitle(tr("Game Properties - %1").arg(title_qstring));
+ m_ui.imagePath->setText(QString::fromStdString(ge->path));
+ m_ui.title->setText(title_qstring);
+ m_ui.gameCode->setText(QString::fromStdString(ge->code));
+ m_ui.region->setCurrentIndex(static_cast(ge->region));
+
+ if (ge->code.empty())
+ {
+ // can't fill in info without a code
+ m_ui.compatibility->setDisabled(true);
+ m_ui.upscalingIssues->setDisabled(true);
+ m_ui.versionTested->setDisabled(true);
+ }
+ else
+ {
+ populateCompatibilityInfo(ge->code);
+ }
+
+ populateTracksInfo(ge->path.c_str());
+}
+
+void GamePropertiesDialog::populateCompatibilityInfo(const std::string& game_code)
+{
+ const GameListCompatibilityEntry* entry = m_host_interface->getGameList()->GetCompatibilityEntryForCode(game_code);
+
+ {
+ QSignalBlocker blocker(m_ui.compatibility);
+ m_ui.compatibility->setCurrentIndex(entry ? static_cast(entry->compatibility_rating) : 0);
+ }
+
+ {
+ QSignalBlocker blocker(m_ui.upscalingIssues);
+ m_ui.upscalingIssues->setText(entry ? QString::fromStdString(entry->upscaling_issues) : QString());
+ }
+
+ {
+ QSignalBlocker blocker(m_ui.comments);
+ m_ui.comments->setText(entry ? QString::fromStdString(entry->comments) : QString());
+ }
+}
+
+void GamePropertiesDialog::setupAdditionalUi()
+{
+ for (u8 i = 0; i < static_cast(DiscRegion::Count); i++)
+ m_ui.region->addItem(tr(Settings::GetDiscRegionDisplayName(static_cast(i))));
+
+ for (int i = 0; i < static_cast(GameListCompatibilityRating::Count); i++)
+ {
+ m_ui.compatibility->addItem(
+ tr(GameList::GetGameListCompatibilityRatingString(static_cast(i))));
+ }
+
+ setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint);
+}
+
+void GamePropertiesDialog::showForEntry(QtHostInterface* host_interface, const GameListEntry* ge)
+{
+ GamePropertiesDialog* gpd = new GamePropertiesDialog(host_interface);
+ gpd->populate(ge);
+ gpd->show();
+ gpd->onResize();
+}
+
+static QString MSFTotString(const CDImage::Position& position)
+{
+ return QStringLiteral("%1:%2:%3 (LBA %4)")
+ .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'))
+ .arg(static_cast(position.ToLBA()));
+}
+
+void GamePropertiesDialog::populateTracksInfo(const char* image_path)
+{
+ 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->clearContents();
+
+ std::unique_ptr image = CDImage::Open(image_path);
+ if (!image)
+ return;
+
+ 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));
+ const int row = static_cast(track - 1u);
+ m_ui.tracks->insertRow(row);
+ m_ui.tracks->setItem(row, 0, new QTableWidgetItem(tr("%1").arg(track)));
+ m_ui.tracks->setItem(row, 1, new QTableWidgetItem(tr(track_mode_strings[static_cast(mode)])));
+ m_ui.tracks->setItem(row, 2, new QTableWidgetItem(MSFTotString(position)));
+ m_ui.tracks->setItem(row, 3, new QTableWidgetItem(MSFTotString(length)));
+ m_ui.tracks->setItem(row, 4, new QTableWidgetItem(tr("")));
+ }
+}
+
+void GamePropertiesDialog::closeEvent(QCloseEvent* ev)
+{
+ deleteLater();
+}
+
+void GamePropertiesDialog::resizeEvent(QResizeEvent* ev)
+{
+ QDialog::resizeEvent(ev);
+ onResize();
+}
+
+void GamePropertiesDialog::onResize()
+{
+ QtUtils::ResizeColumnsForTableView(m_ui.tracks, {20, 85, 125, 125, -1});
+}
+
+void GamePropertiesDialog::connectUi()
+{
+ connect(m_ui.compatibility, static_cast(&QComboBox::currentIndexChanged), this,
+ &GamePropertiesDialog::saveCompatibilityInfo);
+ connect(m_ui.comments, &QLineEdit::textChanged, this, &GamePropertiesDialog::setCompatibilityInfoChanged);
+ connect(m_ui.comments, &QLineEdit::editingFinished, this, &GamePropertiesDialog::saveCompatibilityInfoIfChanged);
+ connect(m_ui.upscalingIssues, &QLineEdit::textChanged, this, &GamePropertiesDialog::setCompatibilityInfoChanged);
+ connect(m_ui.upscalingIssues, &QLineEdit::editingFinished, this,
+ &GamePropertiesDialog::saveCompatibilityInfoIfChanged);
+ connect(m_ui.setToCurrent, &QPushButton::clicked, this, &GamePropertiesDialog::onSetVersionTestedToCurrentClicked);
+ connect(m_ui.computeHashes, &QPushButton::clicked, this, &GamePropertiesDialog::onComputeHashClicked);
+ connect(m_ui.verifyDump, &QPushButton::clicked, this, &GamePropertiesDialog::onVerifyDumpClicked);
+ connect(m_ui.exportCompatibilityInfo, &QPushButton::clicked, this,
+ &GamePropertiesDialog::onExportCompatibilityInfoClicked);
+ connect(m_ui.close, &QPushButton::clicked, this, &QDialog::close);
+}
+
+void GamePropertiesDialog::saveCompatibilityInfo()
+{
+ GameListCompatibilityEntry new_entry;
+ new_entry.code = m_ui.gameCode->text().toStdString();
+ new_entry.title = m_ui.title->text().toStdString();
+ new_entry.version_tested = m_ui.versionTested->text().toStdString();
+ new_entry.upscaling_issues = m_ui.upscalingIssues->text().toStdString();
+ new_entry.comments = m_ui.comments->text().toStdString();
+ new_entry.compatibility_rating = static_cast(m_ui.compatibility->currentIndex());
+ new_entry.region = static_cast(m_ui.region->currentIndex());
+
+ if (new_entry.code.empty())
+ return;
+
+ m_host_interface->getGameList()->UpdateCompatibilityEntry(std::move(new_entry), true);
+ emit m_host_interface->gameListRefreshed();
+ m_compatibility_info_changed = false;
+}
+
+void GamePropertiesDialog::saveCompatibilityInfoIfChanged()
+{
+ if (!m_compatibility_info_changed)
+ return;
+
+ saveCompatibilityInfo();
+}
+
+void GamePropertiesDialog::setCompatibilityInfoChanged()
+{
+ m_compatibility_info_changed = true;
+}
+
+void GamePropertiesDialog::onSetVersionTestedToCurrentClicked()
+{
+ m_ui.versionTested->setText(QString::fromUtf8(g_scm_tag_str));
+ saveCompatibilityInfo();
+}
+
+void GamePropertiesDialog::onComputeHashClicked()
+{
+ QMessageBox::critical(this, tr("Not yet implemented"), tr("Not yet implemented"));
+}
+
+void GamePropertiesDialog::onVerifyDumpClicked()
+{
+ QMessageBox::critical(this, tr("Not yet implemented"), tr("Not yet implemented"));
+}
+
+void GamePropertiesDialog::onExportCompatibilityInfoClicked()
+{
+ QMessageBox::critical(this, tr("Not yet implemented"), tr("Not yet implemented"));
+}
diff --git a/src/duckstation-qt/gamepropertiesdialog.h b/src/duckstation-qt/gamepropertiesdialog.h
new file mode 100644
index 000000000..51325b8b3
--- /dev/null
+++ b/src/duckstation-qt/gamepropertiesdialog.h
@@ -0,0 +1,50 @@
+#pragma once
+#include "ui_gamepropertiesdialog.h"
+#include
+#include
+
+struct GameListEntry;
+
+class QtHostInterface;
+
+class GamePropertiesDialog final : public QDialog
+{
+ Q_OBJECT
+
+public:
+ GamePropertiesDialog(QtHostInterface* host_interface, QWidget* parent = nullptr);
+ ~GamePropertiesDialog();
+
+ static void showForEntry(QtHostInterface* host_interface, const GameListEntry* ge);
+
+public Q_SLOTS:
+ void clear();
+ void populate(const GameListEntry* ge);
+
+protected:
+ void closeEvent(QCloseEvent* ev);
+ void resizeEvent(QResizeEvent* ev);
+
+private Q_SLOTS:
+ void saveCompatibilityInfo();
+ void saveCompatibilityInfoIfChanged();
+ void setCompatibilityInfoChanged();
+
+ void onSetVersionTestedToCurrentClicked();
+ void onComputeHashClicked();
+ void onVerifyDumpClicked();
+ void onExportCompatibilityInfoClicked();
+
+private:
+ void setupAdditionalUi();
+ void connectUi();
+ void populateCompatibilityInfo(const std::string& game_code);
+ void populateTracksInfo(const char* image_path);
+ void onResize();
+
+ Ui::GamePropertiesDialog m_ui;
+
+ QtHostInterface* m_host_interface;
+
+ bool m_compatibility_info_changed = false;
+};
diff --git a/src/duckstation-qt/gamepropertiesdialog.ui b/src/duckstation-qt/gamepropertiesdialog.ui
new file mode 100644
index 000000000..ce0322847
--- /dev/null
+++ b/src/duckstation-qt/gamepropertiesdialog.ui
@@ -0,0 +1,225 @@
+
+
+ GamePropertiesDialog
+
+
+
+ 0
+ 0
+ 722
+ 466
+
+
+
+ Dialog
+
+
+
+ :/icons/duck.png:/icons/duck.png
+
+
+ -
+
+
+ Image Path:
+
+
+
+ -
+
+
+ true
+
+
+
+ -
+
+
+ Game Code:
+
+
+
+ -
+
+
+ true
+
+
+
+ -
+
+
+ Title:
+
+
+
+ -
+
+
+ true
+
+
+
+ -
+
+
+ Region:
+
+
+
+ -
+
+
+ false
+
+
+
+ -
+
+
+ Compatibility:
+
+
+
+ -
+
+
+ -
+
+
+ Upscaling Issues:
+
+
+
+ -
+
+
+ -
+
+
+ Comments:
+
+
+
+ -
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Compute Hashes
+
+
+
+ -
+
+
+ Verify Dump
+
+
+
+ -
+
+
+ Export Compatibility Info
+
+
+
+ -
+
+
+ Close
+
+
+ true
+
+
+
+
+
+ -
+
+
+ Tracks:
+
+
+
+ -
+
+
+ QAbstractItemView::NoEditTriggers
+
+
+ false
+
+
+ false
+
+
+
+ #
+
+
+
+
+ Mode
+
+
+
+
+ Start
+
+
+
+
+ Length
+
+
+
+
+ Hash
+
+
+
+
+ -
+
+
+ -
+
+
+ Version Tested:
+
+
+
+ -
+
+
-
+
+
+ -
+
+
+ Set to Current
+
+
+
+
+
+
+
+
+
+
diff --git a/src/duckstation-qt/mainwindow.cpp b/src/duckstation-qt/mainwindow.cpp
index 90ec57bf9..65913bf3a 100644
--- a/src/duckstation-qt/mainwindow.cpp
+++ b/src/duckstation-qt/mainwindow.cpp
@@ -5,6 +5,7 @@
#include "core/system.h"
#include "gamelistsettingswidget.h"
#include "gamelistwidget.h"
+#include "gamepropertiesdialog.h"
#include "qtdisplaywidget.h"
#include "qthostdisplay.h"
#include "qthostinterface.h"
@@ -347,7 +348,8 @@ void MainWindow::onGameListContextMenuRequested(const QPoint& point, const GameL
// Hopefully this pointer doesn't disappear... it shouldn't.
if (entry)
{
- connect(menu.addAction(tr("Properties...")), &QAction::triggered, [this]() { reportError(tr("TODO")); });
+ connect(menu.addAction(tr("Properties...")), &QAction::triggered,
+ [this, entry]() { GamePropertiesDialog::showForEntry(m_host_interface, entry); });
connect(menu.addAction(tr("Open Containing Directory...")), &QAction::triggered, [this, entry]() {
const QFileInfo fi(QString::fromStdString(entry->path));
diff --git a/src/duckstation-qt/resources/icons.qrc b/src/duckstation-qt/resources/icons.qrc
index 93629c6bd..356d5cc3b 100644
--- a/src/duckstation-qt/resources/icons.qrc
+++ b/src/duckstation-qt/resources/icons.qrc
@@ -6,6 +6,12 @@
icons/flag-jp.svg
icons/flag-us.png
icons/flag-us.svg
+ icons/star-0.png
+ icons/star-1.png
+ icons/star-2.png
+ icons/star-3.png
+ icons/star-4.png
+ icons/star-5.png
icons/applications-internet.png
icons/system-search.png
icons/list-add.png
diff --git a/src/duckstation-qt/resources/icons/star-0.png b/src/duckstation-qt/resources/icons/star-0.png
new file mode 100644
index 000000000..e5b56db70
Binary files /dev/null and b/src/duckstation-qt/resources/icons/star-0.png differ
diff --git a/src/duckstation-qt/resources/icons/star-1.png b/src/duckstation-qt/resources/icons/star-1.png
new file mode 100644
index 000000000..ae91a29ac
Binary files /dev/null and b/src/duckstation-qt/resources/icons/star-1.png differ
diff --git a/src/duckstation-qt/resources/icons/star-2.png b/src/duckstation-qt/resources/icons/star-2.png
new file mode 100644
index 000000000..f7bee9b1d
Binary files /dev/null and b/src/duckstation-qt/resources/icons/star-2.png differ
diff --git a/src/duckstation-qt/resources/icons/star-3.png b/src/duckstation-qt/resources/icons/star-3.png
new file mode 100644
index 000000000..330aefbac
Binary files /dev/null and b/src/duckstation-qt/resources/icons/star-3.png differ
diff --git a/src/duckstation-qt/resources/icons/star-4.png b/src/duckstation-qt/resources/icons/star-4.png
new file mode 100644
index 000000000..4e9f58dfa
Binary files /dev/null and b/src/duckstation-qt/resources/icons/star-4.png differ
diff --git a/src/duckstation-qt/resources/icons/star-5.png b/src/duckstation-qt/resources/icons/star-5.png
new file mode 100644
index 000000000..aa5707ea7
Binary files /dev/null and b/src/duckstation-qt/resources/icons/star-5.png differ