From 78653f73398b98071fe9bb4b4a32ec68f674ccfd Mon Sep 17 00:00:00 2001
From: fearlessTobi <thm.frey@gmail.com>
Date: Wed, 29 Aug 2018 15:42:53 +0200
Subject: [PATCH] Show game compatibility within yuzu

---
 .travis/linux/docker.sh                       |  2 +-
 .travis/macos/build.sh                        |  2 +-
 CMakeLists.txt                                | 13 ++++
 appveyor.yml                                  |  4 +-
 .../compatibility_list/compatibility_list.qrc |  5 ++
 src/yuzu/CMakeLists.txt                       |  4 ++
 src/yuzu/game_list.cpp                        | 65 ++++++++++++++++++-
 src/yuzu/game_list.h                          |  6 ++
 src/yuzu/game_list_p.h                        | 62 +++++++++++++++++-
 src/yuzu/main.cpp                             | 19 ++++++
 src/yuzu/main.h                               |  3 +
 src/yuzu/util/util.cpp                        | 11 ++++
 src/yuzu/util/util.h                          |  7 ++
 13 files changed, 196 insertions(+), 7 deletions(-)
 create mode 100644 dist/compatibility_list/compatibility_list.qrc

diff --git a/.travis/linux/docker.sh b/.travis/linux/docker.sh
index 376ad28ddb..d13ca50d80 100755
--- a/.travis/linux/docker.sh
+++ b/.travis/linux/docker.sh
@@ -10,7 +10,7 @@ ln -sf /usr/bin/ccache /usr/lib/ccache/cc
 ln -sf /usr/bin/ccache /usr/lib/ccache/c++
 mkdir build && cd build
 ccache --show-stats > ccache_before
-cmake .. -DYUZU_BUILD_UNICORN=ON -DCMAKE_BUILD_TYPE=Release -G Ninja
+cmake .. -DYUZU_BUILD_UNICORN=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_COMPATIBILITY_LIST_DOWNLOAD=ON -G Ninja
 ninja
 ccache --show-stats > ccache_after
 diff -U100 ccache_before ccache_after || true
diff --git a/.travis/macos/build.sh b/.travis/macos/build.sh
index 5816b1d6e0..d32340b7c5 100755
--- a/.travis/macos/build.sh
+++ b/.travis/macos/build.sh
@@ -10,7 +10,7 @@ mkdir build && cd build
 export PATH=/usr/local/opt/ccache/libexec:$PATH
 ccache --show-stats > ccache_before
 cmake --version
-cmake .. -DYUZU_BUILD_UNICORN=ON -DCMAKE_BUILD_TYPE=Release
+cmake .. -DYUZU_BUILD_UNICORN=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_COMPATIBILITY_LIST_DOWNLOAD=ON
 make -j4
 ccache --show-stats > ccache_after
 diff -U100 ccache_before ccache_after || true
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 59c6107325..0f32ecfba4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -41,6 +41,19 @@ function(check_submodules_present)
 endfunction()
 check_submodules_present()
 
+configure_file(${CMAKE_SOURCE_DIR}/dist/compatibility_list/compatibility_list.qrc
+               ${CMAKE_BINARY_DIR}/dist/compatibility_list/compatibility_list.qrc
+               COPYONLY)
+if (ENABLE_COMPATIBILITY_LIST_DOWNLOAD AND NOT EXISTS ${CMAKE_BINARY_DIR}/dist/compatibility_list/compatibility_list.json)
+    message(STATUS "Downloading compatibility list for yuzu...")
+    file(DOWNLOAD
+        https://api.yuzu-emu.org/gamedb/
+        "${CMAKE_BINARY_DIR}/dist/compatibility_list/compatibility_list.json" SHOW_PROGRESS)
+endif()
+if (NOT EXISTS ${CMAKE_BINARY_DIR}/dist/compatibility_list/compatibility_list.json)
+    file(WRITE ${CMAKE_BINARY_DIR}/dist/compatibility_list/compatibility_list.json "")
+endif()
+
 # Detect current compilation architecture and create standard definitions
 # =======================================================================
 
diff --git a/appveyor.yml b/appveyor.yml
index a6f12b2674..d68ae87b10 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -41,9 +41,9 @@ before_build:
   - ps: |
         if ($env:BUILD_TYPE -eq 'msvc') {
           # redirect stderr and change the exit code to prevent powershell from cancelling the build if cmake prints a warning
-          cmd /C 'cmake -G "Visual Studio 15 2017 Win64" -DYUZU_USE_BUNDLED_QT=1 -DYUZU_USE_BUNDLED_SDL2=1 -DYUZU_USE_BUNDLED_UNICORN=1 .. 2>&1 && exit 0'
+          cmd /C 'cmake -G "Visual Studio 15 2017 Win64" -DYUZU_USE_BUNDLED_QT=1 -DYUZU_USE_BUNDLED_SDL2=1 -DYUZU_USE_BUNDLED_UNICORN=1 -DENABLE_COMPATIBILITY_LIST_DOWNLOAD=ON .. 2>&1 && exit 0'
         } else {
-          C:\msys64\usr\bin\bash.exe -lc "cmake -G 'MSYS Makefiles' -DYUZU_BUILD_UNICORN=1 -DCMAKE_BUILD_TYPE=Release .. 2>&1"
+          C:\msys64\usr\bin\bash.exe -lc "cmake -G 'MSYS Makefiles' -DYUZU_BUILD_UNICORN=1 -DCMAKE_BUILD_TYPE=Release -DENABLE_COMPATIBILITY_LIST_DOWNLOAD=ON .. 2>&1"
         }
   - cd ..
 
diff --git a/dist/compatibility_list/compatibility_list.qrc b/dist/compatibility_list/compatibility_list.qrc
new file mode 100644
index 0000000000..a29b735981
--- /dev/null
+++ b/dist/compatibility_list/compatibility_list.qrc
@@ -0,0 +1,5 @@
+<RCC>
+  <qresource prefix="compatibility_list">
+      <file>compatibility_list.json</file>
+  </qresource>
+</RCC>
diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt
index 46ed232d83..ea9ea69e4c 100644
--- a/src/yuzu/CMakeLists.txt
+++ b/src/yuzu/CMakeLists.txt
@@ -70,6 +70,9 @@ set(UIS
     main.ui
 )
 
+file(GLOB COMPAT_LIST
+     ${CMAKE_BINARY_DIR}/dist/compatibility_list/compatibility_list.qrc
+     ${CMAKE_BINARY_DIR}/dist/compatibility_list/compatibility_list.json)
 file(GLOB_RECURSE ICONS ${CMAKE_SOURCE_DIR}/dist/icons/*)
 file(GLOB_RECURSE THEMES ${CMAKE_SOURCE_DIR}/dist/qt_themes/*)
 
@@ -77,6 +80,7 @@ qt5_wrap_ui(UI_HDRS ${UIS})
 
 target_sources(yuzu
     PRIVATE
+        ${COMPAT_LIST}
         ${ICONS}
         ${THEMES}
         ${UI_HDRS}
diff --git a/src/yuzu/game_list.cpp b/src/yuzu/game_list.cpp
index 867a3c6f11..27525938a8 100644
--- a/src/yuzu/game_list.cpp
+++ b/src/yuzu/game_list.cpp
@@ -7,10 +7,14 @@
 #include <QDir>
 #include <QFileInfo>
 #include <QHeaderView>
+#include <QJsonArray>
+#include <QJsonDocument>
+#include <QJsonObject>
 #include <QKeyEvent>
 #include <QMenu>
 #include <QThreadPool>
 #include <boost/container/flat_map.hpp>
+#include <fmt/format.h>
 #include "common/common_paths.h"
 #include "common/logging/log.h"
 #include "common/string_util.h"
@@ -224,6 +228,7 @@ GameList::GameList(FileSys::VirtualFilesystem vfs, GMainWindow* parent)
 
     item_model->insertColumns(0, COLUMN_COUNT);
     item_model->setHeaderData(COLUMN_NAME, Qt::Horizontal, "Name");
+    item_model->setHeaderData(COLUMN_COMPATIBILITY, Qt::Horizontal, "Compatibility");
     item_model->setHeaderData(COLUMN_FILE_TYPE, Qt::Horizontal, "File type");
     item_model->setHeaderData(COLUMN_SIZE, Qt::Horizontal, "Size");
 
@@ -325,12 +330,62 @@ void GameList::PopupContextMenu(const QPoint& menu_location) {
 
     QMenu context_menu;
     QAction* open_save_location = context_menu.addAction(tr("Open Save Data Location"));
+    QAction* navigate_to_gamedb_entry = context_menu.addAction(tr("Navigate to GameDB entry"));
+
     open_save_location->setEnabled(program_id != 0);
+    auto it = FindMatchingCompatibilityEntry(compatibility_list, program_id);
+    navigate_to_gamedb_entry->setVisible(it != compatibility_list.end() && program_id != 0);
+
     connect(open_save_location, &QAction::triggered,
             [&]() { emit OpenFolderRequested(program_id, GameListOpenTarget::SaveData); });
+    connect(navigate_to_gamedb_entry, &QAction::triggered,
+            [&]() { emit NavigateToGamedbEntryRequested(program_id, compatibility_list); });
+
     context_menu.exec(tree_view->viewport()->mapToGlobal(menu_location));
 }
 
+void GameList::LoadCompatibilityList() {
+    QFile compat_list{":compatibility_list/compatibility_list.json"};
+
+    if (!compat_list.open(QFile::ReadOnly | QFile::Text)) {
+        LOG_ERROR(Frontend, "Unable to open game compatibility list");
+        return;
+    }
+
+    if (compat_list.size() == 0) {
+        LOG_WARNING(Frontend, "Game compatibility list is empty");
+        return;
+    }
+
+    const QByteArray content = compat_list.readAll();
+    if (content.isEmpty()) {
+        LOG_ERROR(Frontend, "Unable to completely read game compatibility list");
+        return;
+    }
+
+    const QString string_content = content;
+    QJsonDocument json = QJsonDocument::fromJson(string_content.toUtf8());
+    QJsonArray arr = json.array();
+
+    for (const QJsonValue& value : arr) {
+        QJsonObject game = value.toObject();
+
+        if (game.contains("compatibility") && game["compatibility"].isDouble()) {
+            int compatibility = game["compatibility"].toInt();
+            QString directory = game["directory"].toString();
+            QJsonArray ids = game["releases"].toArray();
+
+            for (const QJsonValue& value : ids) {
+                QJsonObject object = value.toObject();
+                QString id = object["id"].toString();
+                compatibility_list.emplace(
+                    id.toUpper().toStdString(),
+                    std::make_pair(QString::number(compatibility), directory));
+            }
+        }
+    }
+}
+
 void GameList::PopulateAsync(const QString& dir_path, bool deep_scan) {
     if (!FileUtil::Exists(dir_path.toStdString()) ||
         !FileUtil::IsDirectory(dir_path.toStdString())) {
@@ -345,7 +400,7 @@ void GameList::PopulateAsync(const QString& dir_path, bool deep_scan) {
 
     emit ShouldCancelWorker();
 
-    GameListWorker* worker = new GameListWorker(vfs, dir_path, deep_scan);
+    GameListWorker* worker = new GameListWorker(vfs, dir_path, deep_scan, compatibility_list);
 
     connect(worker, &GameListWorker::EntryReady, this, &GameList::AddEntry, Qt::QueuedConnection);
     connect(worker, &GameListWorker::Finished, this, &GameList::DonePopulating,
@@ -523,11 +578,19 @@ void GameListWorker::AddFstEntriesToGameList(const std::string& dir_path, unsign
                 }
             }
 
+            auto it = FindMatchingCompatibilityEntry(compatibility_list, program_id);
+
+            // The game list uses this as compatibility number for untested games
+            QString compatibility("99");
+            if (it != compatibility_list.end())
+                compatibility = it->second.first;
+
             emit EntryReady({
                 new GameListItemPath(
                     FormatGameName(physical_name), icon, QString::fromStdString(name),
                     QString::fromStdString(Loader::GetFileTypeString(loader->GetFileType())),
                     program_id),
+                new GameListItemCompat(compatibility),
                 new GameListItem(
                     QString::fromStdString(Loader::GetFileTypeString(loader->GetFileType()))),
                 new GameListItemSize(FileUtil::GetSize(physical_name)),
diff --git a/src/yuzu/game_list.h b/src/yuzu/game_list.h
index 20252e778d..c01351dc96 100644
--- a/src/yuzu/game_list.h
+++ b/src/yuzu/game_list.h
@@ -29,6 +29,7 @@ class GameList : public QWidget {
 public:
     enum {
         COLUMN_NAME,
+        COLUMN_COMPATIBILITY,
         COLUMN_FILE_TYPE,
         COLUMN_SIZE,
         COLUMN_COUNT, // Number of columns
@@ -68,6 +69,7 @@ public:
     void setFilterFocus();
     void setFilterVisible(bool visibility);
 
+    void LoadCompatibilityList();
     void PopulateAsync(const QString& dir_path, bool deep_scan);
 
     void SaveInterfaceLayout();
@@ -79,6 +81,9 @@ signals:
     void GameChosen(QString game_path);
     void ShouldCancelWorker();
     void OpenFolderRequested(u64 program_id, GameListOpenTarget target);
+    void NavigateToGamedbEntryRequested(
+        u64 program_id,
+        std::unordered_map<std::string, std::pair<QString, QString>>& compatibility_list);
 
 private slots:
     void onTextChanged(const QString& newText);
@@ -100,6 +105,7 @@ private:
     QStandardItemModel* item_model = nullptr;
     GameListWorker* current_worker = nullptr;
     QFileSystemWatcher* watcher = nullptr;
+    std::unordered_map<std::string, std::pair<QString, QString>> compatibility_list;
 };
 
 Q_DECLARE_METATYPE(GameListOpenTarget);
diff --git a/src/yuzu/game_list_p.h b/src/yuzu/game_list_p.h
index 1d6c854001..b9676d069b 100644
--- a/src/yuzu/game_list_p.h
+++ b/src/yuzu/game_list_p.h
@@ -8,11 +8,15 @@
 #include <atomic>
 #include <map>
 #include <memory>
+#include <unordered_map>
 #include <utility>
+#include <QCoreApplication>
 #include <QImage>
+#include <QObject>
 #include <QRunnable>
 #include <QStandardItem>
 #include <QString>
+#include "common/logging/log.h"
 #include "common/string_util.h"
 #include "core/file_sys/content_archive.h"
 #include "ui_settings.h"
@@ -29,6 +33,17 @@ static QPixmap GetDefaultIcon(u32 size) {
     return icon;
 }
 
+static auto FindMatchingCompatibilityEntry(
+    const std::unordered_map<std::string, std::pair<QString, QString>>& compatibility_list,
+    u64 program_id) {
+    return std::find_if(
+        compatibility_list.begin(), compatibility_list.end(),
+        [program_id](const std::pair<std::string, std::pair<QString, QString>>& element) {
+            std::string pid = fmt::format("{:016X}", program_id);
+            return element.first == pid;
+        });
+}
+
 class GameListItem : public QStandardItem {
 
 public:
@@ -96,6 +111,45 @@ public:
     }
 };
 
+class GameListItemCompat : public GameListItem {
+    Q_DECLARE_TR_FUNCTIONS(GameListItemCompat)
+public:
+    static const int CompatNumberRole = Qt::UserRole + 1;
+    GameListItemCompat() = default;
+    explicit GameListItemCompat(const QString& compatiblity) {
+        struct CompatStatus {
+            QString color;
+            const char* text;
+            const char* tooltip;
+        };
+        // clang-format off
+        static const std::map<QString, CompatStatus> status_data = {
+        {"0",  {"#5c93ed", QT_TR_NOOP("Perfect"),    QT_TR_NOOP("Game functions flawless with no audio or graphical glitches, all tested functionality works as intended without\nany workarounds needed.")}},
+        {"1",  {"#47d35c", QT_TR_NOOP("Great"),      QT_TR_NOOP("Game functions with minor graphical or audio glitches and is playable from start to finish. May require some\nworkarounds.")}},
+        {"2",  {"#94b242", QT_TR_NOOP("Okay"),       QT_TR_NOOP("Game functions with major graphical or audio glitches, but game is playable from start to finish with\nworkarounds.")}},
+        {"3",  {"#f2d624", QT_TR_NOOP("Bad"),        QT_TR_NOOP("Game functions, but with major graphical or audio glitches. Unable to progress in specific areas due to glitches\neven with workarounds.")}},
+        {"4",  {"#FF0000", QT_TR_NOOP("Intro/Menu"), QT_TR_NOOP("Game is completely unplayable due to major graphical or audio glitches. Unable to progress past the Start\nScreen.")}},
+        {"5",  {"#828282", QT_TR_NOOP("Won't Boot"), QT_TR_NOOP("The game crashes when attempting to startup.")}},
+        {"99", {"#000000", QT_TR_NOOP("Not Tested"), QT_TR_NOOP("The game has not yet been tested.")}}};
+        // clang-format on
+
+        auto iterator = status_data.find(compatiblity);
+        if (iterator == status_data.end()) {
+            LOG_WARNING(Frontend, "Invalid compatibility number {}", compatiblity.toStdString());
+            return;
+        }
+        CompatStatus status = iterator->second;
+        setData(compatiblity, CompatNumberRole);
+        setText(QObject::tr(status.text));
+        setToolTip(QObject::tr(status.tooltip));
+        setData(CreateCirclePixmapFromColor(status.color), Qt::DecorationRole);
+    }
+
+    bool operator<(const QStandardItem& other) const override {
+        return data(CompatNumberRole) < other.data(CompatNumberRole);
+    }
+};
+
 /**
  * A specialization of GameListItem for size values.
  * This class ensures that for every numerical size value it holds (in bytes), a correct
@@ -141,8 +195,11 @@ class GameListWorker : public QObject, public QRunnable {
     Q_OBJECT
 
 public:
-    GameListWorker(FileSys::VirtualFilesystem vfs, QString dir_path, bool deep_scan)
-        : vfs(std::move(vfs)), dir_path(std::move(dir_path)), deep_scan(deep_scan) {}
+    GameListWorker(
+        FileSys::VirtualFilesystem vfs, QString dir_path, bool deep_scan,
+        const std::unordered_map<std::string, std::pair<QString, QString>>& compatibility_list)
+        : vfs(std::move(vfs)), dir_path(std::move(dir_path)), deep_scan(deep_scan),
+          compatibility_list(compatibility_list) {}
 
 public slots:
     /// Starts the processing of directory tree information.
@@ -170,6 +227,7 @@ private:
     QStringList watch_list;
     QString dir_path;
     bool deep_scan;
+    const std::unordered_map<std::string, std::pair<QString, QString>>& compatibility_list;
     std::atomic_bool stop_processing;
 
     void AddInstalledTitlesToGameList(std::shared_ptr<FileSys::RegisteredCache> cache);
diff --git a/src/yuzu/main.cpp b/src/yuzu/main.cpp
index ffa9f72aa5..1501aedc45 100644
--- a/src/yuzu/main.cpp
+++ b/src/yuzu/main.cpp
@@ -16,6 +16,7 @@
 #include <QMessageBox>
 #include <QtGui>
 #include <QtWidgets>
+#include <fmt/format.h>
 #include "common/common_paths.h"
 #include "common/logging/backend.h"
 #include "common/logging/filter.h"
@@ -35,6 +36,7 @@
 #include "core/gdbstub/gdbstub.h"
 #include "core/loader/loader.h"
 #include "core/settings.h"
+#include "game_list_p.h"
 #include "video_core/debug_utils/debug_utils.h"
 #include "yuzu/about_dialog.h"
 #include "yuzu/bootmanager.h"
@@ -134,6 +136,7 @@ GMainWindow::GMainWindow()
 
     // Necessary to load titles from nand in gamelist.
     Service::FileSystem::CreateFactories(vfs);
+    game_list->LoadCompatibilityList();
     game_list->PopulateAsync(UISettings::values.gamedir, UISettings::values.gamedir_deepscan);
 
     // Show one-time "callout" messages to the user
@@ -349,6 +352,8 @@ void GMainWindow::RestoreUIState() {
 void GMainWindow::ConnectWidgetEvents() {
     connect(game_list, &GameList::GameChosen, this, &GMainWindow::OnGameListLoadFile);
     connect(game_list, &GameList::OpenFolderRequested, this, &GMainWindow::OnGameListOpenFolder);
+    connect(game_list, &GameList::NavigateToGamedbEntryRequested, this,
+            &GMainWindow::OnGameListNavigateToGamedbEntry);
 
     connect(this, &GMainWindow::EmulationStarting, render_window,
             &GRenderWindow::OnEmulationStarting);
@@ -678,6 +683,20 @@ void GMainWindow::OnGameListOpenFolder(u64 program_id, GameListOpenTarget target
     QDesktopServices::openUrl(QUrl::fromLocalFile(qpath));
 }
 
+void GMainWindow::OnGameListNavigateToGamedbEntry(
+    u64 program_id,
+    std::unordered_map<std::string, std::pair<QString, QString>>& compatibility_list) {
+
+    auto it = FindMatchingCompatibilityEntry(compatibility_list, program_id);
+
+    QString directory;
+
+    if (it != compatibility_list.end())
+        directory = it->second.second;
+
+    QDesktopServices::openUrl(QUrl("https://yuzu-emu.org/game/" + directory));
+}
+
 void GMainWindow::OnMenuLoadFile() {
     QString extensions;
     for (const auto& piece : game_list->supported_file_extensions)
diff --git a/src/yuzu/main.h b/src/yuzu/main.h
index d1d34552b4..fd2436f4d9 100644
--- a/src/yuzu/main.h
+++ b/src/yuzu/main.h
@@ -124,6 +124,9 @@ private slots:
     /// Called whenever a user selects a game in the game list widget.
     void OnGameListLoadFile(QString game_path);
     void OnGameListOpenFolder(u64 program_id, GameListOpenTarget target);
+    void OnGameListNavigateToGamedbEntry(
+        u64 program_id,
+        std::unordered_map<std::string, std::pair<QString, QString>>& compatibility_list);
     void OnMenuLoadFile();
     void OnMenuLoadFolder();
     void OnMenuInstallToNAND();
diff --git a/src/yuzu/util/util.cpp b/src/yuzu/util/util.cpp
index 91d3f7def4..e99042a239 100644
--- a/src/yuzu/util/util.cpp
+++ b/src/yuzu/util/util.cpp
@@ -4,6 +4,7 @@
 
 #include <array>
 #include <cmath>
+#include <QPainter>
 #include "yuzu/util/util.h"
 
 QFont GetMonospaceFont() {
@@ -24,3 +25,13 @@ QString ReadableByteSize(qulonglong size) {
         .arg(size / std::pow(1024, digit_groups), 0, 'f', 1)
         .arg(units[digit_groups]);
 }
+
+QPixmap CreateCirclePixmapFromColor(const QColor& color) {
+    QPixmap circle_pixmap(16, 16);
+    circle_pixmap.fill(Qt::transparent);
+    QPainter painter(&circle_pixmap);
+    painter.setPen(color);
+    painter.setBrush(color);
+    painter.drawEllipse(0, 0, 15, 15);
+    return circle_pixmap;
+}
diff --git a/src/yuzu/util/util.h b/src/yuzu/util/util.h
index ab443ef9b5..e6790f2606 100644
--- a/src/yuzu/util/util.h
+++ b/src/yuzu/util/util.h
@@ -12,3 +12,10 @@ QFont GetMonospaceFont();
 
 /// Convert a size in bytes into a readable format (KiB, MiB, etc.)
 QString ReadableByteSize(qulonglong size);
+
+/**
+ * Creates a circle pixmap from a specified color
+ * @param color The color the pixmap shall have
+ * @return QPixmap circle pixmap
+ */
+QPixmap CreateCirclePixmapFromColor(const QColor& color);