From 0419de4bafb9381a4866d5c43ab95d730ddf15aa Mon Sep 17 00:00:00 2001 From: Connor McLaughlin Date: Mon, 18 Apr 2022 23:35:14 +1000 Subject: [PATCH] Qt: Add RetroAchievements implementation --- bin/resources/sounds/achievements/README.txt | 2 + .../sounds/achievements/lbsubmit.wav | Bin 0 -> 82096 bytes bin/resources/sounds/achievements/message.wav | Bin 0 -> 15696 bytes bin/resources/sounds/achievements/unlock.wav | Bin 0 -> 87714 bytes common/CocoaTools.mm | 10 + common/General.h | 7 + common/Linux/LnxMisc.cpp | 19 + common/Windows/WinMisc.cpp | 10 + common/ZipHelpers.h | 39 + pcsx2-qt/CMakeLists.txt | 11 + pcsx2-qt/MainWindow.cpp | 46 +- pcsx2-qt/MainWindow.ui | 10 + pcsx2-qt/QtHost.cpp | 61 + pcsx2-qt/QtHost.h | 3 + pcsx2-qt/Settings/AchievementLoginDialog.cpp | 91 + pcsx2-qt/Settings/AchievementLoginDialog.h | 41 + pcsx2-qt/Settings/AchievementLoginDialog.ui | 140 ++ .../Settings/AchievementSettingsWidget.cpp | 194 ++ pcsx2-qt/Settings/AchievementSettingsWidget.h | 42 + .../Settings/AchievementSettingsWidget.ui | 181 ++ pcsx2-qt/Settings/SettingsDialog.cpp | 35 +- pcsx2-qt/Settings/SettingsDialog.h | 5 +- pcsx2-qt/pcsx2-qt.vcxproj | 14 +- pcsx2-qt/pcsx2-qt.vcxproj.filters | 26 +- .../icons/black/svg/login-box-line.svg | 6 + .../resources/icons/black/svg/trophy-line.svg | 6 + .../icons/white/svg/login-box-line.svg | 6 + .../resources/icons/white/svg/trophy-line.svg | 6 + pcsx2-qt/resources/resources.qrc | 4 + pcsx2/Achievements.h | 75 + pcsx2/CMakeLists.txt | 16 + pcsx2/Config.h | 37 + pcsx2/Frontend/Achievements.cpp | 2215 +++++++++++++++++ pcsx2/Frontend/Achievements.h | 171 ++ pcsx2/Frontend/CommonHost.cpp | 44 +- pcsx2/Frontend/CommonHotkeys.cpp | 14 + pcsx2/Frontend/FullscreenUI.cpp | 984 +++++++- pcsx2/Frontend/FullscreenUI.h | 5 + pcsx2/Frontend/ImGuiManager.cpp | 2 +- pcsx2/Pcsx2Config.cpp | 32 + pcsx2/SaveState.cpp | 65 +- pcsx2/VMManager.cpp | 83 +- pcsx2/pcsx2core.vcxproj | 12 +- pcsx2/pcsx2core.vcxproj.filters | 7 + 44 files changed, 4745 insertions(+), 32 deletions(-) create mode 100644 bin/resources/sounds/achievements/README.txt create mode 100644 bin/resources/sounds/achievements/lbsubmit.wav create mode 100644 bin/resources/sounds/achievements/message.wav create mode 100644 bin/resources/sounds/achievements/unlock.wav create mode 100644 pcsx2-qt/Settings/AchievementLoginDialog.cpp create mode 100644 pcsx2-qt/Settings/AchievementLoginDialog.h create mode 100644 pcsx2-qt/Settings/AchievementLoginDialog.ui create mode 100644 pcsx2-qt/Settings/AchievementSettingsWidget.cpp create mode 100644 pcsx2-qt/Settings/AchievementSettingsWidget.h create mode 100644 pcsx2-qt/Settings/AchievementSettingsWidget.ui create mode 100644 pcsx2-qt/resources/icons/black/svg/login-box-line.svg create mode 100644 pcsx2-qt/resources/icons/black/svg/trophy-line.svg create mode 100644 pcsx2-qt/resources/icons/white/svg/login-box-line.svg create mode 100644 pcsx2-qt/resources/icons/white/svg/trophy-line.svg create mode 100644 pcsx2/Achievements.h create mode 100644 pcsx2/Frontend/Achievements.cpp create mode 100644 pcsx2/Frontend/Achievements.h diff --git a/bin/resources/sounds/achievements/README.txt b/bin/resources/sounds/achievements/README.txt new file mode 100644 index 0000000000..db96edd8ea --- /dev/null +++ b/bin/resources/sounds/achievements/README.txt @@ -0,0 +1,2 @@ +lbsubmit.wav: https://freesound.org/people/Eponn/sounds/636656/ +unlock.wav and message.wav are from https://github.com/RetroAchievements/RAInterface diff --git a/bin/resources/sounds/achievements/lbsubmit.wav b/bin/resources/sounds/achievements/lbsubmit.wav new file mode 100644 index 0000000000000000000000000000000000000000..218073d02bb59f55d42cbd828e1d0b9ec7930d83 GIT binary patch literal 82096 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ɽ+xhEN7^hCl{x27iYC|J@iq{&!_~`rn-4 z>VHdyv*6JH~I|Aq`r|E(CB{(Cbt{|{$q`=7(m z^S_p1(*G`o`Tu8u*H(h|`|bI^jp6A3oebyy?_{|5e;32Y{~H)s88$JyXzx3be|H1!0|F`~6{J;2r{r{=|r~L2y zzvX}3|5N|V{y+Jj_y6<%?EfqbdH)3&vj0mnRQy+EDEY6+(DvVu0koTK&VOTuh5xk} zHvHFO*!y3R;n;sshKv7s7_R?kV7T@F^Z#rAU;V%I|K9(j|F8Yu|Nr#=4gXL6UkqOB zx&MFr|3m*9{~!8a{Qu(ry#M$9r-Se4Vq%E@FT)V_U!Ni9zYl}o|5yg^|0N88|7#e$ z{&z5h{_kQ4{NK(H|G$AD^M3_H_5WOkzW-SaEB>c59QdEcaOZy(!@vLe3~~%540a6l z42cX43^feh3^N&e88$FXW;nnwf#D)UKf@J9jM47dL0Fzo#w%&_{uH$(q_D{%Z3{nup3{x8pv{$H9Q1$-lv0C?6k4}3lb zFGKZzA%=$kk_;XHl^7=d*JGIV--==Re^-VL|NR(t{*Pif_&C|5Il8^-qj}VH-ShyVZn$Nzu-zv2J$|MUM}`+xBNx&Jr*AN>FI z|BnA03@iUjGc5UU$S~=@B}3PLUxupxz6`nlqZrcuhcU$ck7tPdAIXsLKZPObe1Nq_bK6ay>64F)BK#|)MX&lsW@ zUNK}bJZ5NTxXmz$;Vi>ShTRO?7#1@eXJ}`*z>v*w8+_ZM8pB-%7KR%P_y1pI*#G|` z!?OP;7`px+XK4JthoSucc82=@D;e7V&tsVNe>!;e$Nv9q443}5FueF*$?yYo-vdK2 PgD68agDOKdgBk+>5k2Tk literal 0 HcmV?d00001 diff --git a/bin/resources/sounds/achievements/message.wav b/bin/resources/sounds/achievements/message.wav new file mode 100644 index 0000000000000000000000000000000000000000..7146107f760d293b61dadd9c5449456cb95630a3 GIT binary patch literal 15696 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⋘92Lm$ILhG`5l80Is~2g|Qw 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 j4y3ut*^NHpYiH{N= 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Ξ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 TGz3ONU^E0qLtr!n=pO #include @@ -125,3 +126,12 @@ void CocoaTools::RemoveThemeChangeHandler(void* ctx) assert([NSThread isMainThread]); [s_themeChangeHandler removeCallback:ctx]; } + +// MARK: - Sound playback + +bool Common::PlaySoundAsync(const char* path) +{ + NSString* nspath = [[NSString alloc] initWithUTF8String:path]; + NSSound* sound = [[NSSound alloc] initWithContentsOfFile:nspath byReference:YES]; + return [sound play]; +} diff --git a/common/General.h b/common/General.h index 8c62763d89..1c3a2f2698 100644 --- a/common/General.h +++ b/common/General.h @@ -166,3 +166,10 @@ extern const u32 SPIN_TIME_NS; extern std::string GetOSVersionString(); void ScreensaverAllow(bool allow); + +namespace Common +{ + /// Abstracts platform-specific code for asynchronously playing a sound. + /// On Windows, this will use PlaySound(). On Linux, it will shell out to aplay. On MacOS, it uses NSSound. + bool PlaySoundAsync(const char* path); +} // namespace Common diff --git a/common/Linux/LnxMisc.cpp b/common/Linux/LnxMisc.cpp index a7f8a8546e..e0fac8fe65 100644 --- a/common/Linux/LnxMisc.cpp +++ b/common/Linux/LnxMisc.cpp @@ -17,7 +17,9 @@ #include #include #include +#include #include +#include #include "common/Pcsx2Types.h" #include "common/General.h" @@ -64,4 +66,21 @@ void ScreensaverAllow(bool allow) { // no-op } + +bool Common::PlaySoundAsync(const char* path) +{ +#ifdef __linux__ + // This is... pretty awful. But I can't think of a better way without linking to e.g. gstreamer. + const char* cmdname = "aplay"; + const char* argv[] = {cmdname, path, nullptr}; + pid_t pid; + + // Since we set SA_NOCLDWAIT in Qt, we don't need to wait here. + int res = posix_spawnp(&pid, cmdname, nullptr, nullptr, const_cast(argv), environ); + return (res == 0); +#else + return false; +#endif +} + #endif diff --git a/common/Windows/WinMisc.cpp b/common/Windows/WinMisc.cpp index 67c8f015a0..35472b784b 100644 --- a/common/Windows/WinMisc.cpp +++ b/common/Windows/WinMisc.cpp @@ -19,9 +19,12 @@ #include "common/RedtapeWindows.h" #include "common/Exceptions.h" #include "common/StringUtil.h" +#include "common/General.h" #include "fmt/core.h" +#include + #pragma comment(lib, "User32.lib") alignas(16) static LARGE_INTEGER lfreq; @@ -116,4 +119,11 @@ void ScreensaverAllow(bool allow) flags |= ES_DISPLAY_REQUIRED; SetThreadExecutionState(flags); } + +bool Common::PlaySoundAsync(const char* path) +{ + const std::wstring wpath(StringUtil::UTF8StringToWideString(path)); + return PlaySoundW(wpath.c_str(), NULL, SND_ASYNC | SND_NODEFAULT); +} + #endif diff --git a/common/ZipHelpers.h b/common/ZipHelpers.h index 283ce37d01..dfcbf54802 100644 --- a/common/ZipHelpers.h +++ b/common/ZipHelpers.h @@ -104,12 +104,51 @@ static inline std::optional ReadFileInZipToContainer(zip_t* zip, const char* return ret; } + +template +static inline std::optional ReadFileInZipToContainer(zip_file_t* file, u32 chunk_size = 4096) +{ + std::optional ret = T(); + for (;;) + { + const size_t pos = ret->size(); + ret->resize(pos + chunk_size); + const s64 read = zip_fread(file, ret->data() + pos, chunk_size); + if (read < 0) + { + // read error + ret.reset(); + break; + } + + // if less than chunk size, we're EOF + if (read != static_cast(chunk_size)) + { + ret->resize(pos + static_cast(read)); + break; + } + } + + return ret; +} + + static inline std::optional ReadFileInZipToString(zip_t* zip, const char* name) { return ReadFileInZipToContainer(zip, name); } +static inline std::optional ReadFileInZipToString(zip_file_t* file, u32 chunk_size = 4096) +{ + return ReadFileInZipToContainer(file, chunk_size); +} + static inline std::optional> ReadBinaryFileInZip(zip_t* zip, const char* name) { return ReadFileInZipToContainer>(zip, name); } + +static inline std::optional> ReadBinaryFileInZip(zip_file_t* file, u32 chunk_size = 4096) +{ + return ReadFileInZipToContainer>(file, chunk_size); +} \ No newline at end of file diff --git a/pcsx2-qt/CMakeLists.txt b/pcsx2-qt/CMakeLists.txt index 039be09df7..619cc3932f 100644 --- a/pcsx2-qt/CMakeLists.txt +++ b/pcsx2-qt/CMakeLists.txt @@ -121,6 +121,17 @@ target_sources(pcsx2-qt PRIVATE resources/resources.qrc ) +if(USE_ACHIEVEMENTS) + target_sources(pcsx2-qt PRIVATE + Settings/AchievementLoginDialog.cpp + Settings/AchievementLoginDialog.h + Settings/AchievementLoginDialog.ui + Settings/AchievementSettingsWidget.cpp + Settings/AchievementSettingsWidget.h + Settings/AchievementSettingsWidget.ui + ) +endif() + target_precompile_headers(pcsx2-qt PRIVATE PrecompiledHeader.h) target_include_directories(pcsx2-qt PRIVATE diff --git a/pcsx2-qt/MainWindow.cpp b/pcsx2-qt/MainWindow.cpp index 8ada30b9b3..e6fd7bd71c 100644 --- a/pcsx2-qt/MainWindow.cpp +++ b/pcsx2-qt/MainWindow.cpp @@ -54,6 +54,10 @@ #include "svnrev.h" #include "Tools/InputRecording/NewInputRecordingDlg.h" +#ifdef ENABLE_RAINTEGRATION +#include "pcsx2/Frontend/Achievements.h" +#endif + static constexpr char OPEN_FILE_FILTER[] = QT_TRANSLATE_NOOP("MainWindow", "All File Types (*.bin *.iso *.cue *.chd *.cso *.gz *.elf *.irx *.m3u *.gs *.gs.xz *.gs.zst *.dump);;" @@ -128,8 +132,8 @@ void MainWindow::initialize() CocoaTools::AddThemeChangeHandler(this, [](void* ctx) { // This handler is called *before* the style change has propagated far enough for Qt to see it // Use RunOnUIThread to delay until it has - QtHost::RunOnUIThread([ctx = static_cast(ctx)]{ - ctx->updateTheme();// Qt won't notice the style change without us touching the palette in some way + QtHost::RunOnUIThread([ctx = static_cast(ctx)] { + ctx->updateTheme(); // Qt won't notice the style change without us touching the palette in some way }); }); #endif @@ -238,6 +242,38 @@ void MainWindow::setupAdditionalUi() updateEmulationActions(false, false); updateDisplayRelatedActions(false, false, false); + +#ifdef ENABLE_RAINTEGRATION + if (Achievements::IsUsingRAIntegration()) + { + QMenu* raMenu = new QMenu(QStringLiteral("RAIntegration"), m_ui.menuDebug); + connect(raMenu, &QMenu::aboutToShow, this, [this, raMenu]() { + raMenu->clear(); + + const auto items = Achievements::RAIntegration::GetMenuItems(); + for (const auto& [id, title, checked] : items) + { + if (id == 0) + { + raMenu->addSeparator(); + continue; + } + + QAction* raAction = raMenu->addAction(QString::fromUtf8(title)); + if (checked) + { + raAction->setCheckable(true); + raAction->setChecked(checked); + } + + connect(raAction, &QAction::triggered, this, [id = id]() { + Host::RunOnCPUThread([id]() { Achievements::RAIntegration::ActivateMenuItem(id); }, false); + }); + } + }); + m_ui.menuDebug->insertMenu(m_ui.actionToggleSoftwareRendering, raMenu); + } +#endif } void MainWindow::connectSignals() @@ -271,6 +307,7 @@ void MainWindow::connectSignals() connect(m_ui.actionMemoryCardSettings, &QAction::triggered, [this]() { doSettings("Memory Cards"); }); connect(m_ui.actionDEV9Settings, &QAction::triggered, [this]() { doSettings("Network & HDD"); }); connect(m_ui.actionFolderSettings, &QAction::triggered, [this]() { doSettings("Folders"); }); + connect(m_ui.actionAchievementSettings, &QAction::triggered, [this]() { doSettings("Achievements"); }); connect( m_ui.actionControllerSettings, &QAction::triggered, [this]() { doControllerSettings(ControllerSettingsDialog::Category::GlobalSettings); }); connect(m_ui.actionHotkeySettings, &QAction::triggered, [this]() { doControllerSettings(ControllerSettingsDialog::Category::HotkeySettings); }); @@ -1690,6 +1727,11 @@ void MainWindow::showEvent(QShowEvent* event) // the rest of the window... so, instead, let's just force it to be resized on show. if (isShowingGameList()) m_game_list_widget->resizeTableViewColumnsToFit(); + +#ifdef ENABLE_RAINTEGRATION + if (Achievements::IsUsingRAIntegration()) + Achievements::RAIntegration::MainWindowChanged((void*)winId()); +#endif } void MainWindow::closeEvent(QCloseEvent* event) diff --git a/pcsx2-qt/MainWindow.ui b/pcsx2-qt/MainWindow.ui index f6badcdc43..34d9652278 100644 --- a/pcsx2-qt/MainWindow.ui +++ b/pcsx2-qt/MainWindow.ui @@ -98,6 +98,7 @@ + @@ -409,6 +410,15 @@ &Graphics + + + + .. + + + A&chievements + + &Post-Processing Settings... diff --git a/pcsx2-qt/QtHost.cpp b/pcsx2-qt/QtHost.cpp index 5c8226d4fc..fc68e98c41 100644 --- a/pcsx2-qt/QtHost.cpp +++ b/pcsx2-qt/QtHost.cpp @@ -63,6 +63,10 @@ #include "QtUtils.h" #include "svnrev.h" +#ifdef ENABLE_ACHIEVEMENTS +#include "Frontend/Achievements.h" +#endif + static constexpr u32 SETTINGS_SAVE_DELAY = 1000; EmuThread* g_emu_thread = nullptr; @@ -1111,6 +1115,45 @@ void Host::OnSaveStateSaved(const std::string_view& filename) emit g_emu_thread->onSaveStateSaved(QtUtils::StringViewToQString(filename)); } +#ifdef ENABLE_ACHIEVEMENTS +void Host::OnAchievementsRefreshed() +{ + u32 game_id = 0; + u32 achievement_count = 0; + u32 max_points = 0; + + QString game_info; + + if (Achievements::HasActiveGame()) + { + game_id = Achievements::GetGameID(); + achievement_count = Achievements::GetAchievementCount(); + max_points = Achievements::GetMaximumPointsForGame(); + + game_info = qApp->translate("EmuThread", + "Game ID: %1\n" + "Game Title: %2\n" + "Achievements: %5 (%6)\n\n") + .arg(game_id) + .arg(QString::fromStdString(Achievements::GetGameTitle())) + .arg(achievement_count) + .arg(qApp->translate("EmuThread", "%n points", "", max_points)); + + const std::string rich_presence_string(Achievements::GetRichPresenceString()); + if (!rich_presence_string.empty()) + game_info.append(QString::fromStdString(rich_presence_string)); + else + game_info.append(qApp->translate("EmuThread", "Rich presence inactive or unsupported.")); + } + else + { + game_info = qApp->translate("EmuThread", "Game not loaded or no RetroAchievements available."); + } + + emit g_emu_thread->onAchievementsRefreshed(game_id, game_info, achievement_count, max_points); +} +#endif + void Host::CPUThreadVSync() { g_emu_thread->getEventLoop()->processEvents(QEventLoop::AllEvents); @@ -1475,6 +1518,14 @@ void QtHost::HookSignals() { std::signal(SIGINT, SignalHandler); std::signal(SIGTERM, SignalHandler); + +#ifdef __linux__ + // Ignore SIGCHLD by default on Linux, since we kick off aplay asynchronously. + struct sigaction sa_chld = {}; + sigemptyset(&sa_chld.sa_mask); + sa_chld.sa_flags = SA_SIGINFO | SA_RESTART | SA_NOCLDSTOP | SA_NOCLDWAIT; + sigaction(SIGCHLD, &sa_chld, nullptr); +#endif } void QtHost::PrintCommandLineVersion() @@ -1504,6 +1555,9 @@ void QtHost::PrintCommandLineHelp(const std::string_view& progname) std::fprintf(stderr, " -fullscreen: Enters fullscreen mode immediately after starting.\n"); std::fprintf(stderr, " -nofullscreen: Prevents fullscreen mode from triggering if enabled.\n"); std::fprintf(stderr, " -earlyconsolelog: Forces logging of early console messages to console.\n"); +#ifdef ENABLE_RAINTEGRATION + std::fprintf(stderr, " -raintegration: Use RAIntegration instead of built-in achievement support.\n"); +#endif std::fprintf(stderr, " --: Signals that no more arguments will follow and the remaining\n" " parameters make up the filename. Use when the filename contains\n" " spaces or starts with a dash.\n"); @@ -1613,6 +1667,13 @@ bool QtHost::ParseCommandLineOptions(const QStringList& args, std::shared_ptr. + */ + +#include "PrecompiledHeader.h" +#include "AchievementLoginDialog.h" +#include "pcsx2/Frontend/Achievements.h" +#include "QtHost.h" +#include + +AchievementLoginDialog::AchievementLoginDialog(QWidget* parent) + : QDialog(parent) +{ + m_ui.setupUi(this); + m_ui.loginIcon->setPixmap(QIcon::fromTheme("login-box-line").pixmap(32)); + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + + m_login = m_ui.buttonBox->addButton(tr("&Login"), QDialogButtonBox::AcceptRole); + m_login->setEnabled(false); + connectUi(); +} + +AchievementLoginDialog::~AchievementLoginDialog() = default; + +void AchievementLoginDialog::loginClicked() +{ + std::string username(m_ui.userName->text().toStdString()); + std::string password(m_ui.password->text().toStdString()); + + // TODO: Make cancellable. + m_ui.status->setText(tr("Logging in...")); + enableUI(false); + + Host::RunOnCPUThread([this, username = std::move(username), password = std::move(password)]() { + const bool result = Achievements::Login(username.c_str(), password.c_str()); + QMetaObject::invokeMethod(this, "processLoginResult", Qt::QueuedConnection, Q_ARG(bool, result)); + }); +} + +void AchievementLoginDialog::cancelClicked() +{ + done(1); +} + +void AchievementLoginDialog::processLoginResult(bool result) +{ + if (!result) + { + QMessageBox::critical(this, tr("Login Error"), + tr("Login failed. Please check your username and password, and try again.")); + m_ui.status->setText(tr("Login failed.")); + enableUI(true); + return; + } + + done(0); +} + +void AchievementLoginDialog::connectUi() +{ + connect(m_ui.buttonBox, &QDialogButtonBox::accepted, this, &AchievementLoginDialog::loginClicked); + connect(m_ui.buttonBox, &QDialogButtonBox::rejected, this, &AchievementLoginDialog::cancelClicked); + + auto enableLoginButton = [this](const QString&) { m_login->setEnabled(canEnableLoginButton()); }; + connect(m_ui.userName, &QLineEdit::textChanged, enableLoginButton); + connect(m_ui.password, &QLineEdit::textChanged, enableLoginButton); +} + +void AchievementLoginDialog::enableUI(bool enabled) +{ + m_ui.userName->setEnabled(enabled); + m_ui.password->setEnabled(enabled); + m_ui.buttonBox->button(QDialogButtonBox::Cancel)->setEnabled(enabled); + m_login->setEnabled(enabled && canEnableLoginButton()); +} + +bool AchievementLoginDialog::canEnableLoginButton() const +{ + return !m_ui.userName->text().isEmpty() && !m_ui.password->text().isEmpty(); +} diff --git a/pcsx2-qt/Settings/AchievementLoginDialog.h b/pcsx2-qt/Settings/AchievementLoginDialog.h new file mode 100644 index 0000000000..835e057eb8 --- /dev/null +++ b/pcsx2-qt/Settings/AchievementLoginDialog.h @@ -0,0 +1,41 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2022 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + * PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with PCSX2. + * If not, see . + */ + +#pragma once +#include "ui_AchievementLoginDialog.h" +#include +#include + +class AchievementLoginDialog : public QDialog +{ + Q_OBJECT + +public: + AchievementLoginDialog(QWidget* parent); + ~AchievementLoginDialog(); + +private Q_SLOTS: + void loginClicked(); + void cancelClicked(); + void processLoginResult(bool result); + +private: + void connectUi(); + void enableUI(bool enabled); + bool canEnableLoginButton() const; + + Ui::AchievementLoginDialog m_ui; + QPushButton* m_login; +}; diff --git a/pcsx2-qt/Settings/AchievementLoginDialog.ui b/pcsx2-qt/Settings/AchievementLoginDialog.ui new file mode 100644 index 0000000000..441c94aa4d --- /dev/null +++ b/pcsx2-qt/Settings/AchievementLoginDialog.ui @@ -0,0 +1,140 @@ + + + AchievementLoginDialog + + + Qt::WindowModal + + + + 0 + 0 + 410 + 190 + + + + + 410 + 190 + + + + + 410 + 190 + + + + RetroAchievements Login + + + true + + + + + + + + + + + :/icons/black/48/login-box-line.png + + + + + + + + 14 + false + + + + RetroAchievements Login + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + Please enter user name and password for retroachievements.org below. Your password will not be saved in PCSX2, an access token will be generated and used instead. + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + User Name: + + + + + + + + + + Password: + + + + + + + QLineEdit::Password + + + + + + + + + + + Ready... + + + + + + + QDialogButtonBox::Cancel + + + + + + + + + + + + diff --git a/pcsx2-qt/Settings/AchievementSettingsWidget.cpp b/pcsx2-qt/Settings/AchievementSettingsWidget.cpp new file mode 100644 index 0000000000..5a3d3e428a --- /dev/null +++ b/pcsx2-qt/Settings/AchievementSettingsWidget.cpp @@ -0,0 +1,194 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2022 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + * PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with PCSX2. + * If not, see . + */ + +#include "PrecompiledHeader.h" + +#include "AchievementSettingsWidget.h" +#include "AchievementLoginDialog.h" +#include "MainWindow.h" +#include "SettingsDialog.h" +#include "SettingWidgetBinder.h" +#include "QtUtils.h" + +#include "pcsx2/Frontend/Achievements.h" +#include "pcsx2/HostSettings.h" + +#include "common/StringUtil.h" + +#include +#include + +AchievementSettingsWidget::AchievementSettingsWidget(SettingsDialog* dialog, QWidget* parent) + : QWidget(parent) + , m_dialog(dialog) +{ + m_ui.setupUi(this); + + SettingsInterface* sif = dialog->getSettingsInterface(); + + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.enable, "Achievements", "Enabled", false); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.richPresence, "Achievements", "RichPresence", true); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.challengeMode, "Achievements", "ChallengeMode", false); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.leaderboards, "Achievements", "Leaderboards", true); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.testMode, "Achievements", "TestMode", false); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.unofficialTestMode, "Achievements", + "UnofficialTestMode", false); + SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.soundEffects, "Achievements", + "SoundEffects", true); + + dialog->registerWidgetHelp(m_ui.enable, tr("Enable Achievements"), tr("Unchecked"), + tr("When enabled and logged in, PCSX2 will scan for achievements on game load.")); + dialog->registerWidgetHelp(m_ui.testMode, tr("Enable Test Mode"), tr("Unchecked"), + tr("When enabled, PCSX2 will assume all achievements are locked and not send any " + "unlock notifications to the server.")); + dialog->registerWidgetHelp( + m_ui.unofficialTestMode, tr("Test Unofficial Achievements"), tr("Unchecked"), + tr("When enabled, PCSX2 will list achievements from unofficial sets. Please note that these achievements are " + "not tracked by RetroAchievements, so they unlock every time.")); + dialog->registerWidgetHelp( + m_ui.richPresence, tr("Enable Rich Presence"), tr("Unchecked"), + tr("When enabled, rich presence information will be collected and sent to the server where supported.")); + dialog->registerWidgetHelp(m_ui.challengeMode, tr("Enable Hardcore Mode"), tr("Unchecked"), + tr("\"Challenge\" mode for achievements, including leaderboard tracking. Disables save state, cheats, and slowdown functions.")); + dialog->registerWidgetHelp(m_ui.leaderboards, tr("Enable Leaderboards"), tr("Checked"), + tr("Enables tracking and submission of leaderboards in supported games. If leaderboards are disabled, you will still " + "be able to view the leaderboard and scores, but no scores will be uploaded.")); + dialog->registerWidgetHelp( + m_ui.soundEffects, tr("Enable Sound Effects"), tr("Checked"), + tr("Plays sound effects for events such as achievement unlocks and leaderboard submissions.")); + + connect(m_ui.enable, &QCheckBox::stateChanged, this, &AchievementSettingsWidget::updateEnableState); + connect(m_ui.challengeMode, &QCheckBox::stateChanged, this, &AchievementSettingsWidget::updateEnableState); + connect(m_ui.challengeMode, &QCheckBox::stateChanged, this, &AchievementSettingsWidget::onChallengeModeStateChanged); + + if (!m_dialog->isPerGameSettings()) + { + connect(m_ui.loginButton, &QPushButton::clicked, this, &AchievementSettingsWidget::onLoginLogoutPressed); + connect(m_ui.viewProfile, &QPushButton::clicked, this, &AchievementSettingsWidget::onViewProfilePressed); + connect(g_emu_thread, &EmuThread::onAchievementsRefreshed, this, &AchievementSettingsWidget::onAchievementsRefreshed); + updateLoginState(); + + // force a refresh of game info + Host::RunOnCPUThread(Host::OnAchievementsRefreshed); + } + else + { + // remove login and game info, not relevant for per-game + m_ui.verticalLayout->removeWidget(m_ui.gameInfoBox); + m_ui.gameInfoBox->deleteLater(); + m_ui.gameInfoBox = nullptr; + m_ui.verticalLayout->removeWidget(m_ui.loginBox); + m_ui.loginBox->deleteLater(); + m_ui.loginBox = nullptr; + } + + updateEnableState(); +} + +AchievementSettingsWidget::~AchievementSettingsWidget() = default; + +void AchievementSettingsWidget::updateEnableState() +{ + const bool enabled = m_dialog->getEffectiveBoolValue("Achievements", "Enabled", false); + const bool challenge = m_dialog->getEffectiveBoolValue("Achievements", "ChallengeMode", false); + m_ui.testMode->setEnabled(enabled); + m_ui.unofficialTestMode->setEnabled(enabled); + m_ui.richPresence->setEnabled(enabled); + m_ui.challengeMode->setEnabled(enabled); + m_ui.leaderboards->setEnabled(enabled && challenge); + m_ui.soundEffects->setEnabled(enabled); +} + +void AchievementSettingsWidget::onChallengeModeStateChanged() +{ + if (!QtHost::IsVMValid()) + return; + + const bool enabled = m_dialog->getEffectiveBoolValue("Achievements", "Enabled", false); + const bool challenge = m_dialog->getEffectiveBoolValue("Achievements", "ChallengeMode", false); + if (!enabled || !challenge) + return; + + // don't bother prompting if the game doesn't have achievements + auto lock = Achievements::GetLock(); + if (!Achievements::HasActiveGame()) + return; + + if (QMessageBox::question(QtUtils::GetRootWidget(this), tr("Reset System"), + tr("Hardcore mode will not be enabled until the system is reset. Do you want to reset the system now?")) != QMessageBox::Yes) + { + return; + } + + g_emu_thread->resetVM(); +} + +void AchievementSettingsWidget::updateLoginState() +{ + const std::string username(Host::GetBaseStringSettingValue("Achievements", "Username")); + const bool logged_in = !username.empty(); + + if (logged_in) + { + const u64 login_unix_timestamp = StringUtil::FromChars(Host::GetBaseStringSettingValue("Achievements", "LoginTimestamp", "0")).value_or(0); + const QDateTime login_timestamp(QDateTime::fromSecsSinceEpoch(static_cast(login_unix_timestamp))); + m_ui.loginStatus->setText(tr("Username: %1\nLogin token generated on %2.") + .arg(QString::fromStdString(username)) + .arg(login_timestamp.toString(Qt::TextDate))); + m_ui.loginButton->setText(tr("Logout")); + } + else + { + m_ui.loginStatus->setText(tr("Not Logged In.")); + m_ui.loginButton->setText(tr("Login...")); + } + + m_ui.viewProfile->setEnabled(logged_in); +} + +void AchievementSettingsWidget::onLoginLogoutPressed() +{ + if (!Host::GetBaseStringSettingValue("Achievements", "Username").empty()) + { + Host::RunOnCPUThread(Achievements::Logout, true); + updateLoginState(); + return; + } + + AchievementLoginDialog login(this); + int res = login.exec(); + if (res != 0) + return; + + updateLoginState(); +} + +void AchievementSettingsWidget::onViewProfilePressed() +{ + const std::string username(Host::GetBaseStringSettingValue("Achievements", "Username")); + if (username.empty()) + return; + + const QByteArray encoded_username(QUrl::toPercentEncoding(QString::fromStdString(username))); + QtUtils::OpenURL( + QtUtils::GetRootWidget(this), + QUrl(QStringLiteral("https://retroachievements.org/user/%1").arg(QString::fromUtf8(encoded_username)))); +} + +void AchievementSettingsWidget::onAchievementsRefreshed(quint32 id, const QString& game_info_string, quint32 total, + quint32 points) +{ + m_ui.gameInfo->setText(game_info_string); +} diff --git a/pcsx2-qt/Settings/AchievementSettingsWidget.h b/pcsx2-qt/Settings/AchievementSettingsWidget.h new file mode 100644 index 0000000000..87ff075e84 --- /dev/null +++ b/pcsx2-qt/Settings/AchievementSettingsWidget.h @@ -0,0 +1,42 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2022 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + * PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with PCSX2. + * If not, see . + */ + +#pragma once +#include +#include "ui_AchievementSettingsWidget.h" + +class SettingsDialog; + +class AchievementSettingsWidget : public QWidget +{ + Q_OBJECT + +public: + explicit AchievementSettingsWidget(SettingsDialog* dialog, QWidget* parent); + ~AchievementSettingsWidget(); + +private Q_SLOTS: + void updateEnableState(); + void onChallengeModeStateChanged(); + void onLoginLogoutPressed(); + void onViewProfilePressed(); + void onAchievementsRefreshed(quint32 id, const QString& game_info_string, quint32 total, quint32 points); + +private: + void updateLoginState(); + + Ui::AchievementSettingsWidget m_ui; + SettingsDialog* m_dialog; +}; diff --git a/pcsx2-qt/Settings/AchievementSettingsWidget.ui b/pcsx2-qt/Settings/AchievementSettingsWidget.ui new file mode 100644 index 0000000000..f6a7609828 --- /dev/null +++ b/pcsx2-qt/Settings/AchievementSettingsWidget.ui @@ -0,0 +1,181 @@ + + + AchievementSettingsWidget + + + + 0 + 0 + 648 + 456 + + + + Form + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Global Settings + + + + + + Enable Leaderboards + + + + + + + Enable Achievements + + + + + + + Enable Rich Presence + + + + + + + Enable Hardcore Mode + + + + + + + Enable Test Mode + + + + + + + Enable Sound Effects + + + + + + + Test Unofficial Achievements + + + + + + + + + + Account + + + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + Login... + + + + + + + View Profile... + + + + + + + + + + + + + 0 + 160 + + + + Game Info + + + + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + + <html><head/><body><p align="justify">PCSX2 uses RetroAchievements as an achievement database and for tracking progress. To use achievements, please sign up for an account at <a href="https://retroachievements.org/"><span style=" text-decoration: underline; color:#0000ff;">retroachievements.org</span></a>.</p><p align="justify">To view the achievement list in-game, press the hotkey for <span style=" font-weight:600;">Open Pause Menu</span> and select <span style=" font-weight:600;">Achievements</span> from the menu.</p></body></html> + + + Qt::RichText + + + true + + + 8 + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + diff --git a/pcsx2-qt/Settings/SettingsDialog.cpp b/pcsx2-qt/Settings/SettingsDialog.cpp index 3880db9fe9..825f6938ca 100644 --- a/pcsx2-qt/Settings/SettingsDialog.cpp +++ b/pcsx2-qt/Settings/SettingsDialog.cpp @@ -43,6 +43,11 @@ #include "MemoryCardSettingsWidget.h" #include "SystemSettingsWidget.h" +#ifdef ENABLE_ACHIEVEMENTS +#include "AchievementSettingsWidget.h" +#include "pcsx2/Frontend/Achievements.h" +#endif + #include #include @@ -127,7 +132,7 @@ void SettingsDialog::setupUi(const GameList::Entry* game) addWidget(m_memory_card_settings = new MemoryCardSettingsWidget(this, m_ui.settingsContainer), tr("Memory Cards"), QStringLiteral("sd-card-line"), tr("Memory Card Settings
Create and configure Memory Cards here.

Mouse over an option for additional information.")); } - + addWidget(m_dev9_settings = new DEV9SettingsWidget(this, m_ui.settingsContainer), tr("Network & HDD"), QStringLiteral("dashboard-line"), tr("Network & HDD Settings
These options control the network connectivity and internal HDD storage of the console.

" "Mouse over an option for additional information.")); @@ -138,6 +143,34 @@ void SettingsDialog::setupUi(const GameList::Entry* game) tr("Folder Settings
These options control where PCSX2 will save runtime data files.")); } + { + QString title = tr("Achievements"); + QString icon_text(QStringLiteral("trophy-line")); + QString help_text = tr( + "Achievements Settings
" + "These options control the RetroAchievements implementation in PCSX2, allowing you to earn achievements in your games."); +#ifdef ENABLE_ACHIEVEMENTS + if (Achievements::IsUsingRAIntegration()) + { + QLabel* placeholder_label = + new QLabel(tr("RAIntegration is being used, built-in RetroAchievements support is disabled."), + m_ui.settingsContainer); + placeholder_label->setAlignment(Qt::AlignLeft | Qt::AlignTop); + addWidget(placeholder_label, std::move(title), std::move(icon_text), std::move(help_text)); + } + else + { + addWidget((m_achievement_settings = new AchievementSettingsWidget(this, m_ui.settingsContainer)), + std::move(title), std::move(icon_text), std::move(help_text)); + } +#else + QLabel* placeholder_label = + new QLabel(tr("This PCSX2 build was not compiled with RetroAchievements support."), m_ui.settingsContainer); + placeholder_label->setAlignment(Qt::AlignLeft | Qt::AlignTop); + addWidget(placeholder_label, std::move(title), std::move(icon_text), std::move(help_text)); +#endif + } + m_ui.settingsCategory->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); m_ui.settingsCategory->setCurrentRow(0); m_ui.settingsContainer->setCurrentIndex(0); diff --git a/pcsx2-qt/Settings/SettingsDialog.h b/pcsx2-qt/Settings/SettingsDialog.h index 20533f031e..76d60903c7 100644 --- a/pcsx2-qt/Settings/SettingsDialog.h +++ b/pcsx2-qt/Settings/SettingsDialog.h @@ -41,6 +41,7 @@ class AudioSettingsWidget; class MemoryCardSettingsWidget; class FolderSettingsWidget; class DEV9SettingsWidget; +class AchievementSettingsWidget; class SettingsDialog final : public QDialog { @@ -68,6 +69,7 @@ public: __fi MemoryCardSettingsWidget* getMemoryCardSettingsWidget() const { return m_memory_card_settings; } __fi FolderSettingsWidget* getFolderSettingsWidget() const { return m_folder_settings; } __fi DEV9SettingsWidget* getDEV9SettingsWidget() const { return m_dev9_settings; } + __fi AchievementSettingsWidget* getAchievementSettingsWidget() const { return m_achievement_settings; } void registerWidgetHelp(QObject* object, QString title, QString recommended_value, QString text); bool eventFilter(QObject* object, QEvent* event) override; @@ -104,7 +106,7 @@ protected: private: enum : u32 { - MAX_SETTINGS_WIDGETS = 11 + MAX_SETTINGS_WIDGETS = 12 }; void setupUi(const GameList::Entry* game); @@ -127,6 +129,7 @@ private: MemoryCardSettingsWidget* m_memory_card_settings = nullptr; FolderSettingsWidget* m_folder_settings = nullptr; DEV9SettingsWidget* m_dev9_settings = nullptr; + AchievementSettingsWidget* m_achievement_settings = nullptr; std::array m_category_help_text; diff --git a/pcsx2-qt/pcsx2-qt.vcxproj b/pcsx2-qt/pcsx2-qt.vcxproj index e2540ccd6b..1529ea05d8 100644 --- a/pcsx2-qt/pcsx2-qt.vcxproj +++ b/pcsx2-qt/pcsx2-qt.vcxproj @@ -51,7 +51,7 @@ Use PrecompiledHeader.h NoExtensions - WIN32_LEAN_AND_MEAN;LZMA_API_STATIC;BUILD_DX=1;ENABLE_OPENGL;ENABLE_VULKAN;DIRECTINPUT_VERSION=0x0800;PCSX2_CORE;%(PreprocessorDefinitions) + WIN32_LEAN_AND_MEAN;LZMA_API_STATIC;BUILD_DX=1;ENABLE_RAINTEGRATION;ENABLE_ACHIEVEMENTS;ENABLE_OPENGL;ENABLE_VULKAN;DIRECTINPUT_VERSION=0x0800;PCSX2_CORE;%(PreprocessorDefinitions) PCSX2_DEBUG;PCSX2_DEVBUILD;_SECURE_SCL_=1;%(PreprocessorDefinitions) PCSX2_DEVEL;PCSX2_DEVBUILD;NDEBUG;_SECURE_SCL_=1;%(PreprocessorDefinitions) NDEBUG;_SECURE_SCL_=0;%(PreprocessorDefinitions) @@ -162,6 +162,8 @@ + + @@ -202,6 +204,8 @@ + + @@ -247,6 +251,8 @@ + + @@ -341,6 +347,12 @@ Document + + Document + + + Document + diff --git a/pcsx2-qt/pcsx2-qt.vcxproj.filters b/pcsx2-qt/pcsx2-qt.vcxproj.filters index dfe03d74ef..4855247f69 100644 --- a/pcsx2-qt/pcsx2-qt.vcxproj.filters +++ b/pcsx2-qt/pcsx2-qt.vcxproj.filters @@ -229,6 +229,18 @@ moc + + Settings + + + Settings + + + moc + + + moc + @@ -335,6 +347,12 @@ + + Settings + + + Settings + @@ -419,6 +437,12 @@ Settings + + Settings + + + Settings + @@ -426,4 +450,4 @@ Settings - \ No newline at end of file + diff --git a/pcsx2-qt/resources/icons/black/svg/login-box-line.svg b/pcsx2-qt/resources/icons/black/svg/login-box-line.svg new file mode 100644 index 0000000000..6b0d9bfa9c --- /dev/null +++ b/pcsx2-qt/resources/icons/black/svg/login-box-line.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/pcsx2-qt/resources/icons/black/svg/trophy-line.svg b/pcsx2-qt/resources/icons/black/svg/trophy-line.svg new file mode 100644 index 0000000000..c53c31fbe0 --- /dev/null +++ b/pcsx2-qt/resources/icons/black/svg/trophy-line.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/pcsx2-qt/resources/icons/white/svg/login-box-line.svg b/pcsx2-qt/resources/icons/white/svg/login-box-line.svg new file mode 100644 index 0000000000..585ef107e2 --- /dev/null +++ b/pcsx2-qt/resources/icons/white/svg/login-box-line.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/pcsx2-qt/resources/icons/white/svg/trophy-line.svg b/pcsx2-qt/resources/icons/white/svg/trophy-line.svg new file mode 100644 index 0000000000..4173ac212e --- /dev/null +++ b/pcsx2-qt/resources/icons/white/svg/trophy-line.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/pcsx2-qt/resources/resources.qrc b/pcsx2-qt/resources/resources.qrc index c0628e02e9..3b2aeab90c 100644 --- a/pcsx2-qt/resources/resources.qrc +++ b/pcsx2-qt/resources/resources.qrc @@ -37,6 +37,7 @@ icons/black/svg/keyboard-line.svg icons/black/svg/layout-grid-line.svg icons/black/svg/list-check.svg + icons/black/svg/login-box-line.svg icons/black/svg/pause-line.svg icons/black/svg/play-line.svg icons/black/svg/price-tag-3-line.svg @@ -47,6 +48,7 @@ icons/black/svg/sd-card-line.svg icons/black/svg/settings-3-line.svg icons/black/svg/shut-down-line.svg + icons/black/svg/trophy-line.svg icons/black/svg/tv-2-line.svg icons/black/svg/volume-up-line.svg icons/black/svg/window-2-line.svg @@ -92,6 +94,7 @@ icons/white/svg/keyboard-line.svg icons/white/svg/layout-grid-line.svg icons/white/svg/list-check.svg + icons/white/svg/login-box-line.svg icons/white/svg/pause-line.svg icons/white/svg/play-line.svg icons/white/svg/price-tag-3-line.svg @@ -102,6 +105,7 @@ icons/white/svg/sd-card-line.svg icons/white/svg/settings-3-line.svg icons/white/svg/shut-down-line.svg + icons/white/svg/trophy-line.svg icons/white/svg/tv-2-line.svg icons/white/svg/volume-up-line.svg icons/white/svg/window-2-line.svg diff --git a/pcsx2/Achievements.h b/pcsx2/Achievements.h new file mode 100644 index 0000000000..f51e183c1c --- /dev/null +++ b/pcsx2/Achievements.h @@ -0,0 +1,75 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2022 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + * PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with PCSX2. + * If not, see . + */ + +#pragma once +#include "common/Pcsx2Types.h" +#include + +namespace Achievements +{ +#ifdef ENABLE_ACHIEVEMENTS + + // Implemented in Host. + extern bool OnReset(); + extern void LoadState(const u8* state_data, u32 state_data_size); + extern std::vector SaveState(); + extern void GameChanged(u32 crc); + + /// Re-enables hardcode mode if it is enabled in the settings. + extern void ResetChallengeMode(); + + /// Forces hardcore mode off until next reset. + extern void DisableChallengeMode(); + + /// Prompts the user to disable hardcore mode, if they agree, returns true. + extern bool ConfirmChallengeModeDisable(const char* trigger); + + /// Returns true if features such as save states should be disabled. + extern bool ChallengeModeActive(); + +#else + + // Make noops when compiling without cheevos. + static inline bool OnReset() + { + return true; + } + static inline void LoadState(const u8* state_data, u32 state_data_size) + { + } + static inline std::vector SaveState() + { + return {}; + } + static inline void GameChanged() + { + } + + static constexpr inline bool ChallengeModeActive() + { + return false; + } + + static inline void ResetChallengeMode() {} + + static inline void DisableChallengeMode() {} + + static inline bool ConfirmChallengeModeDisable(const char* trigger) + { + return true; + } + +#endif +} // namespace Achievements diff --git a/pcsx2/CMakeLists.txt b/pcsx2/CMakeLists.txt index dda47d8ddf..0f132b554a 100644 --- a/pcsx2/CMakeLists.txt +++ b/pcsx2/CMakeLists.txt @@ -199,6 +199,7 @@ set(pcsx2Sources # Main pcsx2 header set(pcsx2Headers + Achievements.h AsyncFileReader.h Cache.h Common.h @@ -1090,6 +1091,21 @@ if(PCSX2_CORE) HostSettings.h INISettingsInterface.h VMManager.h) + + if(USE_ACHIEVEMENTS) + list(APPEND pcsx2FrontendSources + Frontend/Achievements.cpp + ) + list(APPEND pcsx2FrontendHeaders + Frontend/Achievements.h + ) + target_compile_definitions(PCSX2_FLAGS INTERFACE + ENABLE_ACHIEVEMENTS + ) + target_link_libraries(PCSX2_FLAGS INTERFACE + rcheevos + ) + endif() endif() # gui sources diff --git a/pcsx2/Config.h b/pcsx2/Config.h index f2554bd534..195a0a0d03 100644 --- a/pcsx2/Config.h +++ b/pcsx2/Config.h @@ -941,6 +941,39 @@ struct Pcsx2Config MemoryCardType Type; // the memory card implementation that should be used }; + // ------------------------------------------------------------------------ + +#ifdef ENABLE_ACHIEVEMENTS + struct AchievementsOptions + { + BITFIELD32() + bool + Enabled : 1, + TestMode : 1, + UnofficialTestMode : 1, + RichPresence : 1, + ChallengeMode : 1, + Leaderboards : 1, + SoundEffects : 1; + BITFIELD_END + + AchievementsOptions(); + void LoadSave(SettingsWrapper& wrap); + + bool operator==(const AchievementsOptions& right) const + { + return OpEqu(bitset); + } + + bool operator!=(const AchievementsOptions& right) const + { + return !this->operator==(right); + } + }; +#endif + + // ------------------------------------------------------------------------ + BITFIELD32() bool CdvdVerboseReads : 1, // enables cdvd read activity verbosely dumped to the console @@ -993,6 +1026,10 @@ struct Pcsx2Config FilenameOptions BaseFilenames; +#ifdef ENABLE_ACHIEVEMENTS + AchievementsOptions Achievements; +#endif + // Memorycard options - first 2 are default slots, last 6 are multitap 1 and 2 // slots (3 each) McdOptions Mcd[8]; diff --git a/pcsx2/Frontend/Achievements.cpp b/pcsx2/Frontend/Achievements.cpp new file mode 100644 index 0000000000..04902ef7c1 --- /dev/null +++ b/pcsx2/Frontend/Achievements.cpp @@ -0,0 +1,2215 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2022 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + * PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with PCSX2. + * If not, see . + */ + +#include "PrecompiledHeader.h" + +#include "Frontend/Achievements.h" +#include "Frontend/FullscreenUI.h" +#include "Frontend/ImGuiFullscreen.h" + +#include "common/Assertions.h" +#include "common/FileSystem.h" +#include "common/HTTPDownloader.h" +#include "common/Console.h" +#include "common/General.h" +#include "common/MD5Digest.h" +#include "common/Path.h" +#include "common/ScopedGuard.h" +#include "common/StringUtil.h" +#include "common/Timer.h" + +#include "rc_api_info.h" +#include "rc_api_request.h" +#include "rc_api_runtime.h" +#include "rc_api_user.h" +#include "rcheevos.h" +#include "fmt/core.h" + +#include "CDVD/IsoFS/IsoFSCDVD.h" +#include "CDVD/IsoFS/IsoFS.h" +#include "Elfheader.h" +#include "GS.h" +#include "Host.h" +#include "HostSettings.h" +#include "IopMem.h" +#include "Memory.h" +#include "VMManager.h" +#include "vtlb.h" +#include "svnrev.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef ENABLE_RAINTEGRATION +// RA_Interface ends up including windows.h, with its silly macros. +#include "common/RedtapeWindows.h" +#include "RA_Interface.h" +#endif + +namespace Achievements +{ + enum : s32 + { + HTTP_OK = Common::HTTPDownloader::HTTP_OK, + + // Number of seconds between rich presence pings. RAIntegration uses 2 minutes. + RICH_PRESENCE_PING_FREQUENCY = 2 * 60, + NO_RICH_PRESENCE_PING_FREQUENCY = RICH_PRESENCE_PING_FREQUENCY * 2, + + // Size of the EE physical memory exposed to RetroAchievements. + EXPOSED_EE_MEMORY_SIZE = Ps2MemSize::MainRam + Ps2MemSize::Scratch, + }; + + static constexpr const char* INFO_SOUND_NAME = "sounds/achievements/message.wav"; + static constexpr const char* UNLOCK_SOUND_NAME = "sounds/achievements/unlock.wav"; + static constexpr const char* LBSUBMIT_SOUND_NAME = "sounds/achievements/lbsubmit.wav"; + + static void FormattedError(const char* format, ...); + static void LogFailedResponseJSON(const Common::HTTPDownloader::Request::Data& data); + static void EnsureCacheDirectoriesExist(); + static void CheevosEventHandler(const rc_runtime_event_t* runtime_event); + static unsigned PeekMemory(unsigned address, unsigned num_bytes, void* ud); + static unsigned PeekMemoryBlock(unsigned address, unsigned char* buffer, unsigned num_bytes); + static void PokeMemory(unsigned address, unsigned num_bytes, void* ud, unsigned value); + static bool IsMastered(); + static void ActivateLockedAchievements(); + static bool ActivateAchievement(Achievement* achievement); + static void DeactivateAchievement(Achievement* achievement); + static void SendPing(); + static void SendPlaying(); + static void UpdateRichPresence(); + static Achievement* GetMutableAchievementByID(u32 id); + static void ClearGameInfo(bool clear_achievements = true, bool clear_leaderboards = true); + static void ClearGameHash(); + static std::string GetUserAgent(); + static void BeginLoadingScreen(const char* text, bool* was_running_idle); + static void EndLoadingScreen(bool was_running_idle); + static void LoginCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data); + static void LoginASyncCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data); + static void SendLogin(const char* username, const char* password, Common::HTTPDownloader* http_downloader, + Common::HTTPDownloader::Request::Callback callback); + static void DownloadImage(std::string url, std::string cache_filename); + static void DisplayAchievementSummary(); + static void DisplayMasteredNotification(); + static void UnlockAchievement(u32 achievement_id, bool add_notification = true); + static void AchievementPrimed(u32 achievement_id); + static void AchievementUnprimed(u32 achievement_id); + static void SubmitLeaderboard(u32 leaderboard_id, int value); + static void GetUserUnlocksCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data); + static void GetUserUnlocks(); + static void GetPatchesCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data); + static void GetLbInfoCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data); + static void GetPatches(u32 game_id); + static std::string_view GetELFNameForHash(const std::string& elf_path); + static std::optional> ReadELFFromCurrentDisc(const std::string& elf_path); + static std::string GetGameHash(); + static void SetChallengeMode(bool enabled); + static void SendGetGameId(); + static void GetGameIdCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data); + static void SendPlayingCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data); + static void UpdateRichPresence(); + static void SendPingCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data); + static void UnlockAchievementCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data); + static void SubmitLeaderboardCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data); + + static bool s_active = false; + static bool s_logged_in = false; + static bool s_challenge_mode = false; + static u32 s_game_id = 0; + +#ifdef ENABLE_RAINTEGRATION + static bool s_using_raintegration = false; +#endif + + static std::recursive_mutex s_achievements_mutex; + static rc_runtime_t s_rcheevos_runtime; + static std::string s_game_icon_cache_directory; + static std::string s_achievement_icon_cache_directory; + static std::unique_ptr s_http_downloader; + + static std::string s_username; + static std::string s_api_token; + + static u32 s_last_game_crc; + static std::string s_game_path; + static std::string s_game_hash; + static std::string s_game_title; + static std::string s_game_icon; + static std::vector s_achievements; + static std::vector s_leaderboards; + static std::atomic s_primed_achievement_count{0}; + + static bool s_has_rich_presence = false; + static std::string s_rich_presence_string; + static Common::Timer s_last_ping_time; + + static u32 s_last_queried_lboard = 0; + static u32 s_submitting_lboard_id = 0; + static std::optional> s_lboard_entries; + + template + static const char* RAPIStructName(); + +#define RAPI_STRUCT_NAME(x) \ + template <> \ + const char* RAPIStructName() \ + { \ + return #x; \ + } + + RAPI_STRUCT_NAME(rc_api_login_request_t); + RAPI_STRUCT_NAME(rc_api_fetch_image_request_t); + RAPI_STRUCT_NAME(rc_api_resolve_hash_request_t); + RAPI_STRUCT_NAME(rc_api_fetch_game_data_request_t); + RAPI_STRUCT_NAME(rc_api_fetch_user_unlocks_request_t); + RAPI_STRUCT_NAME(rc_api_start_session_request_t); + RAPI_STRUCT_NAME(rc_api_ping_request_t); + RAPI_STRUCT_NAME(rc_api_award_achievement_request_t); + RAPI_STRUCT_NAME(rc_api_submit_lboard_entry_request_t); + RAPI_STRUCT_NAME(rc_api_fetch_leaderboard_info_request_t); + + RAPI_STRUCT_NAME(rc_api_response_t); + RAPI_STRUCT_NAME(rc_api_login_response_t); + RAPI_STRUCT_NAME(rc_api_resolve_hash_response_t); + RAPI_STRUCT_NAME(rc_api_fetch_game_data_response_t); + RAPI_STRUCT_NAME(rc_api_ping_response_t); + RAPI_STRUCT_NAME(rc_api_award_achievement_response_t); + RAPI_STRUCT_NAME(rc_api_submit_lboard_entry_response_t); + RAPI_STRUCT_NAME(rc_api_start_session_response_t); + RAPI_STRUCT_NAME(rc_api_fetch_user_unlocks_response_t); + RAPI_STRUCT_NAME(rc_api_fetch_leaderboard_info_response_t); + + // Unused for now. + // RAPI_STRUCT_NAME(rc_api_fetch_achievement_info_response_t); + // RAPI_STRUCT_NAME(rc_api_fetch_games_list_response_t); + +#undef RAPI_STRUCT_NAME + + template + struct RAPIRequest : public T + { + private: + rc_api_request_t api_request; + + public: + RAPIRequest() { std::memset(this, 0, sizeof(*this)); } + + ~RAPIRequest() { rc_api_destroy_request(&api_request); } + + void Send(Common::HTTPDownloader::Request::Callback callback) { Send(s_http_downloader.get(), std::move(callback)); } + + void Send(Common::HTTPDownloader* http_downloader, Common::HTTPDownloader::Request::Callback callback) + { + const int error = InitFunc(&api_request, this); + if (error != RC_OK) + { + FormattedError("%s failed: error %d (%s)", RAPIStructName(), error, rc_error_str(error)); + callback(-1, std::string(), Common::HTTPDownloader::Request::Data()); + return; + } + + if (api_request.post_data) + { + // needs to be a post + http_downloader->CreatePostRequest(api_request.url, api_request.post_data, std::move(callback)); + } + else + { + // get is fine + http_downloader->CreateRequest(api_request.url, std::move(callback)); + } + } + + bool DownloadImage(std::string cache_filename) + { + const int error = InitFunc(&api_request, this); + if (error != RC_OK) + { + FormattedError("%s failed: error %d (%s)", RAPIStructName(), error, rc_error_str(error)); + return false; + } + + pxAssertMsg(!api_request.post_data, "Download request does not have POST data"); + Achievements::DownloadImage(api_request.url, std::move(cache_filename)); + return true; + } + + std::string GetURL() + { + const int error = InitFunc(&api_request, this); + if (error != RC_OK) + { + FormattedError("%s failed: error %d (%s)", RAPIStructName(), error, rc_error_str(error)); + return std::string(); + } + + return api_request.url; + } + }; + + template + struct RAPIResponse : public T + { + private: + bool initialized = false; + + public: + RAPIResponse(s32 status_code, Common::HTTPDownloader::Request::Data& data) + { + if (status_code != Common::HTTPDownloader::HTTP_OK || data.empty()) + { + FormattedError("%s failed: empty response and/or status code %d", RAPIStructName(), status_code); + LogFailedResponseJSON(data); + return; + } + + // ensure null termination, rapi needs it + data.push_back(0); + + const int error = ParseFunc(this, reinterpret_cast(data.data())); + initialized = true; + + const rc_api_response_t& response = static_cast(this)->response; + if (error != RC_OK) + { + FormattedError("%s failed: parse function returned %d (%s)", RAPIStructName(), error, rc_error_str(error)); + LogFailedResponseJSON(data); + } + else if (!response.succeeded) + { + FormattedError("%s failed: %s", RAPIStructName(), response.error_message ? response.error_message : ""); + LogFailedResponseJSON(data); + } + } + + ~RAPIResponse() + { + if (initialized) + DestroyFunc(this); + } + + operator bool() const { return initialized && static_cast(this)->response.succeeded; } + }; + +} // namespace Achievements + +#ifdef ENABLE_RAINTEGRATION +bool Achievements::IsUsingRAIntegration() +{ + return s_using_raintegration; +} +#endif + +void Achievements::FormattedError(const char* format, ...) +{ + std::va_list ap; + va_start(ap, format); + std::string error(fmt::format("Achievements error: {}", StringUtil::StdStringFromFormatV(format, ap))); + va_end(ap); + + Console.Error(error); + Host::AddOSDMessage(std::move(error), 10.0f); +} + +void Achievements::LogFailedResponseJSON(const Common::HTTPDownloader::Request::Data& data) +{ + const std::string str_data(reinterpret_cast(data.data()), data.size()); + Console.Error("API call failed. Response JSON was:\n%s", str_data.c_str()); +} + +const Achievements::Achievement* Achievements::GetAchievementByID(u32 id) +{ + for (const Achievement& ach : s_achievements) + { + if (ach.id == id) + return &ach; + } + + return nullptr; +} + +Achievements::Achievement* Achievements::GetMutableAchievementByID(u32 id) +{ + for (Achievement& ach : s_achievements) + { + if (ach.id == id) + return &ach; + } + + return nullptr; +} + +void Achievements::ClearGameInfo(bool clear_achievements, bool clear_leaderboards) +{ + std::unique_lock lock(s_achievements_mutex); + const bool had_game = (s_game_id != 0); + + if (clear_achievements) + { + while (!s_achievements.empty()) + { + Achievement& ach = s_achievements.back(); + DeactivateAchievement(&ach); + s_achievements.pop_back(); + } + s_primed_achievement_count.store(0, std::memory_order_release); + } + if (clear_leaderboards) + { + while (!s_leaderboards.empty()) + { + Leaderboard& lb = s_leaderboards.back(); + rc_runtime_deactivate_lboard(&s_rcheevos_runtime, lb.id); + s_leaderboards.pop_back(); + } + + s_last_queried_lboard = 0; + s_lboard_entries.reset(); + } + + if (s_achievements.empty() && s_leaderboards.empty()) + { + // Ready to tear down cheevos completely + s_game_title = {}; + s_game_icon = {}; + s_rich_presence_string = {}; + s_has_rich_presence = false; + s_game_id = 0; + } + + if (had_game) + Host::OnAchievementsRefreshed(); +} + +void Achievements::ClearGameHash() +{ + s_last_game_crc = 0; + std::string().swap(s_game_hash); +} + +std::string Achievements::GetUserAgent() +{ + std::string ret; + if (!PCSX2_isReleaseVersion && GIT_TAGGED_COMMIT) + ret = fmt::format("PCSX2 Nightly - {} ({})", GIT_TAG, GetOSVersionString()); + else if (!PCSX2_isReleaseVersion) + ret = fmt::format("PCSX2 {} ({})", GIT_REV, GetOSVersionString()); + else + ret = fmt::format("PCSX2 {}.{}.{}-{} ({})", PCSX2_VersionHi, PCSX2_VersionMid, PCSX2_VersionLo, SVN_REV, GetOSVersionString()); + + return ret; +} + +void Achievements::BeginLoadingScreen(const char* text, bool* was_running_idle) +{ + GetMTGS().RunOnGSThread(&FullscreenUI::Initialize); + ImGuiFullscreen::OpenBackgroundProgressDialog("achievements_loading", text, 0, 0, 0); +} + +void Achievements::EndLoadingScreen(bool was_running_idle) +{ + ImGuiFullscreen::CloseBackgroundProgressDialog("achievements_loading"); +} + +bool Achievements::IsActive() +{ + return s_active; +} + +bool Achievements::IsLoggedIn() +{ + return s_logged_in; +} + +bool Achievements::ChallengeModeActive() +{ +#ifdef ENABLE_RAINTEGRATION + if (IsUsingRAIntegration()) + return RA_HardcoreModeIsActive() != 0; +#endif + + return s_challenge_mode; +} + +bool Achievements::LeaderboardsActive() +{ + return ChallengeModeActive() && EmuConfig.Achievements.Leaderboards; +} + +bool Achievements::IsTestModeActive() +{ + return EmuConfig.Achievements.TestMode; +} + +bool Achievements::IsUnofficialTestModeActive() +{ + return EmuConfig.Achievements.UnofficialTestMode; +} + +bool Achievements::IsRichPresenceEnabled() +{ + return EmuConfig.Achievements.RichPresence; +} + +bool Achievements::HasActiveGame() +{ + return s_game_id != 0; +} + +u32 Achievements::GetGameID() +{ + return s_game_id; +} + +std::unique_lock Achievements::GetLock() +{ + return std::unique_lock(s_achievements_mutex); +} + +void Achievements::Initialize() +{ + if (IsUsingRAIntegration()) + return; + + std::unique_lock lock(s_achievements_mutex); + pxAssertRel(EmuConfig.Achievements.Enabled, "Achievements are enabled"); + + s_http_downloader = Common::HTTPDownloader::Create(GetUserAgent().c_str()); + if (!s_http_downloader) + { + Host::ReportErrorAsync("Achievements Error", "Failed to create HTTPDownloader, cannot use achievements"); + return; + } + + s_active = true; + s_challenge_mode = false; + rc_runtime_init(&s_rcheevos_runtime); + EnsureCacheDirectoriesExist(); + + s_last_ping_time.Reset(); + s_username = Host::GetBaseStringSettingValue("Achievements", "Username"); + s_api_token = Host::GetBaseStringSettingValue("Achievements", "Token"); + s_logged_in = (!s_username.empty() && !s_api_token.empty()); + + if (VMManager::HasValidVM()) + GameChanged(VMManager::GetGameCRC()); +} + +void Achievements::UpdateSettings(const Pcsx2Config::AchievementsOptions& old_config) +{ + if (IsUsingRAIntegration()) + return; + + if (!EmuConfig.Achievements.Enabled) + { + // we're done here + Shutdown(); + return; + } + + if (!s_active) + { + // we just got enabled + Initialize(); + return; + } + + if (EmuConfig.Achievements.ChallengeMode != old_config.ChallengeMode) + { + // Hardcore mode can only be enabled through system boot/reset. + if (s_challenge_mode && !EmuConfig.Achievements.ChallengeMode) + { + ResetChallengeMode(); + } + else if (!s_challenge_mode && EmuConfig.Achievements.ChallengeMode) + { + Host::AddKeyedOSDMessage("challenge_mode_reset", "Hardcore mode will be enabled on system reset.", 10.0f); + } + } + + // We *could* handle these separately, but easier to just flush everything. + if (EmuConfig.Achievements.TestMode != old_config.TestMode || + EmuConfig.Achievements.UnofficialTestMode != old_config.UnofficialTestMode || + EmuConfig.Achievements.RichPresence != old_config.RichPresence) + { + Shutdown(); + Initialize(); + return; + } + else if (EmuConfig.Achievements.Leaderboards != old_config.Leaderboards) + { + // If leaderboards were toggled, redisplay the summary (but only when HC mode is active, + // because otherwise leaderboards are irrelevant anyway). + if (HasActiveGame() && ChallengeModeActive()) + DisplayAchievementSummary(); + } + + // in case cache directory changed + EnsureCacheDirectoriesExist(); +} + +bool Achievements::ConfirmChallengeModeDisable(const char* trigger) +{ +#ifdef ENABLE_RAINTEGRATION + if (IsUsingRAIntegration()) + return (RA_WarnDisableHardcore(trigger) != 0); +#endif + + const bool confirmed = + Host::ConfirmMessage("Confirm Hardcore Mode", fmt::format("{0} cannot be performed while hardcore mode is active. Do you want to " + "disable hardcore mode? {0} will be cancelled if you select No.", + trigger)); + if (!confirmed) + return false; + + DisableChallengeMode(); + return true; +} + +void Achievements::DisableChallengeMode() +{ + if (!s_active) + return; + +#ifdef ENABLE_RAINTEGRATION + if (IsUsingRAIntegration()) + { + if (RA_HardcoreModeIsActive()) + RA_DisableHardcore(); + + return; + } +#endif + + if (s_challenge_mode) + SetChallengeMode(false); +} + +void Achievements::ResetChallengeMode() +{ + if (!s_active) + return; + + Host::RemoveKeyedOSDMessage("challenge_mode_reset"); + + if (s_challenge_mode != EmuConfig.Achievements.ChallengeMode) + SetChallengeMode(EmuConfig.Achievements.ChallengeMode); +} + +void Achievements::SetChallengeMode(bool enabled) +{ + if (enabled == s_challenge_mode) + return; + + // new mode + s_challenge_mode = enabled; + + if (HasActiveGame()) + ImGuiFullscreen::ShowToast(std::string(), enabled ? "Hardcore mode is now enabled." : "Hardcore mode is now disabled.", 10.0f); + + if (HasActiveGame() && !IsTestModeActive()) + { + // deactivate, but don't clear all achievements (getting unlocks will reactivate them) + std::unique_lock lock(s_achievements_mutex); + for (Achievement& achievement : s_achievements) + { + DeactivateAchievement(&achievement); + achievement.locked = true; + } + for (Leaderboard& leaderboard : s_leaderboards) + rc_runtime_deactivate_lboard(&s_rcheevos_runtime, leaderboard.id); + } + + // re-grab unlocks, this will reactivate what's locked in non-hardcore mode later on + if (!s_achievements.empty()) + GetUserUnlocks(); +} + +bool Achievements::Shutdown() +{ +#ifdef ENABLE_RAINTEGRATION + if (IsUsingRAIntegration()) + { + if (!RA_ConfirmLoadNewRom(true)) + return false; + + RA_SetPaused(false); + RA_ActivateGame(0); + return true; + } +#endif + + if (!s_active) + return true; + + std::unique_lock lock(s_achievements_mutex); + s_http_downloader->WaitForAllRequests(); + + ClearGameInfo(); + ClearGameHash(); + std::string().swap(s_username); + std::string().swap(s_api_token); + s_logged_in = false; + Host::OnAchievementsRefreshed(); + + s_active = false; + rc_runtime_destroy(&s_rcheevos_runtime); + + s_http_downloader.reset(); + return true; +} + +bool Achievements::OnReset() +{ +#ifdef ENABLE_RAINTEGRATION + if (IsUsingRAIntegration()) + { + if (!RA_ConfirmLoadNewRom(false)) + return false; + + RA_OnReset(); + return true; + } +#endif + + if (!s_active) + return true; + + std::unique_lock lock(s_achievements_mutex); + DevCon.WriteLn("Resetting rcheevos state..."); + rc_runtime_reset(&s_rcheevos_runtime); + + // we can re-enable hardcore mode, since we're rebooting the system + if (EmuConfig.Achievements.ChallengeMode && !s_challenge_mode) + { + // but, clear the game first. otherwise, we re-query the current game in hardcore mode, + // reset to nothing (booting), then back to the game again and spam the user. + ClearGameInfo(true, true); + ClearGameHash(); + ResetChallengeMode(); + } + + return true; +} + +void Achievements::OnPaused(bool paused) +{ +#ifdef ENABLE_RAINTEGRATION + if (IsUsingRAIntegration()) + RA_SetPaused(paused); +#endif +} + +void Achievements::VSyncUpdate() +{ +#ifdef ENABLE_RAINTEGRATION + if (IsUsingRAIntegration()) + { + RA_DoAchievementsFrame(); + return; + } +#endif + + s_http_downloader->PollRequests(); + + if (HasActiveGame()) + { + std::unique_lock lock(s_achievements_mutex); + rc_runtime_do_frame(&s_rcheevos_runtime, &CheevosEventHandler, &PeekMemory, nullptr, nullptr); + UpdateRichPresence(); + + if (!IsTestModeActive()) + { + const s32 ping_frequency = EmuConfig.Achievements.RichPresence ? RICH_PRESENCE_PING_FREQUENCY : NO_RICH_PRESENCE_PING_FREQUENCY; + if (static_cast(s_last_ping_time.GetTimeSeconds()) >= ping_frequency) + SendPing(); + } + } +} + +void Achievements::ProcessPendingHTTPRequestsFromGSThread() +{ + // no need to do this if we're running, because VSyncUpdate() will take care of it + if (VMManager::GetState() == VMState::Running) + return; + + if (s_http_downloader->HasAnyRequests()) + Host::RunOnCPUThread([]() { s_http_downloader->PollRequests(); }); +} + +void Achievements::LoadState(const u8* state_data, u32 state_data_size) +{ + if (!s_active) + return; + + // this assumes that the CRC and ELF name has been loaded prior to the cheevos state (it should be). + if (ElfCRC != s_last_game_crc) + GameChanged(ElfCRC); + +#ifdef ENABLE_RAINTEGRATION + if (IsUsingRAIntegration()) + { + if (state_data_size == 0) + { + Console.Warning("State is missing cheevos data, resetting RAIntegration"); + RA_OnReset(); + } + else + { + RA_RestoreState(reinterpret_cast(state_data)); + } + + return; + } +#endif + + // if we're active, make sure we've downloaded and activated all the achievements + // before deserializing, otherwise that state's going to get lost. + if (s_http_downloader->HasAnyRequests()) + { + bool was_running_idle; + BeginLoadingScreen("Downloading achievements data...", &was_running_idle); + s_http_downloader->WaitForAllRequests(); + EndLoadingScreen(was_running_idle); + } + + std::unique_lock lock(s_achievements_mutex); + if (state_data_size == 0) + { + // reset runtime, no data (state might've been created without cheevos) + Console.Warning("State is missing cheevos data, resetting runtime"); + rc_runtime_reset(&s_rcheevos_runtime); + return; + } + + // These routines scare me a bit.. the data isn't bounds checked. + // Really hope that nobody puts any thing malicious in a save state... + const int result = rc_runtime_deserialize_progress(&s_rcheevos_runtime, state_data, nullptr); + if (result != RC_OK) + { + Console.Warning("Failed to deserialize cheevos state (%d), resetting", result); + rc_runtime_reset(&s_rcheevos_runtime); + } +} + +std::vector Achievements::SaveState() +{ + std::vector ret; + +#ifdef ENABLE_RAINTEGRATION + if (IsUsingRAIntegration()) + { + const int size = RA_CaptureState(nullptr, 0); + + const u32 data_size = (size >= 0) ? static_cast(size) : 0; + ret.resize(data_size); + + const int result = RA_CaptureState(reinterpret_cast(ret.data()), static_cast(data_size)); + if (result != static_cast(data_size)) + { + Console.Warning("Failed to serialize cheevos state from RAIntegration."); + ret.clear(); + } + + return ret; + } +#endif + + if (s_active) + { + std::unique_lock lock(s_achievements_mutex); + + // internally this happens twice.. not great. + const int size = rc_runtime_progress_size(&s_rcheevos_runtime, nullptr); + + const u32 data_size = (size >= 0) ? static_cast(size) : 0; + ret.resize(data_size); + + const int result = rc_runtime_serialize_progress(ret.data(), &s_rcheevos_runtime, nullptr); + if (result != RC_OK) + { + // set data to zero, effectively serializing nothing + Console.Warning("Failed to serialize cheevos state (%d)", result); + ret.clear(); + } + } + + return ret; +} + +bool Achievements::SafeHasAchievementsOrLeaderboards() +{ + std::unique_lock lock(s_achievements_mutex); + return !s_achievements.empty() || s_leaderboards.empty(); +} + +const std::string& Achievements::GetUsername() +{ + return s_username; +} + +const std::string& Achievements::GetRichPresenceString() +{ + return s_rich_presence_string; +} + +void Achievements::EnsureCacheDirectoriesExist() +{ + s_game_icon_cache_directory = Path::Combine(EmuFolders::Cache, "achievement_gameicon"); + s_achievement_icon_cache_directory = Path::Combine(EmuFolders::Cache, "achievement_badge"); + + if (!FileSystem::DirectoryExists(s_game_icon_cache_directory.c_str()) && + !FileSystem::CreateDirectoryPath(s_game_icon_cache_directory.c_str(), false)) + { + FormattedError("Failed to create cache directory '%s'", s_game_icon_cache_directory.c_str()); + } + + if (!FileSystem::DirectoryExists(s_achievement_icon_cache_directory.c_str()) && + !FileSystem::CreateDirectoryPath(s_achievement_icon_cache_directory.c_str(), false)) + { + FormattedError("Failed to create cache directory '%s'", s_achievement_icon_cache_directory.c_str()); + } +} + +void Achievements::LoginCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data) +{ + std::unique_lock lock(s_achievements_mutex); + + RAPIResponse response(status_code, data); + if (!response) + { + FormattedError("Login failed. Please check your user name and password, and try again."); + return; + } + + std::string username(response.username); + std::string api_token(response.api_token); + + // save to config + Host::SetBaseStringSettingValue("Achievements", "Username", username.c_str()); + Host::SetBaseStringSettingValue("Achievements", "Token", api_token.c_str()); + Host::SetBaseStringSettingValue("Achievements", "LoginTimestamp", fmt::format("{}", std::time(nullptr)).c_str()); + Host::CommitBaseSettingChanges(); + + if (s_active) + { + s_username = std::move(username); + s_api_token = std::move(api_token); + s_logged_in = true; + + // If we have a game running, set it up. + if (!s_game_hash.empty()) + SendGetGameId(); + } +} + +void Achievements::LoginASyncCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data) +{ + ImGuiFullscreen::CloseBackgroundProgressDialog("cheevos_async_login"); + + LoginCallback(status_code, std::move(content_type), std::move(data)); +} + +void Achievements::SendLogin( + const char* username, const char* password, Common::HTTPDownloader* http_downloader, Common::HTTPDownloader::Request::Callback callback) +{ + RAPIRequest request; + request.username = username; + request.password = password; + request.api_token = nullptr; + request.Send(http_downloader, std::move(callback)); +} + +bool Achievements::LoginAsync(const char* username, const char* password) +{ + s_http_downloader->WaitForAllRequests(); + + if (s_logged_in || std::strlen(username) == 0 || std::strlen(password) == 0 || IsUsingRAIntegration()) + return false; + + if (FullscreenUI::IsInitialized()) + { + ImGuiFullscreen::OpenBackgroundProgressDialog("cheevos_async_login", "Logging in to RetroAchivements...", 0, 1, 0); + } + + SendLogin(username, password, s_http_downloader.get(), LoginASyncCallback); + return true; +} + +bool Achievements::Login(const char* username, const char* password) +{ + if (s_active) + s_http_downloader->WaitForAllRequests(); + + if (s_logged_in || std::strlen(username) == 0 || std::strlen(password) == 0 || IsUsingRAIntegration()) + return false; + + if (s_active) + { + SendLogin(username, password, s_http_downloader.get(), LoginCallback); + s_http_downloader->WaitForAllRequests(); + return IsLoggedIn(); + } + + // create a temporary downloader if we're not initialized + pxAssertRel(!s_active, "RetroAchievements is not active on login"); + std::unique_ptr http_downloader = Common::HTTPDownloader::Create(GetUserAgent().c_str()); + if (!http_downloader) + return false; + + SendLogin(username, password, http_downloader.get(), LoginCallback); + http_downloader->WaitForAllRequests(); + + return !Host::GetBaseStringSettingValue("Achievements", "Token").empty(); +} + +void Achievements::Logout() +{ + if (s_active) + { + std::unique_lock lock(s_achievements_mutex); + s_http_downloader->WaitForAllRequests(); + if (s_logged_in) + { + ClearGameInfo(); + std::string().swap(s_username); + std::string().swap(s_api_token); + s_logged_in = false; + Host::OnAchievementsRefreshed(); + } + } + + // remove from config + Host::RemoveBaseSettingValue("Achievements", "Username"); + Host::RemoveBaseSettingValue("Achievements", "Token"); + Host::RemoveBaseSettingValue("Achievements", "LoginTimestamp"); + Host::CommitBaseSettingChanges(); +} + +void Achievements::DownloadImage(std::string url, std::string cache_filename) +{ + auto callback = [cache_filename](s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data) { + if (status_code != HTTP_OK) + return; + + if (!FileSystem::WriteBinaryFile(cache_filename.c_str(), data.data(), data.size())) + { + Console.Error("Failed to write badge image to '%s'", cache_filename.c_str()); + return; + } + + GetMTGS().RunOnGSThread([cache_filename]() { ImGuiFullscreen::InvalidateCachedTexture(cache_filename); }); + }; + + s_http_downloader->CreateRequest(std::move(url), std::move(callback)); +} + +void Achievements::DisplayAchievementSummary() +{ + std::string title = s_game_title; + if (ChallengeModeActive()) + title += " (Hardcore Mode)"; + + std::string summary; + if (GetAchievementCount() > 0) + { + summary = StringUtil::StdStringFromFormat("You have earned %u of %u achievements, and %u of %u points.", + GetUnlockedAchiementCount(), GetAchievementCount(), GetCurrentPointsForGame(), GetMaximumPointsForGame()); + } + else + { + summary = "This game has no achievements."; + } + if (GetLeaderboardCount() > 0) + { + summary.push_back('\n'); + if (LeaderboardsActive()) + summary.append("Leaderboard submission is enabled."); + } + + ImGuiFullscreen::AddNotification(10.0f, std::move(title), std::move(summary), s_game_icon); + + // Technically not going through the resource API, but since we're passing this to something else, we can't. + if (EmuConfig.Achievements.SoundEffects) + Common::PlaySoundAsync(Path::Combine(EmuFolders::Resources, INFO_SOUND_NAME).c_str()); +} + +void Achievements::DisplayMasteredNotification() +{ + if (!FullscreenUI::IsInitialized()) + return; + + std::string title(fmt::format("Mastered {}", s_game_title)); + std::string message(fmt::format( + "{} achievements, {} points{}", GetAchievementCount(), GetCurrentPointsForGame(), s_challenge_mode ? " (Hardcore Mode)" : "")); + + ImGuiFullscreen::AddNotification(20.0f, std::move(title), std::move(message), s_game_icon); +} + +void Achievements::GetUserUnlocksCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data) +{ + RAPIResponse + response(status_code, data); + + std::unique_lock lock(s_achievements_mutex); + if (!response) + { + ClearGameInfo(true, false); + return; + } + + // flag achievements as unlocked + for (u32 i = 0; i < response.num_achievement_ids; i++) + { + Achievement* cheevo = GetMutableAchievementByID(response.achievement_ids[i]); + if (!cheevo) + { + Console.Error("Server returned unknown achievement %u", response.achievement_ids[i]); + continue; + } + + cheevo->locked = false; + } + + // start scanning for locked achievements + ActivateLockedAchievements(); + DisplayAchievementSummary(); + SendPlaying(); + UpdateRichPresence(); + SendPing(); + Host::OnAchievementsRefreshed(); +} + +void Achievements::GetUserUnlocks() +{ + RAPIRequest request; + request.username = s_username.c_str(); + request.api_token = s_api_token.c_str(); + request.game_id = s_game_id; + request.hardcore = static_cast(ChallengeModeActive()); + request.Send(GetUserUnlocksCallback); +} + +void Achievements::GetPatchesCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data) +{ + if (!VMManager::HasValidVM()) + return; + + RAPIResponse + response(status_code, data); + + std::unique_lock lock(s_achievements_mutex); + ClearGameInfo(); + if (!response) + return; + + // ensure fullscreen UI is ready + GetMTGS().RunOnGSThread([]() { FullscreenUI::Initialize(); }); + + s_game_id = response.id; + s_game_title = response.title; + + // try for a icon + if (std::strlen(response.image_name) > 0) + { + s_game_icon = Path::Combine(s_game_icon_cache_directory, fmt::format("{}.png", s_game_id)); + if (!FileSystem::FileExists(s_game_icon.c_str())) + { + RAPIRequest request; + request.image_name = response.image_name; + request.image_type = RC_IMAGE_TYPE_GAME; + request.DownloadImage(s_game_icon); + } + } + + // parse achievements + for (u32 i = 0; i < response.num_achievements; i++) + { + const rc_api_achievement_definition_t& defn = response.achievements[i]; + + // Skip local and unofficial achievements for now, unless "Test Unofficial Achievements" is enabled + if (defn.category == RC_ACHIEVEMENT_CATEGORY_UNOFFICIAL) + { + if (!IsUnofficialTestModeActive()) + { + Console.Warning("Skipping unofficial achievement %u (%s)", defn.id, defn.title); + continue; + } + } + // local achievements shouldn't be in this list, but just in case? + else if (defn.category != RC_ACHIEVEMENT_CATEGORY_CORE) + { + continue; + } + + if (GetMutableAchievementByID(defn.id)) + { + Console.Error("Achievement %u already exists", defn.id); + continue; + } + + Achievement cheevo; + cheevo.id = defn.id; + cheevo.memaddr = defn.definition; + cheevo.title = defn.title; + cheevo.description = defn.description; + cheevo.badge_name = defn.badge_name; + cheevo.locked = true; + cheevo.active = false; + cheevo.primed = false; + cheevo.points = defn.points; + cheevo.category = static_cast(defn.category); + s_achievements.push_back(std::move(cheevo)); + } + + for (u32 i = 0; i < response.num_leaderboards; i++) + { + const rc_api_leaderboard_definition_t& defn = response.leaderboards[i]; + + Leaderboard lboard; + lboard.id = defn.id; + lboard.title = defn.title; + lboard.description = defn.description; + lboard.format = defn.format; + s_leaderboards.push_back(std::move(lboard)); + + // Always track the leaderboard, if we don't have leaderboards enabled we just won't submit it. + // That way if someone activates them later on, current progress will count. + const int err = rc_runtime_activate_lboard(&s_rcheevos_runtime, defn.id, defn.definition, nullptr, 0); + if (err != RC_OK) + Console.Error("Leaderboard %u memaddr parse error: %s", defn.id, rc_error_str(err)); + else + DevCon.WriteLn("Activated leaderboard %s (%u)", defn.title, defn.id); + } + + // Parse rich presence. + if (std::strlen(response.rich_presence_script) > 0) + { + const int res = rc_runtime_activate_richpresence(&s_rcheevos_runtime, response.rich_presence_script, nullptr, 0); + if (res == RC_OK) + s_has_rich_presence = true; + else + Console.Warning("Failed to activate rich presence: %s", rc_error_str(res)); + } + + Console.WriteLn("Game Title: %s", s_game_title.c_str()); + Console.WriteLn("Achievements: %zu", s_achievements.size()); + Console.WriteLn("Leaderboards: %zu", s_leaderboards.size()); + + // We don't want to block saving/loading states when there's no achievements. + if (s_achievements.empty() && s_leaderboards.empty()) + DisableChallengeMode(); + + if (!s_achievements.empty() || s_has_rich_presence) + { + if (!IsTestModeActive()) + { + GetUserUnlocks(); + } + else + { + ActivateLockedAchievements(); + DisplayAchievementSummary(); + Host::OnAchievementsRefreshed(); + } + } + else + { + DisplayAchievementSummary(); + } + + if (s_achievements.empty() && s_leaderboards.empty() && !s_has_rich_presence) + { + ClearGameInfo(); + } +} + +void Achievements::GetLbInfoCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data) +{ + if (!VMManager::HasValidVM()) + return; + + RAPIResponse + response(status_code, data); + if (!response) + return; + + std::unique_lock lock(s_achievements_mutex); + if (response.id != s_last_queried_lboard) + { + // User has already requested another leaderboard, drop this data + return; + } + + const Leaderboard* leaderboard = GetLeaderboardByID(response.id); + if (!leaderboard) + { + Console.Error("Attempting to list unknown leaderboard %u", response.id); + return; + } + + s_lboard_entries = std::vector(); + for (u32 i = 0; i < response.num_entries; i++) + { + const rc_api_lboard_info_entry_t& entry = response.entries[i]; + + char score[128]; + rc_runtime_format_lboard_value(score, sizeof(score), entry.score, leaderboard->format); + + LeaderboardEntry lbe; + lbe.user = entry.username; + lbe.rank = entry.rank; + lbe.formatted_score = score; + lbe.is_self = lbe.user == s_username; + + s_lboard_entries->push_back(std::move(lbe)); + } +} + +void Achievements::GetPatches(u32 game_id) +{ + RAPIRequest request; + request.username = s_username.c_str(); + request.api_token = s_api_token.c_str(); + request.game_id = game_id; + request.Send(GetPatchesCallback); +} + +std::string_view Achievements::GetELFNameForHash(const std::string& elf_path) +{ + std::string::size_type start = elf_path.rfind('\\'); + if (start == std::string::npos) + start = 0; + else + start++; // skip backslash + + std::string::size_type end = elf_path.rfind(';'); + if (end == std::string::npos) + end = elf_path.size(); + + if (end < start) + end = start; + + return std::string_view(elf_path).substr(start, end - start); +} + +std::optional> Achievements::ReadELFFromCurrentDisc(const std::string& elf_path) +{ + // This CDVD stuff is super nasty and full of exceptions.. + std::optional> ret; + try + { + IsoFSCDVD isofs; + IsoFile file(isofs, elf_path); + const u32 size = file.getLength(); + + ret = std::vector(); + ret->resize(size); + + if (size > 0) + { + const s32 bytes_read = file.read(ret->data(), static_cast(size)); + if (bytes_read != static_cast(size)) + { + Console.Error("(Achievements) Only read %d of %u bytes of ELF '%s'", bytes_read, size, elf_path.c_str()); + ret.reset(); + } + } + } + catch (...) + { + Console.Error("(Achievements) Caught exception while trying to read ELF '%s'.", elf_path.c_str()); + ret.reset(); + } + + return ret; +} + +std::string Achievements::GetGameHash() +{ + const std::string& elf_path = LastELF; + if (elf_path.empty()) + return {}; + + // this.. really shouldn't be invalid + const std::string_view name_for_hash(GetELFNameForHash(elf_path)); + if (name_for_hash.empty()) + return {}; + + std::optional> elf_data(ReadELFFromCurrentDisc(elf_path)); + if (!elf_data.has_value()) + return {}; + + // See rcheevos hash.c - rc_hash_ps2(). + const u32 MAX_HASH_SIZE = 64 * 1024 * 1024; + const u32 hash_size = std::min(static_cast(elf_data->size()), MAX_HASH_SIZE); + pxAssert(hash_size <= elf_data->size()); + + MD5Digest digest; + if (!name_for_hash.empty()) + digest.Update(name_for_hash.data(), static_cast(name_for_hash.size())); + if (hash_size > 0) + digest.Update(elf_data->data(), hash_size); + + u8 hash[16]; + digest.Final(hash); + + std::string hash_str( + StringUtil::StdStringFromFormat("%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])); + + Console.WriteLn("Hash for '%.*s' (%zu bytes, %u bytes hashed): %s", static_cast(name_for_hash.size()), name_for_hash.data(), + elf_data->size(), hash_size, hash_str.c_str()); + return hash_str; +} + +void Achievements::GetGameIdCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data) +{ + if (!VMManager::HasValidVM()) + return; + + RAPIResponse response( + status_code, data); + if (!response) + return; + + const u32 game_id = response.game_id; + Console.WriteLn("Server returned GameID %u", game_id); + if (game_id == 0) + { + // We don't want to block saving/loading states when there's no achievements. + DisableChallengeMode(); + return; + } + + GetPatches(game_id); +} + +void Achievements::GameChanged(u32 crc) +{ + std::unique_lock lock(s_achievements_mutex); + if (!s_active) + return; + + // avoid reading+hashing the executable if the crc hasn't changed + if (s_last_game_crc == crc) + return; + + std::string game_hash(GetGameHash()); + if (s_game_hash == game_hash) + return; + + if (!IsUsingRAIntegration() && s_http_downloader->HasAnyRequests()) + { + bool was_running_idle; + BeginLoadingScreen("Downloading achievements data...", &was_running_idle); + s_http_downloader->WaitForAllRequests(); + EndLoadingScreen(was_running_idle); + } + + ClearGameInfo(); + ClearGameHash(); + s_last_game_crc = crc; + s_game_hash = std::move(game_hash); + +#ifdef ENABLE_RAINTEGRATION + if (IsUsingRAIntegration()) + { + RAIntegration::GameChanged(); + return; + } +#endif + + if (s_game_hash.empty()) + { + // when we're booting the bios, or shutting down, this will fail + if (crc != 0) + { + Host::AddKeyedOSDMessage( + "retroachievements_disc_read_failed", "Failed to read executable from disc. Achievements disabled.", 10.0f); + } + + s_last_game_crc = 0; + return; + } + + if (IsLoggedIn()) + SendGetGameId(); +} + +void Achievements::SendGetGameId() +{ + RAPIRequest request; + request.username = s_username.c_str(); + request.api_token = s_api_token.c_str(); + request.game_hash = s_game_hash.c_str(); + request.Send(GetGameIdCallback); +} + +void Achievements::SendPlayingCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data) +{ + if (!VMManager::HasValidVM()) + return; + + RAPIResponse response( + status_code, data); + if (!response) + return; + + Console.WriteLn("Playing game updated to %u (%s)", s_game_id, s_game_title.c_str()); +} + +void Achievements::SendPlaying() +{ + if (!HasActiveGame()) + return; + + RAPIRequest request; + request.username = s_username.c_str(); + request.api_token = s_api_token.c_str(); + request.game_id = s_game_id; + request.Send(SendPlayingCallback); +} + +void Achievements::UpdateRichPresence() +{ + if (!s_has_rich_presence) + return; + + char buffer[512]; + const int res = rc_runtime_get_richpresence(&s_rcheevos_runtime, buffer, sizeof(buffer), PeekMemory, nullptr, nullptr); + if (res <= 0) + { + const bool had_rich_presence = !s_rich_presence_string.empty(); + s_rich_presence_string.clear(); + if (had_rich_presence) + Host::OnAchievementsRefreshed(); + + return; + } + + std::unique_lock lock(s_achievements_mutex); + if (s_rich_presence_string == buffer) + return; + + s_rich_presence_string.assign(buffer); + Host::OnAchievementsRefreshed(); +} + +void Achievements::SendPingCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data) +{ + if (!VMManager::HasValidVM()) + return; + + RAPIResponse response(status_code, data); +} + +void Achievements::SendPing() +{ + if (!HasActiveGame()) + return; + + s_last_ping_time.Reset(); + + RAPIRequest request; + request.api_token = s_api_token.c_str(); + request.username = s_username.c_str(); + request.game_id = s_game_id; + request.rich_presence = s_rich_presence_string.c_str(); + request.Send(SendPingCallback); +} + +const std::string& Achievements::GetGameTitle() +{ + return s_game_title; +} + +const std::string& Achievements::GetGameIcon() +{ + return s_game_icon; +} + +bool Achievements::EnumerateAchievements(std::function callback) +{ + for (const Achievement& cheevo : s_achievements) + { + if (!callback(cheevo)) + return false; + } + + return true; +} + +u32 Achievements::GetUnlockedAchiementCount() +{ + u32 count = 0; + for (const Achievement& cheevo : s_achievements) + { + if (!cheevo.locked) + count++; + } + + return count; +} + +u32 Achievements::GetAchievementCount() +{ + return static_cast(s_achievements.size()); +} + +u32 Achievements::GetMaximumPointsForGame() +{ + u32 points = 0; + for (const Achievement& cheevo : s_achievements) + points += cheevo.points; + + return points; +} + +u32 Achievements::GetCurrentPointsForGame() +{ + u32 points = 0; + for (const Achievement& cheevo : s_achievements) + { + if (!cheevo.locked) + points += cheevo.points; + } + + return points; +} + +bool Achievements::EnumerateLeaderboards(std::function callback) +{ + for (const Leaderboard& lboard : s_leaderboards) + { + if (!callback(lboard)) + return false; + } + + return true; +} + +std::optional Achievements::TryEnumerateLeaderboardEntries(u32 id, std::function callback) +{ + if (id == s_last_queried_lboard) + { + if (s_lboard_entries) + { + for (const LeaderboardEntry& entry : *s_lboard_entries) + { + if (!callback(entry)) + return false; + } + return true; + } + } + else + { + s_last_queried_lboard = id; + s_lboard_entries.reset(); + + // TODO: Add paging? For now, stick to defaults + RAPIRequest request; + request.username = s_username.c_str(); + request.leaderboard_id = id; + request.first_entry = 0; + + // Just over what a single page can store, should be a reasonable amount for now + request.count = 15; + + request.Send(GetLbInfoCallback); + } + + return std::nullopt; +} + +const Achievements::Leaderboard* Achievements::GetLeaderboardByID(u32 id) +{ + for (const Leaderboard& lb : s_leaderboards) + { + if (lb.id == id) + return &lb; + } + + return nullptr; +} + +u32 Achievements::GetLeaderboardCount() +{ + return static_cast(s_leaderboards.size()); +} + +bool Achievements::IsLeaderboardTimeType(const Leaderboard& leaderboard) +{ + return leaderboard.format != RC_FORMAT_SCORE && leaderboard.format != RC_FORMAT_VALUE; +} + +bool Achievements::IsMastered() +{ + for (const Achievement& cheevo : s_achievements) + { + if (cheevo.locked) + return false; + } + + return true; +} + +void Achievements::ActivateLockedAchievements() +{ + for (Achievement& cheevo : s_achievements) + { + if (cheevo.locked) + ActivateAchievement(&cheevo); + } +} + +bool Achievements::ActivateAchievement(Achievement* achievement) +{ + if (achievement->active) + return true; + + const int err = rc_runtime_activate_achievement(&s_rcheevos_runtime, achievement->id, achievement->memaddr.c_str(), nullptr, 0); + if (err != RC_OK) + { + Console.Error("Achievement %u memaddr parse error: %s", achievement->id, rc_error_str(err)); + return false; + } + + achievement->active = true; + + DevCon.WriteLn("Activated achievement %s (%u)", achievement->title.c_str(), achievement->id); + return true; +} + +void Achievements::DeactivateAchievement(Achievement* achievement) +{ + if (!achievement->active) + return; + + rc_runtime_deactivate_achievement(&s_rcheevos_runtime, achievement->id); + achievement->active = false; + + if (achievement->primed) + { + achievement->primed = false; + s_primed_achievement_count.fetch_sub(std::memory_order_acq_rel); + } + + DevCon.WriteLn("Deactivated achievement %s (%u)", achievement->title.c_str(), achievement->id); +} + +void Achievements::UnlockAchievementCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data) +{ + if (!VMManager::HasValidVM()) + return; + + RAPIResponse + response(status_code, data); + if (!response) + return; + + Console.WriteLn("Successfully unlocked achievement %u, new score %u", response.awarded_achievement_id, response.new_player_score); +} + +void Achievements::SubmitLeaderboardCallback(s32 status_code, const std::string& content_type, Common::HTTPDownloader::Request::Data data) +{ + if (!VMManager::HasValidVM()) + return; + + RAPIResponse + response(status_code, data); + if (!response) + return; + + // Force the next leaderboard query to repopulate everything, just in case the user wants to see their new score + s_last_queried_lboard = 0; + + // RA API doesn't send us the leaderboard ID back.. hopefully we don't submit two at once :/ + if (s_submitting_lboard_id == 0) + return; + + const Leaderboard* lb = GetLeaderboardByID(std::exchange(s_submitting_lboard_id, 0u)); + if (!lb) + return; + + char submitted_score[128]; + char best_score[128]; + rc_runtime_format_lboard_value(submitted_score, sizeof(submitted_score), response.submitted_score, lb->format); + rc_runtime_format_lboard_value(best_score, sizeof(best_score), response.best_score, lb->format); + + std::string summary = fmt::format( + "Your Score: {} (Best: {})\nLeaderboard Position: {} of {}", submitted_score, best_score, response.new_rank, response.num_entries); + + ImGuiFullscreen::AddNotification(10.0f, lb->title, std::move(summary), s_game_icon); +} + +void Achievements::UnlockAchievement(u32 achievement_id, bool add_notification /* = true*/) +{ + std::unique_lock lock(s_achievements_mutex); + Achievement* achievement = GetMutableAchievementByID(achievement_id); + if (!achievement) + { + Console.Error("Attempting to unlock unknown achievement %u", achievement_id); + return; + } + else if (!achievement->locked) + { + Console.Warning("Achievement %u for game %u is already unlocked", achievement_id, s_game_id); + return; + } + + achievement->locked = false; + DeactivateAchievement(achievement); + + Console.WriteLn("Achievement %s (%u) for game %u unlocked", achievement->title.c_str(), achievement_id, s_game_id); + + std::string title; + switch (achievement->category) + { + case AchievementCategory::Local: + title = fmt::format("{} (Local)", achievement->title); + break; + case AchievementCategory::Unofficial: + title = fmt::format("{} (Unofficial)", achievement->title); + break; + case AchievementCategory::Core: + default: + title = achievement->title; + break; + } + + ImGuiFullscreen::AddNotification(15.0f, std::move(title), achievement->description, GetAchievementBadgePath(*achievement)); + if (EmuConfig.Achievements.SoundEffects) + Common::PlaySoundAsync(Path::Combine(EmuFolders::Resources, UNLOCK_SOUND_NAME).c_str()); + + if (IsMastered()) + DisplayMasteredNotification(); + + if (IsTestModeActive()) + { + Console.Warning("Skipping sending achievement %u unlock to server because of test mode.", achievement_id); + return; + } + + if (achievement->category != AchievementCategory::Core) + { + Console.Warning("Skipping sending achievement %u unlock to server because it's not from the core set.", achievement_id); + return; + } + + RAPIRequest request; + request.username = s_username.c_str(); + request.api_token = s_api_token.c_str(); + request.game_hash = s_game_hash.c_str(); + request.achievement_id = achievement_id; + request.hardcore = static_cast(ChallengeModeActive()); + request.Send(UnlockAchievementCallback); +} + +void Achievements::AchievementPrimed(u32 achievement_id) +{ + std::unique_lock lock(s_achievements_mutex); + Achievement* achievement = GetMutableAchievementByID(achievement_id); + if (!achievement || achievement->primed) + return; + + achievement->primed = true; + s_primed_achievement_count.fetch_add(std::memory_order_acq_rel); +} + +void Achievements::AchievementUnprimed(u32 achievement_id) +{ + std::unique_lock lock(s_achievements_mutex); + Achievement* achievement = GetMutableAchievementByID(achievement_id); + if (!achievement || !achievement->primed) + return; + + achievement->primed = false; + s_primed_achievement_count.fetch_sub(std::memory_order_acq_rel); +} + +void Achievements::SubmitLeaderboard(u32 leaderboard_id, int value) +{ + if (IsTestModeActive()) + { + Console.Warning("Skipping sending leaderboard %u result to server because of test mode.", leaderboard_id); + return; + } + + if (!ChallengeModeActive()) + { + Console.Warning("Skipping sending leaderboard %u result to server because Challenge mode is off.", leaderboard_id); + return; + } + + if (!LeaderboardsActive()) + { + Console.Warning("Skipping sending leaderboard %u result to server because leaderboards are disabled.", leaderboard_id); + return; + } + + std::unique_lock lock(s_achievements_mutex); + s_submitting_lboard_id = leaderboard_id; + + RAPIRequest request; + request.username = s_username.c_str(); + request.api_token = s_api_token.c_str(); + request.game_hash = s_game_hash.c_str(); + request.leaderboard_id = leaderboard_id; + request.score = value; + request.Send(SubmitLeaderboardCallback); + + // Technically not going through the resource API, but since we're passing this to something else, we can't. + if (EmuConfig.Achievements.SoundEffects) + Common::PlaySoundAsync(Path::Combine(EmuFolders::Resources, LBSUBMIT_SOUND_NAME).c_str()); +} + +std::pair Achievements::GetAchievementProgress(const Achievement& achievement) +{ + std::pair result; + rc_runtime_get_achievement_measured(&s_rcheevos_runtime, achievement.id, &result.first, &result.second); + return result; +} + +std::string Achievements::GetAchievementProgressText(const Achievement& achievement) +{ + char buf[256]; + rc_runtime_format_achievement_measured(&s_rcheevos_runtime, achievement.id, buf, std::size(buf)); + return buf; +} + +const std::string& Achievements::GetAchievementBadgePath(const Achievement& achievement, bool download_if_missing) +{ + std::string& badge_path = achievement.locked ? achievement.locked_badge_path : achievement.unlocked_badge_path; + if (!badge_path.empty() || achievement.badge_name.empty()) + return badge_path; + + // well, this comes from the internet.... :) + std::string clean_name(Path::SanitizeFileName(achievement.badge_name)); + badge_path = Path::Combine(s_achievement_icon_cache_directory, fmt::format("{}{}.png", clean_name, achievement.locked ? "_lock" : "")); + if (FileSystem::FileExists(badge_path.c_str())) + return badge_path; + + // need to download it + if (download_if_missing) + { + RAPIRequest request; + request.image_name = achievement.badge_name.c_str(); + request.image_type = achievement.locked ? RC_IMAGE_TYPE_ACHIEVEMENT_LOCKED : RC_IMAGE_TYPE_ACHIEVEMENT; + request.DownloadImage(badge_path); + } + + return badge_path; +} + +std::string Achievements::GetAchievementBadgeURL(const Achievement& achievement) +{ + RAPIRequest request; + request.image_name = achievement.badge_name.c_str(); + request.image_type = achievement.locked ? RC_IMAGE_TYPE_ACHIEVEMENT_LOCKED : RC_IMAGE_TYPE_ACHIEVEMENT; + return request.GetURL(); +} + +u32 Achievements::GetPrimedAchievementCount() +{ + // Relaxed is fine here, worst that happens is we draw the triggers one frame late. + return s_primed_achievement_count.load(std::memory_order_relaxed); +} + +void Achievements::CheevosEventHandler(const rc_runtime_event_t* runtime_event) +{ + static const char* events[] = {"RC_RUNTIME_EVENT_ACHIEVEMENT_ACTIVATED", "RC_RUNTIME_EVENT_ACHIEVEMENT_PAUSED", + "RC_RUNTIME_EVENT_ACHIEVEMENT_RESET", "RC_RUNTIME_EVENT_ACHIEVEMENT_TRIGGERED", "RC_RUNTIME_EVENT_ACHIEVEMENT_PRIMED", + "RC_RUNTIME_EVENT_LBOARD_STARTED", "RC_RUNTIME_EVENT_LBOARD_CANCELED", "RC_RUNTIME_EVENT_LBOARD_UPDATED", + "RC_RUNTIME_EVENT_LBOARD_TRIGGERED", "RC_RUNTIME_EVENT_ACHIEVEMENT_DISABLED", "RC_RUNTIME_EVENT_LBOARD_DISABLED"}; + const char* event_text = ((unsigned)runtime_event->type >= std::size(events)) ? "unknown" : events[(unsigned)runtime_event->type]; + DevCon.WriteLn("Cheevos Event %s for %u", event_text, runtime_event->id); + + switch (runtime_event->type) + { + case RC_RUNTIME_EVENT_ACHIEVEMENT_TRIGGERED: + UnlockAchievement(runtime_event->id); + break; + + case RC_RUNTIME_EVENT_ACHIEVEMENT_PRIMED: + AchievementPrimed(runtime_event->id); + break; + + case RC_RUNTIME_EVENT_ACHIEVEMENT_UNPRIMED: + AchievementUnprimed(runtime_event->id); + break; + + case RC_RUNTIME_EVENT_LBOARD_TRIGGERED: + SubmitLeaderboard(runtime_event->id, runtime_event->value); + break; + + default: + break; + } +} + +unsigned Achievements::PeekMemory(unsigned address, unsigned num_bytes, void* ud) +{ + if ((static_cast(address) + num_bytes) >= EXPOSED_EE_MEMORY_SIZE) + { + Console.Warning("[Achievements] Ignoring out of bounds memory peek of %u bytes at %08X.", num_bytes, address); + return 0u; + } + + switch (num_bytes) + { + case 1: + { + u8 value; + std::memcpy(&value, reinterpret_cast(eeMem) + address, sizeof(value)); + return static_cast(value); + } + + case 2: + { + u16 value; + std::memcpy(&value, reinterpret_cast(eeMem) + address, sizeof(value)); + return static_cast(value); + } + + case 4: + { + u32 value; + std::memcpy(&value, reinterpret_cast(eeMem) + address, sizeof(value)); + return static_cast(value); + } + + default: + return 0u; + } +} + +unsigned Achievements::PeekMemoryBlock(unsigned address, unsigned char* buffer, unsigned num_bytes) +{ + if ((address >= EXPOSED_EE_MEMORY_SIZE)) + { + Console.Warning("[Achievements] Ignoring out of bounds block memory read for %u bytes at %08X.", num_bytes, address); + return 0u; + } + + const unsigned read_byte_count = std::min(EXPOSED_EE_MEMORY_SIZE - address, num_bytes); + std::memcpy(buffer, reinterpret_cast(eeMem) + address, read_byte_count); + return read_byte_count; +} + +void Achievements::PokeMemory(unsigned address, unsigned num_bytes, void* ud, unsigned value) +{ + if ((static_cast(address) + num_bytes) >= EXPOSED_EE_MEMORY_SIZE) + { + Console.Warning("[Achievements] Ignoring out of bounds memory poke of %u bytes at %08X (value %08X).", num_bytes, address, value); + return; + } + + switch (num_bytes) + { + case 1: + { + const u8 value8 = static_cast(value); + std::memcpy(reinterpret_cast(eeMem) + address, &value8, sizeof(value8)); + } + break; + + case 2: + { + const u16 value16 = static_cast(value); + std::memcpy(reinterpret_cast(eeMem) + address, &value16, sizeof(value16)); + } + break; + + case 4: + { + const u32 value32 = static_cast(value); + std::memcpy(reinterpret_cast(eeMem) + address, &value32, sizeof(value32)); + } + break; + + default: + break; + } +} + +#ifdef ENABLE_RAINTEGRATION + +#include "RA_Consoles.h" + +namespace Achievements::RAIntegration +{ + static void InitializeRAIntegration(void* main_window_handle); + + static int RACallbackIsActive(); + static void RACallbackCauseUnpause(); + static void RACallbackCausePause(); + static void RACallbackRebuildMenu(); + static void RACallbackEstimateTitle(char* buf); + static void RACallbackResetEmulator(); + static void RACallbackLoadROM(const char* unused); + static unsigned char RACallbackReadMemory(unsigned int address); + static unsigned int RACallbackReadBlock(unsigned int address, unsigned char* buffer, unsigned int bytes); + static void RACallbackWriteMemory(unsigned int address, unsigned char value); + + static bool s_raintegration_initialized = false; +} // namespace Achievements::RAIntegration + +void Achievements::SwitchToRAIntegration() +{ + s_using_raintegration = true; + s_active = true; + + // Not strictly the case, but just in case we gate anything by IsLoggedIn(). + s_logged_in = true; +} + +void Achievements::RAIntegration::InitializeRAIntegration(void* main_window_handle) +{ + RA_InitClient((HWND)main_window_handle, "PCSX2", GIT_TAG); + RA_SetUserAgentDetail(Achievements::GetUserAgent().c_str()); + + RA_InstallSharedFunctions(RACallbackIsActive, RACallbackCauseUnpause, RACallbackCausePause, RACallbackRebuildMenu, + RACallbackEstimateTitle, RACallbackResetEmulator, RACallbackLoadROM); + RA_SetConsoleID(PlayStation2); + + // EE physical memory and scratchpad are currently exposed (matching direct rcheevos implementation). + RA_InstallMemoryBank(0, RACallbackReadMemory, RACallbackWriteMemory, EXPOSED_EE_MEMORY_SIZE); + RA_InstallMemoryBankBlockReader(0, RACallbackReadBlock); + + // Fire off a login anyway. Saves going into the menu and doing it. + RA_AttemptLogin(0); + + s_raintegration_initialized = true; + + // this is pretty lame, but we may as well persist until we exit anyway + std::atexit(RA_Shutdown); +} + +void Achievements::RAIntegration::MainWindowChanged(void* new_handle) +{ + if (s_raintegration_initialized) + { + RA_UpdateHWnd((HWND)new_handle); + return; + } + + InitializeRAIntegration(new_handle); +} + +void Achievements::RAIntegration::GameChanged() +{ + s_game_id = s_game_hash.empty() ? 0 : RA_IdentifyHash(s_game_hash.c_str()); + RA_ActivateGame(s_game_id); +} + +std::vector> Achievements::RAIntegration::GetMenuItems() +{ + std::array items; + const int num_items = RA_GetPopupMenuItems(items.data()); + + std::vector> ret; + ret.reserve(static_cast(num_items)); + + for (int i = 0; i < num_items; i++) + { + const RA_MenuItem& it = items[i]; + if (!it.sLabel) + { + // separator + ret.emplace_back(0, std::string(), false); + } + else + { + // option, maybe checkable + ret.emplace_back(static_cast(it.nID), StringUtil::WideStringToUTF8String(it.sLabel), it.bChecked); + } + } + + return ret; +} + +void Achievements::RAIntegration::ActivateMenuItem(int item) +{ + RA_InvokeDialog(item); +} + +int Achievements::RAIntegration::RACallbackIsActive() +{ + return static_cast(HasActiveGame()); +} + +void Achievements::RAIntegration::RACallbackCauseUnpause() +{ + if (VMManager::HasValidVM()) + VMManager::SetState(VMState::Running); +} + +void Achievements::RAIntegration::RACallbackCausePause() +{ + if (VMManager::HasValidVM()) + VMManager::SetState(VMState::Paused); +} + +void Achievements::RAIntegration::RACallbackRebuildMenu() +{ + // unused, we build the menu on demand +} + +void Achievements::RAIntegration::RACallbackEstimateTitle(char* buf) +{ + std::string title(fmt::format("{0} ({1}) [{2:08X}]", VMManager::GetGameName(), VMManager::GetGameSerial(), VMManager::GetGameCRC())); + StringUtil::Strlcpy(buf, title, 256); +} + +void Achievements::RAIntegration::RACallbackResetEmulator() +{ + if (VMManager::HasValidVM()) + VMManager::Reset(); +} + +void Achievements::RAIntegration::RACallbackLoadROM(const char* unused) +{ + // unused + UNREFERENCED_PARAMETER(unused); +} + +unsigned char Achievements::RAIntegration::RACallbackReadMemory(unsigned int address) +{ + return static_cast(PeekMemory(address, sizeof(unsigned char), nullptr)); +} + +unsigned int Achievements::RAIntegration::RACallbackReadBlock(unsigned int address, unsigned char* buffer, unsigned int bytes) +{ + return PeekMemoryBlock(address, buffer, bytes); +} + +void Achievements::RAIntegration::RACallbackWriteMemory(unsigned int address, unsigned char value) +{ + PokeMemory(address, sizeof(value), nullptr, static_cast(value)); +} + +#endif diff --git a/pcsx2/Frontend/Achievements.h b/pcsx2/Frontend/Achievements.h new file mode 100644 index 0000000000..b21eb4f9f0 --- /dev/null +++ b/pcsx2/Frontend/Achievements.h @@ -0,0 +1,171 @@ +/* PCSX2 - PS2 Emulator for PCs + * Copyright (C) 2002-2022 PCSX2 Dev Team + * + * PCSX2 is free software: you can redistribute it and/or modify it under the terms + * of the GNU Lesser General Public License as published by the Free Software Found- + * ation, either version 3 of the License, or (at your option) any later version. + * + * PCSX2 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; + * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR + * PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with PCSX2. + * If not, see . + */ + +#pragma once +#include "common/Pcsx2Defs.h" +#include "pcsx2/Achievements.h" +#include "Config.h" +#include +#include +#include +#include +#include +#include +#include + +namespace Achievements +{ + enum class AchievementCategory : u8 + { + Local = 0, + Core = 3, + Unofficial = 5 + }; + + struct Achievement + { + u32 id; + std::string title; + std::string description; + std::string memaddr; + std::string badge_name; + + // badge paths are mutable because they're resolved when they're needed. + mutable std::string locked_badge_path; + mutable std::string unlocked_badge_path; + + u32 points; + AchievementCategory category; + bool locked; + bool active; + bool primed; + }; + + struct Leaderboard + { + u32 id; + std::string title; + std::string description; + int format; + }; + + struct LeaderboardEntry + { + std::string user; + std::string formatted_score; + u32 rank; + bool is_self; + }; + + // RAIntegration only exists for Windows, so no point checking it on other platforms. +#ifdef ENABLE_RAINTEGRATION + + bool IsUsingRAIntegration(); + +#else + + static __fi bool IsUsingRAIntegration() { return false; } + +#endif + + bool IsActive(); + bool IsLoggedIn(); + bool ChallengeModeActive(); + bool LeaderboardsActive(); + bool IsTestModeActive(); + bool IsUnofficialTestModeActive(); + bool IsRichPresenceEnabled(); + bool HasActiveGame(); + + u32 GetGameID(); + + /// Acquires the achievements lock. Must be held when accessing any achievement state from another thread. + std::unique_lock GetLock(); + + void Initialize(); + void UpdateSettings(const Pcsx2Config::AchievementsOptions& old_config); + + /// Called when the system is being reset. If it returns false, the reset should be aborted. + bool OnReset(); + + /// Called when the system is being shut down. If Shutdown() returns false, the shutdown should be aborted. + bool Shutdown(); + + /// Called when the system is being paused and resumed. + void OnPaused(bool paused); + + /// Called once a frame at vsync time on the CPU thread. + void VSyncUpdate(); + + /// Called to process pending HTTP requests when the VM is paused, because otherwise the vsync event won't fire. + void ProcessPendingHTTPRequestsFromGSThread(); + + void LoadState(const u8* state_data, u32 state_data_size); + std::vector SaveState(); + + /// Returns true if the current game has any achievements or leaderboards. + /// Does not need to have the lock held. + bool SafeHasAchievementsOrLeaderboards(); + + const std::string& GetUsername(); + const std::string& GetRichPresenceString(); + + bool LoginAsync(const char* username, const char* password); + bool Login(const char* username, const char* password); + void Logout(); + + void GameChanged(u32 crc); + + const std::string& GetGameTitle(); + const std::string& GetGameIcon(); + + bool EnumerateAchievements(std::function callback); + u32 GetUnlockedAchiementCount(); + u32 GetAchievementCount(); + u32 GetMaximumPointsForGame(); + u32 GetCurrentPointsForGame(); + + bool EnumerateLeaderboards(std::function callback); + std::optional TryEnumerateLeaderboardEntries(u32 id, std::function callback); + const Leaderboard* GetLeaderboardByID(u32 id); + u32 GetLeaderboardCount(); + bool IsLeaderboardTimeType(const Leaderboard& leaderboard); + + const Achievement* GetAchievementByID(u32 id); + std::pair GetAchievementProgress(const Achievement& achievement); + std::string GetAchievementProgressText(const Achievement& achievement); + const std::string& GetAchievementBadgePath(const Achievement& achievement, bool download_if_missing = true); + std::string GetAchievementBadgeURL(const Achievement& achievement); + u32 GetPrimedAchievementCount(); + +#ifdef ENABLE_RAINTEGRATION + void SwitchToRAIntegration(); + + namespace RAIntegration + { + void MainWindowChanged(void* new_handle); + void GameChanged(); + std::vector> GetMenuItems(); + void ActivateMenuItem(int item); + } // namespace RAIntegration +#endif +} // namespace Achievements + +/// Functions implemented in the frontend. +namespace Host +{ + void OnAchievementsRefreshed(); + void OnAchievementsChallengeModeChanged(); +} // namespace Host diff --git a/pcsx2/Frontend/CommonHost.cpp b/pcsx2/Frontend/CommonHost.cpp index 647901963f..114d90e3ec 100644 --- a/pcsx2/Frontend/CommonHost.cpp +++ b/pcsx2/Frontend/CommonHost.cpp @@ -36,6 +36,10 @@ #include "Sio.h" #include "VMManager.h" +#ifdef ENABLE_ACHIEVEMENTS +#include "Frontend/Achievements.h" +#endif + #ifdef _WIN32 #include "common/RedtapeWindows.h" #include @@ -183,6 +187,12 @@ void CommonHost::LoadStartupSettings() EmuFolders::LoadConfig(*bsi); EmuFolders::EnsureFoldersExist(); UpdateLogging(*bsi); + +#ifdef ENABLE_RAINTEGRATION + // RAIntegration switch must happen before the UI is created. + if (Host::GetBaseBoolSettingValue("Achievements", "UseRAIntegration", false)) + Achievements::SwitchToRAIntegration(); +#endif } void CommonHost::SetDefaultSettings(SettingsInterface& si, bool folders, bool core, bool controllers, bool hotkeys, bool ui) @@ -222,10 +232,19 @@ void CommonHost::CPUThreadInitialize() // We want settings loaded so we choose the correct renderer for big picture mode. // This also sorts out input sources. VMManager::LoadSettings(); + +#ifdef ENABLE_ACHIEVEMENTS + if (EmuConfig.Achievements.Enabled) + Achievements::Initialize(); +#endif } void CommonHost::CPUThreadShutdown() { +#ifdef ENABLE_ACHIEVEMENTS + Achievements::Shutdown(); +#endif + InputManager::CloseSources(); VMManager::WaitForSaveStateFlush(); VMManager::Internal::ReleaseMemory(); @@ -238,12 +257,18 @@ void CommonHost::LoadSettings(SettingsInterface& si, std::unique_lock s_limiter_mode_prior_to_hold_interaction; @@ -121,6 +125,16 @@ DEFINE_HOTKEY("OpenPauseMenu", "System", "Open Pause Menu", [](s32 pressed) { if (!pressed && VMManager::HasValidVM()) FullscreenUI::OpenPauseMenu(); }) +#ifdef ENABLE_ACHIEVEMENTS +DEFINE_HOTKEY("OpenAchievementsList", "System", "Open Achievements List", [](s32 pressed) { + if (!pressed) + FullscreenUI::OpenAchievementsWindow(); +}) +DEFINE_HOTKEY("OpenLeaderboardsList", "System", "Open Leaderboards List", [](s32 pressed) { + if (!pressed) + FullscreenUI::OpenLeaderboardsWindow(); +}) +#endif DEFINE_HOTKEY("TogglePause", "System", "Toggle Pause", [](s32 pressed) { if (!pressed && VMManager::HasValidVM()) VMManager::SetPaused(VMManager::GetState() != VMState::Paused); diff --git a/pcsx2/Frontend/FullscreenUI.cpp b/pcsx2/Frontend/FullscreenUI.cpp index faff4f9d9c..fac5aa0b5a 100644 --- a/pcsx2/Frontend/FullscreenUI.cpp +++ b/pcsx2/Frontend/FullscreenUI.cpp @@ -57,6 +57,10 @@ #include #include +#ifdef ENABLE_ACHIEVEMENTS +#include "Frontend/Achievements.h" +#endif + static constexpr s32 MAX_SAVE_STATE_SLOTS = 10; using ImGuiFullscreen::g_large_font; @@ -145,13 +149,20 @@ namespace FullscreenUI Landing, GameList, Settings, - PauseMenu + PauseMenu, +#ifdef ENABLE_ACHIEVEMENTS + Achievements, + Leaderboards, +#endif }; enum class PauseSubMenu { None, Exit, +#ifdef ENABLE_ACHIEVEMENTS + Achievements, +#endif }; enum class SettingsPage @@ -287,6 +298,7 @@ namespace FullscreenUI static void DrawHotkeySettingsPage(); static void DrawAchievementsSettingsPage(); static void DrawFoldersSettingsPage(); + static void DrawAchievementsLoginWindow(); static void DrawAdvancedSettingsPage(); static void DrawGameFixesSettingsPage(); @@ -411,6 +423,21 @@ namespace FullscreenUI static std::unordered_map s_cover_image_map; static std::vector s_game_list_sorted_entries; static GameListPage s_game_list_page = GameListPage::Grid; + +#ifdef ENABLE_ACHIEVEMENTS + ////////////////////////////////////////////////////////////////////////// + // Achievements + ////////////////////////////////////////////////////////////////////////// + static void DrawAchievementsWindow(); + static void DrawAchievement(const Achievements::Achievement& cheevo); + static void DrawPrimedAchievements(); + static void DrawLeaderboardsWindow(); + static void DrawLeaderboardListEntry(const Achievements::Leaderboard& lboard); + static void DrawLeaderboardEntry( + const Achievements::LeaderboardEntry& lbEntry, float rank_column_width, float name_column_width, float column_spacing); + + static std::optional s_open_leaderboard_id; +#endif } // namespace FullscreenUI ////////////////////////////////////////////////////////////////////////// @@ -568,6 +595,28 @@ void FullscreenUI::UpdateForcedVsync(bool should_force) g_host_display->SetVSync((should_force && mode == VsyncMode::Off) ? VsyncMode::On : mode); } +void FullscreenUI::CheckForConfigChanges(const Pcsx2Config& old_config) +{ + if (!IsInitialized()) + return; + +#ifdef ENABLE_ACHIEVEMENTS + // If achievements got disabled, we might have the menu open... + // That means we're going to be reaching achievement state. + if (old_config.Achievements.Enabled && !EmuConfig.Achievements.Enabled) + { + // So, wait just in case. + GetMTGS().RunOnGSThread([]() { + if (s_current_main_window == MainWindowType::Achievements || s_current_main_window == MainWindowType::Leaderboards) + { + ReturnToMainWindow(); + } + }); + GetMTGS().WaitGS(false, false, false); + } +#endif +} + void FullscreenUI::OnVMStarted() { if (!IsInitialized()) @@ -736,6 +785,12 @@ void FullscreenUI::Render() ImGuiFullscreen::BeginLayout(); +#ifdef ENABLE_ACHIEVEMENTS + // Primed achievements must come first, because we don't want the pause screen to be behind them. + if (Achievements::GetPrimedAchievementCount() > 0) + DrawPrimedAchievements(); +#endif + switch (s_current_main_window) { case MainWindowType::Landing: @@ -750,6 +805,14 @@ void FullscreenUI::Render() case MainWindowType::PauseMenu: DrawPauseMenu(s_current_main_window); break; +#ifdef ENABLE_ACHIEVEMENTS + case MainWindowType::Achievements: + DrawAchievementsWindow(); + break; + case MainWindowType::Leaderboards: + DrawLeaderboardsWindow(); + break; +#endif default: break; } @@ -857,7 +920,7 @@ void FullscreenUI::DoStartPath(const std::string& path, std::optional state if (VMManager::HasValidVM()) return; - if (VMManager::Initialize(params)) + if (VMManager::Initialize(std::move(params))) VMManager::SetState(VMState::Running); else s_current_main_window = prev_window; @@ -884,7 +947,7 @@ void FullscreenUI::DoStartBIOS() return; VMBootParameters params; - if (VMManager::Initialize(params)) + if (VMManager::Initialize(std::move(params))) VMManager::SetState(VMState::Running); else SwitchToLanding(); @@ -3400,16 +3463,6 @@ void FullscreenUI::DrawHotkeySettingsPage() EndMenuButtons(); } -void FullscreenUI::DrawAchievementsSettingsPage() -{ - // TODO: Implement once achievements are merged. - - BeginMenuButtons(); - ActiveButton(ICON_FA_BAN " This build was not compiled with Achivements support.", false, false, - ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); - EndMenuButtons(); -} - void FullscreenUI::DrawFoldersSettingsPage() { SettingsInterface* bsi = GetEditingSettingsInterface(); @@ -3430,6 +3483,18 @@ void FullscreenUI::DrawFoldersSettingsPage() EndMenuButtons(); } +#ifndef ENABLE_ACHIEVEMENTS + +void FullscreenUI::DrawAchievementsSettingsPage() +{ + BeginMenuButtons(); + ActiveButton(ICON_FA_BAN " This build was not compiled with Achivements support.", false, false, + ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); + EndMenuButtons(); +} + +#endif + void FullscreenUI::DrawAdvancedSettingsPage() { SettingsInterface* bsi = GetEditingSettingsInterface(); @@ -3579,8 +3644,11 @@ void FullscreenUI::DrawPauseMenu(MainWindowType type) window_pos, window_size, "pause_menu", ImVec4(0.0f, 0.0f, 0.0f, 0.0f), 0.0f, 10.0f, ImGuiWindowFlags_NoBackground)) { static constexpr u32 submenu_item_count[] = { - 10, // None + 11, // None 4, // Exit +#ifdef ENABLE_ACHIEVEMENTS + 3, // Achievements +#endif }; const bool just_focused = ResetFocusHere(); @@ -3620,6 +3688,22 @@ void FullscreenUI::DrawPauseMenu(MainWindowType type) SwitchToGameSettings(); } +#ifdef ENABLE_ACHIEVEMENTS + if (ActiveButton(ICON_FA_TROPHY " Achievements", false, + Achievements::HasActiveGame() && Achievements::SafeHasAchievementsOrLeaderboards())) + { + const auto lock = Achievements::GetLock(); + + // skip second menu and go straight to cheevos if there's no lbs + if (Achievements::GetLeaderboardCount() == 0) + OpenAchievementsWindow(); + else + OpenPauseSubMenu(PauseSubMenu::Achievements); + } +#else + ActiveButton(ICON_FA_TROPHY " Achievements", false, false); +#endif + if (ActiveButton(ICON_FA_CAMERA " Save Screenshot", false)) { GSQueueSnapshot(std::string()); @@ -3677,6 +3761,21 @@ void FullscreenUI::DrawPauseMenu(MainWindowType type) DoShutdown(false); } break; + +#ifdef ENABLE_ACHIEVEMENTS + case PauseSubMenu::Achievements: + { + if (ActiveButton(ICON_FA_BACKWARD " Back To Pause Menu", false)) + OpenPauseSubMenu(PauseSubMenu::None); + + if (ActiveButton(ICON_FA_TROPHY " Achievements", false)) + OpenAchievementsWindow(); + + if (ActiveButton(ICON_FA_STOPWATCH " Leaderboards", false)) + OpenLeaderboardsWindow(); + } + break; +#endif } EndMenuButtons(); @@ -4032,7 +4131,7 @@ void FullscreenUI::DoLoadState(std::string path) VMBootParameters params; params.filename = std::move(boot_path); params.save_state = std::move(path); - if (VMManager::Initialize(params)) + if (VMManager::Initialize(std::move(params))) VMManager::SetState(VMState::Running); } }); @@ -4933,3 +5032,858 @@ void FullscreenUI::ProgressCallback::SetCancelled() if (m_cancellable) m_cancelled = true; } + +#ifdef ENABLE_ACHIEVEMENTS + +bool FullscreenUI::OpenAchievementsWindow() +{ + if (!VMManager::HasValidVM() || !Initialize()) + return false; + + if (!Achievements::HasActiveGame() || Achievements::GetAchievementCount() == 0) + { + ShowToast(std::string(), "This game has no achievements."); + return false; + } + + if (s_current_main_window != MainWindowType::PauseMenu) + PauseForMenuOpen(); + + s_current_main_window = MainWindowType::Achievements; + QueueResetFocus(); + return true; +} + +void FullscreenUI::DrawAchievement(const Achievements::Achievement& cheevo) +{ + static constexpr float alpha = 0.8f; + static constexpr float progress_height_unscaled = 20.0f; + static constexpr float progress_spacing_unscaled = 5.0f; + + std::string id_str(fmt::format("chv_{}", cheevo.id)); + + const auto progress = Achievements::GetAchievementProgress(cheevo); + const bool is_measured = progress.second != 0; + + ImRect bb; + bool visible, hovered; + bool pressed = MenuButtonFrame(id_str.c_str(), true, + !is_measured ? LAYOUT_MENU_BUTTON_HEIGHT : LAYOUT_MENU_BUTTON_HEIGHT + progress_height_unscaled + progress_spacing_unscaled, + &visible, &hovered, &bb.Min, &bb.Max, 0, alpha); + if (!visible) + return; + + const ImVec2 image_size(LayoutScale(LAYOUT_MENU_BUTTON_HEIGHT, LAYOUT_MENU_BUTTON_HEIGHT)); + const std::string& badge_path = Achievements::GetAchievementBadgePath(cheevo); + if (!badge_path.empty()) + { + HostDisplayTexture* badge = GetCachedTextureAsync(badge_path.c_str()); + if (badge) + { + ImGui::GetWindowDrawList()->AddImage( + badge->GetHandle(), bb.Min, bb.Min + image_size, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255)); + } + } + + const float midpoint = bb.Min.y + g_large_font->FontSize + LayoutScale(4.0f); + std::string points_text(fmt::format("{} point{}", cheevo.points, cheevo.points != 1 ? "s" : "")); + const ImVec2 points_template_size(g_medium_font->CalcTextSizeA(g_medium_font->FontSize, FLT_MAX, 0.0f, "XXX points")); + const ImVec2 points_size(g_medium_font->CalcTextSizeA( + g_medium_font->FontSize, FLT_MAX, 0.0f, points_text.c_str(), points_text.c_str() + points_text.length())); + const float points_template_start = bb.Max.x - points_template_size.x; + const float points_start = points_template_start + ((points_template_size.x - points_size.x) * 0.5f); + const char* lock_text = cheevo.locked ? ICON_FA_LOCK : ICON_FA_LOCK_OPEN; + const ImVec2 lock_size(g_large_font->CalcTextSizeA(g_large_font->FontSize, FLT_MAX, 0.0f, lock_text)); + + const float text_start_x = bb.Min.x + image_size.x + LayoutScale(15.0f); + const ImRect title_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(points_start, midpoint)); + const ImRect summary_bb(ImVec2(text_start_x, midpoint), ImVec2(points_start, bb.Max.y)); + const ImRect points_bb(ImVec2(points_start, midpoint), bb.Max); + const ImRect lock_bb( + ImVec2(points_template_start + ((points_template_size.x - lock_size.x) * 0.5f), bb.Min.y), ImVec2(bb.Max.x, midpoint)); + + ImGui::PushFont(g_large_font); + ImGui::RenderTextClipped(title_bb.Min, title_bb.Max, cheevo.title.c_str(), cheevo.title.c_str() + cheevo.title.size(), nullptr, + ImVec2(0.0f, 0.0f), &title_bb); + ImGui::RenderTextClipped(lock_bb.Min, lock_bb.Max, lock_text, nullptr, &lock_size, ImVec2(0.0f, 0.0f), &lock_bb); + ImGui::PopFont(); + + ImGui::PushFont(g_medium_font); + if (!cheevo.description.empty()) + { + ImGui::RenderTextClipped(summary_bb.Min, summary_bb.Max, cheevo.description.c_str(), + cheevo.description.c_str() + cheevo.description.size(), nullptr, ImVec2(0.0f, 0.0f), &summary_bb); + } + ImGui::RenderTextClipped(points_bb.Min, points_bb.Max, points_text.c_str(), points_text.c_str() + points_text.length(), &points_size, + ImVec2(0.0f, 0.0f), &points_bb); + ImGui::PopFont(); + + if (is_measured) + { + ImDrawList* dl = ImGui::GetWindowDrawList(); + const float progress_height = LayoutScale(progress_height_unscaled); + const float progress_spacing = LayoutScale(progress_spacing_unscaled); + const float top = midpoint + g_medium_font->FontSize + progress_spacing; + const ImRect progress_bb(ImVec2(text_start_x, top), ImVec2(bb.Max.x, top + progress_height)); + const float fraction = static_cast(progress.first) / static_cast(progress.second); + dl->AddRectFilled(progress_bb.Min, progress_bb.Max, ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryDarkColor)); + dl->AddRectFilled(progress_bb.Min, ImVec2(progress_bb.Min.x + fraction * progress_bb.GetWidth(), progress_bb.Max.y), + ImGui::GetColorU32(ImGuiFullscreen::UISecondaryColor)); + + const std::string text(Achievements::GetAchievementProgressText(cheevo)); + const ImVec2 text_size = ImGui::CalcTextSize(text.c_str()); + const ImVec2 text_pos(progress_bb.Min.x + ((progress_bb.Max.x - progress_bb.Min.x) / 2.0f) - (text_size.x / 2.0f), + progress_bb.Min.y + ((progress_bb.Max.y - progress_bb.Min.y) / 2.0f) - (text_size.y / 2.0f)); + dl->AddText(g_medium_font, g_medium_font->FontSize, text_pos, ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryTextColor), text.c_str(), + text.c_str() + text.size()); + } + +#if 0 + // The API doesn't seem to send us this :( + if (!cheevo.locked) + { + ImGui::PushFont(g_medium_font); + + const ImRect time_bb(ImVec2(text_start_x, bb.Min.y), + ImVec2(bb.Max.x, bb.Min.y + g_medium_font->FontSize + LayoutScale(4.0f))); + text.Format("Unlocked 21 Feb, 2019 @ 3:14am"); + ImGui::RenderTextClipped(time_bb.Min, time_bb.Max, text.GetCharArray(), text.GetCharArray() + text.GetLength(), + nullptr, ImVec2(1.0f, 0.0f), &time_bb); + ImGui::PopFont(); + } +#endif + + if (pressed) + { + // TODO: What should we do here? + // Display information or something.. + } +} + +void FullscreenUI::DrawAchievementsWindow() +{ + // ensure image downloads still happen while we're paused + Achievements::ProcessPendingHTTPRequestsFromGSThread(); + + static constexpr float alpha = 0.8f; + static constexpr float heading_height_unscaled = 110.0f; + + ImGui::SetNextWindowBgAlpha(alpha); + + const ImVec4 background(0.13f, 0.13f, 0.13f, alpha); + const ImVec2 display_size(ImGui::GetIO().DisplaySize); + const float heading_height = LayoutScale(heading_height_unscaled); + + if (BeginFullscreenWindow(ImVec2(0.0f, 0.0f), ImVec2(display_size.x, heading_height), "achievements_heading", background, 0.0f, 0.0f, + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoScrollWithMouse)) + { + auto lock = Achievements::GetLock(); + + ImRect bb; + bool visible, hovered; + /*bool pressed = */ MenuButtonFrame( + "achievements_heading", false, heading_height_unscaled, &visible, &hovered, &bb.Min, &bb.Max, 0, alpha); + + if (visible) + { + const float padding = LayoutScale(10.0f); + const float spacing = LayoutScale(10.0f); + const float image_height = LayoutScale(85.0f); + + const ImVec2 icon_min(bb.Min + ImVec2(padding, padding)); + const ImVec2 icon_max(icon_min + ImVec2(image_height, image_height)); + + const std::string& icon_path = Achievements::GetGameIcon(); + if (!icon_path.empty()) + { + HostDisplayTexture* badge = GetCachedTexture(icon_path.c_str()); + if (badge) + { + ImGui::GetWindowDrawList()->AddImage( + badge->GetHandle(), icon_min, icon_max, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255)); + } + } + + float left = bb.Min.x + padding + image_height + spacing; + float right = bb.Max.x - padding; + float top = bb.Min.y + padding; + ImDrawList* dl = ImGui::GetWindowDrawList(); + std::string text; + ImVec2 text_size; + + const u32 unlocked_count = Achievements::GetUnlockedAchiementCount(); + const u32 achievement_count = Achievements::GetAchievementCount(); + const u32 current_points = Achievements::GetCurrentPointsForGame(); + const u32 total_points = Achievements::GetMaximumPointsForGame(); + + if (FloatingButton(ICON_FA_WINDOW_CLOSE, 10.0f, 10.0f, -1.0f, -1.0f, 1.0f, 0.0f, true, g_large_font) || WantsToCloseMenu()) + { + ReturnToMainWindow(); + } + + const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + g_large_font->FontSize)); + text = Achievements::GetGameTitle(); + + if (Achievements::ChallengeModeActive()) + text += " (Hardcore Mode)"; + + top += g_large_font->FontSize + spacing; + + ImGui::PushFont(g_large_font); + ImGui::RenderTextClipped( + title_bb.Min, title_bb.Max, text.c_str(), text.c_str() + text.length(), nullptr, ImVec2(0.0f, 0.0f), &title_bb); + ImGui::PopFont(); + + const ImRect summary_bb(ImVec2(left, top), ImVec2(right, top + g_medium_font->FontSize)); + if (unlocked_count == achievement_count) + { + text = fmt::format("You have unlocked all achievements and earned {} points!", total_points); + } + else + { + text = fmt::format("You have unlocked {} of {} achievements, earning {} of {} possible points.", unlocked_count, + achievement_count, current_points, total_points); + } + + top += g_medium_font->FontSize + spacing; + + ImGui::PushFont(g_medium_font); + ImGui::RenderTextClipped( + summary_bb.Min, summary_bb.Max, text.c_str(), text.c_str() + text.length(), nullptr, ImVec2(0.0f, 0.0f), &summary_bb); + ImGui::PopFont(); + + const float progress_height = LayoutScale(20.0f); + const ImRect progress_bb(ImVec2(left, top), ImVec2(right, top + progress_height)); + const float fraction = static_cast(unlocked_count) / static_cast(achievement_count); + dl->AddRectFilled(progress_bb.Min, progress_bb.Max, ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryDarkColor)); + dl->AddRectFilled(progress_bb.Min, ImVec2(progress_bb.Min.x + fraction * progress_bb.GetWidth(), progress_bb.Max.y), + ImGui::GetColorU32(ImGuiFullscreen::UISecondaryColor)); + + text = fmt::format("{}%", static_cast(std::round(fraction * 100.0f))); + text_size = ImGui::CalcTextSize(text.c_str()); + const ImVec2 text_pos(progress_bb.Min.x + ((progress_bb.Max.x - progress_bb.Min.x) / 2.0f) - (text_size.x / 2.0f), + progress_bb.Min.y + ((progress_bb.Max.y - progress_bb.Min.y) / 2.0f) - (text_size.y / 2.0f)); + dl->AddText(g_medium_font, g_medium_font->FontSize, text_pos, ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryTextColor), + text.c_str(), text.c_str() + text.length()); + top += progress_height + spacing; + } + } + EndFullscreenWindow(); + + ImGui::SetNextWindowBgAlpha(alpha); + + if (BeginFullscreenWindow(ImVec2(0.0f, heading_height), ImVec2(display_size.x, display_size.y - heading_height), "achievements", + background, 0.0f, 0.0f, 0)) + { + BeginMenuButtons(); + + static bool unlocked_achievements_collapsed = false; + + unlocked_achievements_collapsed ^= + MenuHeadingButton("Unlocked Achievements", unlocked_achievements_collapsed ? ICON_FA_CHEVRON_DOWN : ICON_FA_CHEVRON_UP); + if (!unlocked_achievements_collapsed) + { + Achievements::EnumerateAchievements([](const Achievements::Achievement& cheevo) -> bool { + if (!cheevo.locked) + DrawAchievement(cheevo); + + return true; + }); + } + + if (Achievements::GetUnlockedAchiementCount() != Achievements::GetAchievementCount()) + { + static bool locked_achievements_collapsed = false; + locked_achievements_collapsed ^= + MenuHeadingButton("Locked Achievements", locked_achievements_collapsed ? ICON_FA_CHEVRON_DOWN : ICON_FA_CHEVRON_UP); + if (!locked_achievements_collapsed) + { + Achievements::EnumerateAchievements([](const Achievements::Achievement& cheevo) -> bool { + if (cheevo.locked) + DrawAchievement(cheevo); + + return true; + }); + } + } + + EndMenuButtons(); + } + EndFullscreenWindow(); +} + +void FullscreenUI::DrawPrimedAchievements() +{ + const ImVec2 image_size(LayoutScale(LAYOUT_MENU_BUTTON_HEIGHT, LAYOUT_MENU_BUTTON_HEIGHT)); + const float spacing = LayoutScale(10.0f); + const float padding = LayoutScale(10.0f); + + const ImGuiIO& io = ImGui::GetIO(); + const float x_advance = image_size.x + spacing; + ImVec2 position(io.DisplaySize.x - padding - image_size.x, io.DisplaySize.y - padding - image_size.y); + + auto lock = Achievements::GetLock(); + Achievements::EnumerateAchievements([&image_size, &x_advance, &position](const Achievements::Achievement& achievement) { + if (!achievement.primed) + return true; + + const std::string& badge_path = Achievements::GetAchievementBadgePath(achievement); + if (badge_path.empty()) + return true; + + HostDisplayTexture* badge = GetCachedTextureAsync(badge_path.c_str()); + if (!badge) + return true; + + ImDrawList* dl = ImGui::GetBackgroundDrawList(); + dl->AddImage(badge->GetHandle(), position, position + image_size); + position.x -= x_advance; + return true; + }); +} + +bool FullscreenUI::OpenLeaderboardsWindow() +{ + if (!VMManager::HasValidVM() || !Initialize()) + return false; + + if (!Achievements::HasActiveGame() || Achievements::GetLeaderboardCount() == 0) + { + ShowToast(std::string(), "This game has no leaderboards."); + return false; + } + + if (s_current_main_window != MainWindowType::PauseMenu) + PauseForMenuOpen(); + + s_current_main_window = MainWindowType::Leaderboards; + s_open_leaderboard_id.reset(); + QueueResetFocus(); + return true; +} + + +void FullscreenUI::DrawLeaderboardListEntry(const Achievements::Leaderboard& lboard) +{ + static constexpr float alpha = 0.8f; + + std::string id_str(fmt::format("lb_{}", lboard.id)); + + ImRect bb; + bool visible, hovered; + bool pressed = MenuButtonFrame(id_str.c_str(), true, LAYOUT_MENU_BUTTON_HEIGHT, &visible, &hovered, &bb.Min, &bb.Max, 0, alpha); + if (!visible) + return; + + const float midpoint = bb.Min.y + g_large_font->FontSize + LayoutScale(4.0f); + const float text_start_x = bb.Min.x + LayoutScale(15.0f); + const ImRect title_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint)); + const ImRect summary_bb(ImVec2(text_start_x, midpoint), bb.Max); + + ImGui::PushFont(g_large_font); + ImGui::RenderTextClipped(title_bb.Min, title_bb.Max, lboard.title.c_str(), lboard.title.c_str() + lboard.title.size(), nullptr, + ImVec2(0.0f, 0.0f), &title_bb); + ImGui::PopFont(); + + if (!lboard.description.empty()) + { + ImGui::PushFont(g_medium_font); + ImGui::RenderTextClipped(summary_bb.Min, summary_bb.Max, lboard.description.c_str(), + lboard.description.c_str() + lboard.description.size(), nullptr, ImVec2(0.0f, 0.0f), &summary_bb); + ImGui::PopFont(); + } + + if (pressed) + { + s_open_leaderboard_id = lboard.id; + } +} + +void FullscreenUI::DrawLeaderboardEntry( + const Achievements::LeaderboardEntry& lbEntry, float rank_column_width, float name_column_width, float column_spacing) +{ + static constexpr float alpha = 0.8f; + + ImRect bb; + bool visible, hovered; + bool pressed = + MenuButtonFrame(lbEntry.user.c_str(), true, LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY, &visible, &hovered, &bb.Min, &bb.Max, 0, alpha); + if (!visible) + return; + + const float midpoint = bb.Min.y + g_large_font->FontSize + LayoutScale(4.0f); + float text_start_x = bb.Min.x + LayoutScale(15.0f); + std::string rank_str(fmt::format("{}", lbEntry.rank)); + + ImGui::PushFont(g_large_font); + if (lbEntry.is_self) + { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(255, 242, 0, 255)); + } + + const ImRect rank_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint)); + ImGui::RenderTextClipped(rank_bb.Min, rank_bb.Max, rank_str.c_str(), nullptr, nullptr, ImVec2(0.0f, 0.0f), &rank_bb); + text_start_x += rank_column_width + column_spacing; + + const ImRect user_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint)); + ImGui::RenderTextClipped( + user_bb.Min, user_bb.Max, lbEntry.user.c_str(), lbEntry.user.c_str() + lbEntry.user.size(), nullptr, ImVec2(0.0f, 0.0f), &user_bb); + text_start_x += name_column_width + column_spacing; + + const ImRect score_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint)); + ImGui::RenderTextClipped(score_bb.Min, score_bb.Max, lbEntry.formatted_score.c_str(), + lbEntry.formatted_score.c_str() + lbEntry.formatted_score.size(), nullptr, ImVec2(0.0f, 0.0f), &score_bb); + + if (lbEntry.is_self) + { + ImGui::PopStyleColor(); + } + + ImGui::PopFont(); + + // This API DOES list the submission date/time, but is it relevant? +#if 0 + if (!cheevo.locked) + { + ImGui::PushFont(g_medium_font); + + const ImRect time_bb(ImVec2(text_start_x, bb.Min.y), + ImVec2(bb.Max.x, bb.Min.y + g_medium_font->FontSize + LayoutScale(4.0f))); + text.Format("Unlocked 21 Feb, 2019 @ 3:14am"); + ImGui::RenderTextClipped(time_bb.Min, time_bb.Max, text.GetCharArray(), text.GetCharArray() + text.GetLength(), + nullptr, ImVec2(1.0f, 0.0f), &time_bb); + ImGui::PopFont(); + } +#endif + + if (pressed) + { + // Anything? + } +} + +void FullscreenUI::DrawLeaderboardsWindow() +{ + static constexpr float alpha = 0.8f; + static constexpr float heading_height_unscaled = 110.0f; + + // ensure image downloads still happen while we're paused + Achievements::ProcessPendingHTTPRequestsFromGSThread(); + + ImGui::SetNextWindowBgAlpha(alpha); + + const bool is_leaderboard_open = s_open_leaderboard_id.has_value(); + bool close_leaderboard_on_exit = false; + + const ImVec4 background(0.13f, 0.13f, 0.13f, alpha); + const ImVec2 display_size(ImGui::GetIO().DisplaySize); + const float padding = LayoutScale(10.0f); + const float spacing = LayoutScale(10.0f); + const float spacing_small = spacing / 2.0f; + float heading_height = LayoutScale(heading_height_unscaled); + if (is_leaderboard_open) + { + // Add space for a legend - spacing + 1 line of text + spacing + line + heading_height += spacing + LayoutScale(LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY) + spacing; + } + + const float rank_column_width = + g_large_font->CalcTextSizeA(g_large_font->FontSize, std::numeric_limits::max(), -1.0f, "99999").x; + const float name_column_width = + g_large_font->CalcTextSizeA(g_large_font->FontSize, std::numeric_limits::max(), -1.0f, "WWWWWWWWWWWWWWWWWWWW").x; + const float column_spacing = spacing * 2.0f; + + if (BeginFullscreenWindow(ImVec2(0.0f, 0.0f), ImVec2(display_size.x, heading_height), "leaderboards_heading", background, 0.0f, 0.0f, + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoScrollWithMouse)) + { + ImRect bb; + bool visible, hovered; + /*bool pressed = */ + MenuButtonFrame("leaderboards_heading", false, heading_height_unscaled, &visible, &hovered, &bb.Min, &bb.Max, 0, alpha); + + if (visible) + { + const float image_height = LayoutScale(85.0f); + + const ImVec2 icon_min(bb.Min + ImVec2(padding, padding)); + const ImVec2 icon_max(icon_min + ImVec2(image_height, image_height)); + + const std::string& icon_path = Achievements::GetGameIcon(); + if (!icon_path.empty()) + { + HostDisplayTexture* badge = GetCachedTexture(icon_path.c_str()); + if (badge) + { + ImGui::GetWindowDrawList()->AddImage( + badge->GetHandle(), icon_min, icon_max, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255)); + } + } + + float left = bb.Min.x + padding + image_height + spacing; + float right = bb.Max.x - padding; + float top = bb.Min.y + padding; + ImVec2 text_size; + + const u32 leaderboard_count = Achievements::GetLeaderboardCount(); + + if (!is_leaderboard_open) + { + if (FloatingButton(ICON_FA_WINDOW_CLOSE, 10.0f, 10.0f, -1.0f, -1.0f, 1.0f, 0.0f, true, g_large_font) || WantsToCloseMenu()) + { + ReturnToMainWindow(); + } + } + else + { + if (FloatingButton(ICON_FA_CARET_SQUARE_LEFT, 10.0f, 10.0f, -1.0f, -1.0f, 1.0f, 0.0f, true, g_large_font) || + WantsToCloseMenu()) + { + close_leaderboard_on_exit = true; + } + } + + const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + g_large_font->FontSize)); + const std::string& title = Achievements::GetGameTitle(); + + top += g_large_font->FontSize + spacing; + + ImGui::PushFont(g_large_font); + ImGui::RenderTextClipped(title_bb.Min, title_bb.Max, title.c_str(), nullptr, nullptr, ImVec2(0.0f, 0.0f), &title_bb); + ImGui::PopFont(); + + std::string lb_description; + if (s_open_leaderboard_id.has_value()) + { + const Achievements::Leaderboard* lboard = Achievements::GetLeaderboardByID(s_open_leaderboard_id.value()); + if (lboard != nullptr) + { + const ImRect subtitle_bb(ImVec2(left, top), ImVec2(right, top + g_large_font->FontSize)); + const std::string& subtitle = lboard->title; + + top += g_large_font->FontSize + spacing_small; + + ImGui::PushFont(g_large_font); + ImGui::RenderTextClipped( + subtitle_bb.Min, subtitle_bb.Max, subtitle.c_str(), nullptr, nullptr, ImVec2(0.0f, 0.0f), &subtitle_bb); + ImGui::PopFont(); + + lb_description = lboard->description; + } + } + else + { + lb_description = fmt::format("This game has {} leaderboards.", leaderboard_count); + } + + const ImRect summary_bb(ImVec2(left, top), ImVec2(right, top + g_medium_font->FontSize)); + top += g_medium_font->FontSize + spacing_small; + + ImGui::PushFont(g_medium_font); + ImGui::RenderTextClipped( + summary_bb.Min, summary_bb.Max, lb_description.c_str(), nullptr, nullptr, ImVec2(0.0f, 0.0f), &summary_bb); + + if (!Achievements::ChallengeModeActive()) + { + const ImRect hardcore_warning_bb(ImVec2(left, top), ImVec2(right, top + g_medium_font->FontSize)); + top += g_medium_font->FontSize + spacing_small; + + ImGui::RenderTextClipped(hardcore_warning_bb.Min, hardcore_warning_bb.Max, + "Submitting scores is disabled because hardcore mode is off. Leaderboards are read-only.", nullptr, nullptr, + ImVec2(0.0f, 0.0f), &hardcore_warning_bb); + } + + ImGui::PopFont(); + } + + if (is_leaderboard_open) + { + /*bool pressed = */ + MenuButtonFrame("legend", false, LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY, &visible, &hovered, &bb.Min, &bb.Max, 0, alpha); + + if (visible) + { + const Achievements::Leaderboard* lboard = Achievements::GetLeaderboardByID(s_open_leaderboard_id.value()); + + const float midpoint = bb.Min.y + g_large_font->FontSize + LayoutScale(4.0f); + float text_start_x = bb.Min.x + LayoutScale(15.0f) + padding; + + ImGui::PushFont(g_large_font); + + const ImRect rank_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint)); + ImGui::RenderTextClipped(rank_bb.Min, rank_bb.Max, "Rank", nullptr, nullptr, ImVec2(0.0f, 0.0f), &rank_bb); + text_start_x += rank_column_width + column_spacing; + + const ImRect user_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint)); + ImGui::RenderTextClipped(user_bb.Min, user_bb.Max, "Name", nullptr, nullptr, ImVec2(0.0f, 0.0f), &user_bb); + text_start_x += name_column_width + column_spacing; + + const ImRect score_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint)); + ImGui::RenderTextClipped(score_bb.Min, score_bb.Max, + lboard != nullptr && Achievements::IsLeaderboardTimeType(*lboard) ? "Time" : "Score", nullptr, nullptr, + ImVec2(0.0f, 0.0f), &score_bb); + + ImGui::PopFont(); + + const float line_thickness = LayoutScale(1.0f); + const float line_padding = LayoutScale(5.0f); + const ImVec2 line_start(bb.Min.x, bb.Min.y + g_large_font->FontSize + line_padding); + const ImVec2 line_end(bb.Max.x, line_start.y); + ImGui::GetWindowDrawList()->AddLine(line_start, line_end, ImGui::GetColorU32(ImGuiCol_TextDisabled), line_thickness); + } + } + } + EndFullscreenWindow(); + + ImGui::SetNextWindowBgAlpha(alpha); + + if (!is_leaderboard_open) + { + if (BeginFullscreenWindow(ImVec2(0.0f, heading_height), ImVec2(display_size.x, display_size.y - heading_height), "leaderboards", + background, 0.0f, 0.0f, 0)) + { + BeginMenuButtons(); + + Achievements::EnumerateLeaderboards([](const Achievements::Leaderboard& lboard) -> bool { + DrawLeaderboardListEntry(lboard); + + return true; + }); + + EndMenuButtons(); + } + EndFullscreenWindow(); + } + else + { + if (BeginFullscreenWindow(ImVec2(0.0f, heading_height), ImVec2(display_size.x, display_size.y - heading_height), "leaderboard", + background, 0.0f, 0.0f, 0)) + { + BeginMenuButtons(); + + const auto result = Achievements::TryEnumerateLeaderboardEntries(s_open_leaderboard_id.value(), + [rank_column_width, name_column_width, column_spacing](const Achievements::LeaderboardEntry& lbEntry) -> bool { + DrawLeaderboardEntry(lbEntry, rank_column_width, name_column_width, column_spacing); + return true; + }); + + if (!result.has_value()) + { + ImGui::PushFont(g_large_font); + + const ImVec2 pos_min(0.0f, heading_height); + const ImVec2 pos_max(display_size.x, display_size.y); + ImGui::RenderTextClipped( + pos_min, pos_max, "Downloading leaderboard data, please wait...", nullptr, nullptr, ImVec2(0.5f, 0.5f)); + + ImGui::PopFont(); + } + + EndMenuButtons(); + } + EndFullscreenWindow(); + } + + if (close_leaderboard_on_exit) + s_open_leaderboard_id.reset(); +} + +void FullscreenUI::DrawAchievementsSettingsPage() +{ +#ifdef ENABLE_RAINTEGRATION + if (Achievements::IsUsingRAIntegration()) + { + BeginMenuButtons(); + ActiveButton(ICON_FA_BAN " RAIntegration is being used instead of the built-in achievements implementation.", false, false, + LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); + EndMenuButtons(); + return; + } +#endif + + const auto lock = Achievements::GetLock(); + if (Achievements::IsActive()) + Achievements::ProcessPendingHTTPRequestsFromGSThread(); + + SettingsInterface* bsi = GetEditingSettingsInterface(); + + BeginMenuButtons(); + + MenuHeading("Settings"); + DrawToggleSetting(bsi, ICON_FA_TROPHY " Enable Achievements", + "When enabled and logged in, PCSX2 will scan for achievements on startup.", "Achievements", "Enabled", false); + + const bool enabled = bsi->GetBoolValue("Achievements", "Enabled", false); + const bool challenge = bsi->GetBoolValue("Achievements", "ChallengeMode", false); + + DrawToggleSetting(bsi, ICON_FA_USER_FRIENDS " Rich Presence", + "When enabled, rich presence information will be collected and sent to the server where supported.", "Achievements", "RichPresence", + true, enabled); + if (DrawToggleSetting(bsi, ICON_FA_HARD_HAT " Hardcore Mode", + "\"Challenge\" mode for achievements, including leaderboard tracking. Disables save state, cheats, and slowdown functions.", + "Achievements", "ChallengeMode", false, enabled)) + { + if (VMManager::HasValidVM() && bsi->GetBoolValue("Achievements", "ChallengeMode", false)) + ShowToast(std::string(), "Hardcore mode will be enabled on next game restart."); + } + DrawToggleSetting(bsi, ICON_FA_LIST_OL " Leaderboards", "Enables tracking and submission of leaderboards in supported games.", + "Achievements", "Leaderboards", true, enabled && challenge); + DrawToggleSetting(bsi, ICON_FA_HEADPHONES " Sound Effects", + "Plays sound effects for events such as achievement unlocks and leaderboard submissions.", "Achievements", "SoundEffects", true, + enabled); + DrawToggleSetting(bsi, ICON_FA_MEDAL " Test Unofficial Achievements", + "When enabled, PCSX2 will list achievements from unofficial sets. These achievements are not tracked by RetroAchievements.", + "Achievements", "UnofficialTestMode", false, enabled); + DrawToggleSetting(bsi, ICON_FA_STETHOSCOPE " Test Mode", + "When enabled, PCSX2 will assume all achievements are locked and not send any unlock notifications to the server.", "Achievements", + "TestMode", false, enabled); + + MenuHeading("Account"); + if (Achievements::IsLoggedIn()) + { + ImGui::PushStyleColor(ImGuiCol_TextDisabled, ImGui::GetStyle().Colors[ImGuiCol_Text]); + ActiveButton(fmt::format(ICON_FA_USER " Username: {}", Achievements::GetUsername()).c_str(), false, false, + LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); + + const u64 ts = StringUtil::FromChars(bsi->GetStringValue("Achievements", "LoginTimestamp", "0")).value_or(0); + ActiveButton(fmt::format(ICON_FA_CLOCK " Login token generated on {}", TimeToPrintableString(static_cast(ts))).c_str(), + false, false, LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); + ImGui::PopStyleColor(); + + if (MenuButton(ICON_FA_KEY " Logout", "Logs out of RetroAchievements.")) + { + Host::RunOnCPUThread([]() { Achievements::Logout(); }); + } + } + else if (Achievements::IsActive()) + { + ActiveButton(ICON_FA_USER " Not Logged In", false, false, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); + + if (MenuButton(ICON_FA_KEY " Login", "Logs in to RetroAchievements.")) + ImGui::OpenPopup("Achievements Login"); + + DrawAchievementsLoginWindow(); + } + else + { + ActiveButton(ICON_FA_USER " Achievements are disabled.", false, false, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); + } + + MenuHeading("Current Game"); + if (Achievements::HasActiveGame()) + { + ImGui::PushStyleColor(ImGuiCol_TextDisabled, ImGui::GetStyle().Colors[ImGuiCol_Text]); + ActiveButton(fmt::format(ICON_FA_BOOKMARK " Game ID: {}", Achievements::GetGameID()).c_str(), false, false, + LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); + ActiveButton(fmt::format(ICON_FA_BOOK " Game Title: {}", Achievements::GetGameTitle()).c_str(), false, false, + LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); + ActiveButton(fmt::format(ICON_FA_TROPHY " Achievements: {} ({} points)", Achievements::GetAchievementCount(), + Achievements::GetMaximumPointsForGame()) + .c_str(), + false, false, LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); + + const std::string& rich_presence_string = Achievements::GetRichPresenceString(); + if (!rich_presence_string.empty()) + { + ActiveButton(fmt::format(ICON_FA_MAP " {}", rich_presence_string).c_str(), false, false, LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); + } + else + { + ActiveButton(ICON_FA_MAP " Rich presence inactive or unsupported.", false, false, LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); + } + + ImGui::PopStyleColor(); + } + else + { + ActiveButton( + ICON_FA_BAN " Game not loaded or no RetroAchievements available.", false, false, LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); + } + + EndMenuButtons(); +} + +void FullscreenUI::DrawAchievementsLoginWindow() +{ + ImGui::SetNextWindowSize(LayoutScale(700.0f, 0.0f)); + ImGui::SetNextWindowPos(ImGui::GetIO().DisplaySize * 0.5f, ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, LayoutScale(10.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, LayoutScale(20.0f, 20.0f)); + ImGui::PushFont(g_large_font); + + bool is_open = true; + if (ImGui::BeginPopupModal("Achievements Login", &is_open, ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize)) + { + + ImGui::TextWrapped("Please enter your user name and password for retroachievements.org."); + ImGui::NewLine(); + ImGui::TextWrapped("Your password will not be saved in PCSX2, an access token will be generated and used instead."); + + ImGui::NewLine(); + + static char username[256] = {}; + static char password[256] = {}; + + ImGui::Text("User Name: "); + ImGui::SameLine(LayoutScale(200.0f)); + ImGui::InputText("##username", username, sizeof(username)); + + ImGui::Text("Password: "); + ImGui::SameLine(LayoutScale(200.0f)); + ImGui::InputText("##password", password, sizeof(password), ImGuiInputTextFlags_Password); + + ImGui::NewLine(); + + BeginMenuButtons(); + + const bool login_enabled = (std::strlen(username) > 0 && std::strlen(password) > 0); + + if (ActiveButton(ICON_FA_KEY " Login", false, login_enabled)) + { + Achievements::LoginAsync(username, password); + std::memset(username, 0, sizeof(username)); + std::memset(password, 0, sizeof(password)); + ImGui::CloseCurrentPopup(); + } + + if (ActiveButton(ICON_FA_TIMES " Cancel", false)) + { + std::memset(username, 0, sizeof(username)); + std::memset(password, 0, sizeof(password)); + ImGui::CloseCurrentPopup(); + } + + EndMenuButtons(); + + ImGui::EndPopup(); + } + + ImGui::PopFont(); + ImGui::PopStyleVar(2); +} + +#else + +bool FullscreenUI::OpenAchievementsWindow() +{ + return false; +} + +bool FullscreenUI::OpenLeaderboardsWindow() +{ + return false; +} + +void FullscreenUI::DrawAchievementsSettingsPage() +{ + BeginMenuButtons(); + ActiveButton(ICON_FA_BAN " This build was not compiled with RetroAchivements support.", false, false, + ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY); + EndMenuButtons(); +} + +void FullscreenUI::DrawAchievementsLoginWindow() +{ +} + +#endif diff --git a/pcsx2/Frontend/FullscreenUI.h b/pcsx2/Frontend/FullscreenUI.h index 3503eaf280..097e93748e 100644 --- a/pcsx2/Frontend/FullscreenUI.h +++ b/pcsx2/Frontend/FullscreenUI.h @@ -21,17 +21,22 @@ class HostDisplayTexture; +struct Pcsx2Config; + namespace FullscreenUI { bool Initialize(); bool IsInitialized(); bool HasActiveWindow(); + void CheckForConfigChanges(const Pcsx2Config& old_config); void OnVMStarted(); void OnVMPaused(); void OnVMResumed(); void OnVMDestroyed(); void OnRunningGameChanged(std::string path, std::string serial, std::string title, u32 crc); void OpenPauseMenu(); + bool OpenAchievementsWindow(); + bool OpenLeaderboardsWindow(); void Shutdown(); void Render(); diff --git a/pcsx2/Frontend/ImGuiManager.cpp b/pcsx2/Frontend/ImGuiManager.cpp index fc6160c650..9c471664a8 100644 --- a/pcsx2/Frontend/ImGuiManager.cpp +++ b/pcsx2/Frontend/ImGuiManager.cpp @@ -418,7 +418,7 @@ ImFont* ImGuiManager::AddFixedFont(float size) bool ImGuiManager::AddIconFonts(float size) { - static constexpr ImWchar range_fa[] = { 0xf001,0xf002,0xf005,0xf005,0xf00c,0xf00e,0xf011,0xf011,0xf013,0xf013,0xf017,0xf017,0xf019,0xf019,0xf021,0xf021,0xf025,0xf025,0xf028,0xf028,0xf030,0xf030,0xf03a,0xf03a,0xf03d,0xf03d,0xf04a,0xf04c,0xf04e,0xf04e,0xf050,0xf050,0xf052,0xf052,0xf059,0xf059,0xf05e,0xf05e,0xf065,0xf065,0xf067,0xf067,0xf06a,0xf06a,0xf071,0xf071,0xf07b,0xf07c,0xf085,0xf085,0xf091,0xf091,0xf0a0,0xf0a0,0xf0ac,0xf0ad,0xf0b0,0xf0b0,0xf0c5,0xf0c5,0xf0c7,0xf0c9,0xf0d0,0xf0d0,0xf0e2,0xf0e2,0xf0eb,0xf0eb,0xf0f3,0xf0f3,0xf0fe,0xf0fe,0xf110,0xf110,0xf119,0xf119,0xf11b,0xf11c,0xf121,0xf121,0xf133,0xf133,0xf140,0xf140,0xf144,0xf144,0xf14a,0xf14a,0xf15b,0xf15b,0xf188,0xf188,0xf192,0xf192,0xf1c9,0xf1c9,0xf1dd,0xf1de,0xf1e6,0xf1e6,0xf1ea,0xf1eb,0xf1f8,0xf1f8,0xf1fc,0xf1fc,0xf242,0xf242,0xf245,0xf245,0xf26c,0xf26c,0xf2d0,0xf2d0,0xf2db,0xf2db,0xf2f5,0xf2f5,0xf302,0xf302,0xf3fd,0xf3fd,0xf410,0xf410,0xf466,0xf466,0xf479,0xf479,0xf517,0xf517,0xf51f,0xf51f,0xf543,0xf543,0xf545,0xf545,0xf547,0xf548,0xf552,0xf552,0xf5aa,0xf5aa,0xf65d,0xf65e,0xf6a9,0xf6a9,0xf756,0xf756,0xf7c2,0xf7c2,0xf815,0xf815,0xf818,0xf818,0xf84c,0xf84c,0xf8cc,0xf8cc,0x0,0x0 }; + static constexpr ImWchar range_fa[] = { 0xf001,0xf002,0xf005,0xf005,0xf007,0xf007,0xf00c,0xf00e,0xf011,0xf011,0xf013,0xf013,0xf017,0xf017,0xf019,0xf019,0xf021,0xf021,0xf023,0xf023,0xf025,0xf025,0xf028,0xf028,0xf02d,0xf02e,0xf030,0xf030,0xf03a,0xf03a,0xf03d,0xf03d,0xf04a,0xf04c,0xf04e,0xf04e,0xf050,0xf050,0xf052,0xf052,0xf059,0xf059,0xf05e,0xf05e,0xf065,0xf065,0xf067,0xf067,0xf06a,0xf06a,0xf071,0xf071,0xf077,0xf078,0xf07b,0xf07c,0xf084,0xf085,0xf091,0xf091,0xf0a0,0xf0a0,0xf0ac,0xf0ad,0xf0b0,0xf0b0,0xf0c5,0xf0c5,0xf0c7,0xf0c9,0xf0cb,0xf0cb,0xf0d0,0xf0d0,0xf0e2,0xf0e2,0xf0eb,0xf0eb,0xf0f1,0xf0f1,0xf0f3,0xf0f3,0xf0fe,0xf0fe,0xf110,0xf110,0xf119,0xf119,0xf11b,0xf11c,0xf121,0xf121,0xf133,0xf133,0xf140,0xf140,0xf144,0xf144,0xf14a,0xf14a,0xf15b,0xf15b,0xf188,0xf188,0xf191,0xf192,0xf1c9,0xf1c9,0xf1dd,0xf1de,0xf1e6,0xf1e6,0xf1ea,0xf1eb,0xf1f8,0xf1f8,0xf1fc,0xf1fc,0xf242,0xf242,0xf245,0xf245,0xf26c,0xf26c,0xf279,0xf279,0xf2d0,0xf2d0,0xf2db,0xf2db,0xf2f2,0xf2f2,0xf2f5,0xf2f5,0xf302,0xf302,0xf3c1,0xf3c1,0xf3fd,0xf3fd,0xf410,0xf410,0xf466,0xf466,0xf479,0xf479,0xf500,0xf500,0xf517,0xf517,0xf51f,0xf51f,0xf543,0xf543,0xf545,0xf545,0xf547,0xf548,0xf552,0xf552,0xf5a2,0xf5a2,0xf65d,0xf65e,0xf6a9,0xf6a9,0xf756,0xf756,0xf7c2,0xf7c2,0xf807,0xf807,0xf815,0xf815,0xf818,0xf818,0xf84c,0xf84c,0xf8cc,0xf8cc,0x0,0x0 }; ImFontConfig cfg; cfg.MergeMode = true; diff --git a/pcsx2/Pcsx2Config.cpp b/pcsx2/Pcsx2Config.cpp index d8df96b62b..d2bb073dac 100644 --- a/pcsx2/Pcsx2Config.cpp +++ b/pcsx2/Pcsx2Config.cpp @@ -1016,6 +1016,34 @@ void Pcsx2Config::FramerateOptions::LoadSave(SettingsWrapper& wrap) SettingsWrapEntry(SlomoScalar); } +#ifdef ENABLE_ACHIEVEMENTS + +Pcsx2Config::AchievementsOptions::AchievementsOptions() +{ + Enabled = false; + TestMode = false; + UnofficialTestMode = false; + RichPresence = true; + ChallengeMode = false; + Leaderboards = true; + SoundEffects = true; +} + +void Pcsx2Config::AchievementsOptions::LoadSave(SettingsWrapper& wrap) +{ + SettingsWrapSection("Achievements"); + + SettingsWrapBitBool(Enabled); + SettingsWrapBitBool(TestMode); + SettingsWrapBitBool(UnofficialTestMode); + SettingsWrapBitBool(RichPresence); + SettingsWrapBitBool(ChallengeMode); + SettingsWrapBitBool(Leaderboards); + SettingsWrapBitBool(SoundEffects); +} + +#endif + Pcsx2Config::Pcsx2Config() { bitset = 0; @@ -1102,6 +1130,10 @@ void Pcsx2Config::LoadSave(SettingsWrapper& wrap) Debugger.LoadSave(wrap); Trace.LoadSave(wrap); +#ifdef ENABLE_ACHIEVEMENTS + Achievements.LoadSave(wrap); +#endif + SettingsWrapEntry(GzipIsoIndexTemplate); // For now, this in the derived config for backwards ini compatibility. diff --git a/pcsx2/SaveState.cpp b/pcsx2/SaveState.cpp index a2d8f93e15..3b49e21bca 100644 --- a/pcsx2/SaveState.cpp +++ b/pcsx2/SaveState.cpp @@ -51,6 +51,10 @@ #include "VMManager.h" #endif +#ifdef ENABLE_ACHIEVEMENTS +#include "Frontend/Achievements.h" +#endif + #include "fmt/core.h" #include @@ -429,6 +433,9 @@ static void SysState_ComponentFreezeOutRoot(void* dest, SysState_Component comp) static void SysState_ComponentFreezeIn(zip_file_t* zf, SysState_Component comp) { + if (!zf) + return; + freezeData fP = { 0, nullptr }; if (comp.freeze(FreezeAction::Size, &fP) != 0) fP.size = 0; @@ -653,7 +660,44 @@ public: bool IsRequired() const { return true; } }; +#ifdef ENABLE_ACHIEVEMENTS +class SaveStateEntry_Achievements : public BaseSavestateEntry +{ + virtual ~SaveStateEntry_Achievements() override = default; + const char* GetFilename() const override { return "Achievements.bin"; } + void FreezeIn(zip_file_t* zf) const override + { + if (!Achievements::IsActive()) + return; + + std::optional> data; + if (zf) + data = ReadBinaryFileInZip(zf); + + if (data.has_value() && !data->empty()) + Achievements::LoadState(data->data(), data->size()); + else + Achievements::LoadState(nullptr, 0); + } + + void FreezeOut(SaveStateBase& writer) const override + { + if (!Achievements::IsActive()) + return; + + std::vector data(Achievements::SaveState()); + if (!data.empty()) + { + writer.PrepBlock(static_cast(data.size())); + std::memcpy(writer.GetBlockPtr(), data.data(), data.size()); + writer.CommitBlock(static_cast(data.size())); + } + } + + bool IsRequired() const override { return false; } +}; +#endif // (cpuRegs, iopRegs, VPU/GIF/DMAC structures should all remain as part of a larger unified // block, since they're all PCSX2-dependent and having separate files in the archie for them @@ -676,6 +720,9 @@ static const std::unique_ptr SavestateEntries[] = { #endif std::unique_ptr(new SavestateEntry_PAD), std::unique_ptr(new SavestateEntry_GS), +#ifdef ENABLE_ACHIEVEMENTS + std::unique_ptr(new SaveStateEntry_Achievements), +#endif }; std::unique_ptr SaveState_DownloadState() @@ -1000,7 +1047,7 @@ static void CheckVersion(const std::string& filename, zip_t* zf) .SetUserMsg("Cannot load this savestate. The state is an unsupported version."); } -static zip_int64_t CheckFileExistsInState(zip_t* zf, const char* name) +static zip_int64_t CheckFileExistsInState(zip_t* zf, const char* name, bool required) { zip_int64_t index = zip_name_locate(zf, name, /*ZIP_FL_NOCASE*/ 0); if (index >= 0) @@ -1009,7 +1056,11 @@ static zip_int64_t CheckFileExistsInState(zip_t* zf, const char* name) return index; } - Console.WriteLn(Color_Red, " ... not found '%s'!", name); + if (required) + Console.WriteLn(Color_Red, " ... not found '%s'!", name); + else + DevCon.WriteLn(Color_Red, " ... not found '%s'!", name); + return index; } @@ -1048,15 +1099,16 @@ void SaveState_UnzipFromDisk(const std::string& filename) CheckVersion(filename, zf.get()); // check that all parts are included - const s64 internal_index = CheckFileExistsInState(zf.get(), EntryFilename_InternalStructures); + const s64 internal_index = CheckFileExistsInState(zf.get(), EntryFilename_InternalStructures, true); s64 entryIndices[std::size(SavestateEntries)]; // Log any parts and pieces that are missing, and then generate an exception. bool throwIt = (internal_index < 0); for (u32 i = 0; i < std::size(SavestateEntries); i++) { - entryIndices[i] = CheckFileExistsInState(zf.get(), SavestateEntries[i]->GetFilename()); - if (entryIndices[i] < 0 && SavestateEntries[i]->IsRequired()) + const bool required = SavestateEntries[i]->IsRequired(); + entryIndices[i] = CheckFileExistsInState(zf.get(), SavestateEntries[i]->GetFilename(), required); + if (entryIndices[i] < 0 && required) throwIt = true; } @@ -1071,7 +1123,10 @@ void SaveState_UnzipFromDisk(const std::string& filename) for (u32 i = 0; i < std::size(SavestateEntries); ++i) { if (entryIndices[i] < 0) + { + SavestateEntries[i]->FreezeIn(nullptr); continue; + } auto zff = zip_fopen_index_managed(zf.get(), entryIndices[i], 0); if (!zff) diff --git a/pcsx2/VMManager.cpp b/pcsx2/VMManager.cpp index dcaa39669c..5ca604ff15 100644 --- a/pcsx2/VMManager.cpp +++ b/pcsx2/VMManager.cpp @@ -30,6 +30,7 @@ #include "common/Threading.h" #include "fmt/core.h" +#include "Achievements.h" #include "Counters.h" #include "CDVD/CDVD.h" #include "DEV9/DEV9.h" @@ -80,6 +81,7 @@ namespace VMManager static void CheckForSPU2ConfigChanges(const Pcsx2Config& old_config); static void CheckForDEV9ConfigChanges(const Pcsx2Config& old_config); static void CheckForMemoryCardConfigChanges(const Pcsx2Config& old_config); + static void EnforceAchievementsChallengeModeSettings(); static void WarnAboutUnsafeSettings(); static bool AutoDetectSource(const std::string& filename); @@ -183,7 +185,8 @@ void VMManager::SetState(VMState state) if (state != VMState::Stopping && (state == VMState::Paused || old_state == VMState::Paused)) { - if (state == VMState::Paused) + const bool paused = (state == VMState::Paused); + if (paused) { if (THREAD_VU1) vu1Thread.WaitVU(); @@ -312,6 +315,9 @@ void VMManager::LoadSettings() PAD::LoadConfig(*si); Host::LoadSettings(*si, lock); + // Achievements hardcore mode disallows setting some configuration options. + EnforceAchievementsChallengeModeSettings(); + // Remove any user-specified hacks in the config (we don't want stale/conflicting values when it's globally disabled). EmuConfig.GS.MaskUserHacks(); EmuConfig.GS.MaskUpscalingHacks(); @@ -532,7 +538,7 @@ void VMManager::LoadPatches(const std::string& serial, u32 crc, bool show_messag // wide screen patches if (EmuConfig.EnableWideScreenPatches && crc != 0) { - if (s_active_widescreen_patches = LoadPatchesFromDir(crc_string, EmuFolders::CheatsWS, "Widescreen hacks", false)) + if (!Achievements::ChallengeModeActive() && (s_active_widescreen_patches = LoadPatchesFromDir(crc_string, EmuFolders::CheatsWS, "Widescreen hacks", false) > 0)) { Console.WriteLn(Color_Gray, "Found widescreen patches in the cheats_ws folder --> skipping cheats_ws.zip"); } @@ -574,7 +580,7 @@ void VMManager::LoadPatches(const std::string& serial, u32 crc, bool show_messag // no-interlacing patches if (EmuConfig.EnableNoInterlacingPatches && crc != 0) { - if (s_active_no_interlacing_patches = LoadPatchesFromDir(crc_string, EmuFolders::CheatsNI, "No-interlacing patches", false)) + if (!Achievements::ChallengeModeActive() && (s_active_no_interlacing_patches = LoadPatchesFromDir(crc_string, EmuFolders::CheatsNI, "No-interlacing patches", false)) > 0) { Console.WriteLn(Color_Gray, "Found no-interlacing patches in the cheats_ni folder --> skipping cheats_ni.zip"); } @@ -781,6 +787,16 @@ bool VMManager::ApplyBootParameters(VMBootParameters params, std::string* state_ } } +#ifdef ENABLE_ACHIEVEMENTS + // Check for resuming with hardcore mode. + Achievements::ResetChallengeMode(); + if (!state_to_load->empty() && Achievements::ChallengeModeActive() && + !Achievements::ConfirmChallengeModeDisable("Resuming state")) + { + return false; + } +#endif + // resolve source type if (params.source_type.has_value()) { @@ -1085,6 +1101,11 @@ void VMManager::Shutdown(bool save_resume_state) void VMManager::Reset() { +#ifdef ENABLE_ACHIEVEMENTS + if (!Achievements::OnReset()) + return; +#endif + const bool game_was_started = g_GameStarted; s_active_game_fixes = 0; @@ -1256,6 +1277,14 @@ void VMManager::WaitForSaveStateFlush() bool VMManager::LoadState(const char* filename) { +#ifdef ENABLE_ACHIEVEMENTS + if (Achievements::ChallengeModeActive() && + !Achievements::ConfirmChallengeModeDisable("Loading state")) + { + return false; + } +#endif + // TODO: Save the current state so we don't need to reset. if (DoLoadState(filename)) return true; @@ -1273,6 +1302,14 @@ bool VMManager::LoadStateFromSlot(s32 slot) return false; } +#ifdef ENABLE_ACHIEVEMENTS + if (Achievements::ChallengeModeActive() && + !Achievements::ConfirmChallengeModeDisable("Loading state")) + { + return false; + } +#endif + Host::AddIconOSDMessage("LoadStateFromSlot", ICON_FA_FOLDER_OPEN, fmt::format("Loading state from slot {}...", slot), 5.0f); return DoLoadState(filename.c_str()); } @@ -1313,6 +1350,11 @@ void VMManager::FrameAdvance(u32 num_frames /*= 1*/) if (!HasValidVM()) return; +#ifdef ENABLE_ACHIEVEMENTS + if (Achievements::ChallengeModeActive() && !Achievements::ConfirmChallengeModeDisable("Frame advancing")) + return; +#endif + s_frame_advance_count = num_frames; SetState(VMState::Running); } @@ -1708,6 +1750,41 @@ void VMManager::SetDefaultSettings(SettingsInterface& si) SetHardwareDependentDefaultSettings(si); } +void VMManager::EnforceAchievementsChallengeModeSettings() +{ + if (!Achievements::ChallengeModeActive()) + return; + + static constexpr auto ClampSpeed = [](float& rate) { + if (rate > 0.0f && rate < 1.0f) + rate = 1.0f; + }; + + // Can't use slow motion. + ClampSpeed(EmuConfig.Framerate.NominalScalar); + ClampSpeed(EmuConfig.Framerate.TurboScalar); + ClampSpeed(EmuConfig.Framerate.SlomoScalar); + + // Can't use cheats. + if (EmuConfig.EnableCheats) + { + Host::AddKeyedOSDMessage("ChallengeDisableCheats", "Cheats have been disabled due to achievements hardcore mode.", 10.0f); + EmuConfig.EnableCheats = false; + } + + // Input recording/playback is probably an issue. + EmuConfig.EnableRecordingTools = false; + EmuConfig.EnablePINE = false; + + // Framerates should be at default. + EmuConfig.GS.FramerateNTSC = Pcsx2Config::GSOptions::DEFAULT_FRAME_RATE_NTSC; + EmuConfig.GS.FrameratePAL = Pcsx2Config::GSOptions::DEFAULT_FRAME_RATE_PAL; + + // You can overclock, but not underclock (since that might slow down the game and make it easier). + EmuConfig.Speedhacks.EECycleRate = std::max(EmuConfig.Speedhacks.EECycleRate, 0); + EmuConfig.Speedhacks.EECycleSkip = 0; +} + void VMManager::WarnAboutUnsafeSettings() { std::string messages; diff --git a/pcsx2/pcsx2core.vcxproj b/pcsx2/pcsx2core.vcxproj index 7b797b3d99..83c886cab2 100644 --- a/pcsx2/pcsx2core.vcxproj +++ b/pcsx2/pcsx2core.vcxproj @@ -50,13 +50,14 @@ %(AdditionalIncludeDirectories);$(SolutionDir)3rdparty\zstd\zstd\lib %(AdditionalIncludeDirectories);$(SolutionDir)3rdparty\cpuinfo\include %(AdditionalIncludeDirectories);$(SolutionDir)3rdparty\rapidyaml\rapidyaml\ext\c4core\src\c4\ext\fast_float\include + %(AdditionalIncludeDirectories);$(SolutionDir)3rdparty\rcheevos\rcheevos\include;$(SolutionDir)3rdparty\rainterface Async Use PrecompiledHeader.h PrecompiledHeader.h;%(ForcedIncludeFiles) NoExtensions /Zc:externConstexpr %(AdditionalOptions) - WIN32_LEAN_AND_MEAN;ZIP_STATIC;LZMA_API_STATIC;BUILD_DX=1;ENABLE_OPENGL;ENABLE_VULKAN;SPU2X_CUBEB;SDL_BUILD;PCSX2_CORE;%(PreprocessorDefinitions) + WIN32_LEAN_AND_MEAN;ZIP_STATIC;LZMA_API_STATIC;BUILD_DX=1;ENABLE_RAINTEGRATION;ENABLE_ACHIEVEMENTS;ENABLE_OPENGL;ENABLE_VULKAN;SPU2X_CUBEB;SDL_BUILD;PCSX2_CORE;%(PreprocessorDefinitions) PCSX2_DEBUG;PCSX2_DEVBUILD;_SECURE_SCL_=1;%(PreprocessorDefinitions) PCSX2_DEVEL;PCSX2_DEVBUILD;NDEBUG;_SECURE_SCL_=1;%(PreprocessorDefinitions) NDEBUG;_SECURE_SCL_=0;%(PreprocessorDefinitions) @@ -205,6 +206,7 @@ + @@ -453,6 +455,7 @@ + @@ -528,6 +531,7 @@ + @@ -807,6 +811,12 @@ {7e183337-a7e9-460c-9d3d-568bc9f9bcc1} + + {95dd0a0c-d14d-4cff-a593-820ef26efcc8} + + + {6d5b5ad9-1525-459b-939f-a5e1082af6b3} + {812b4434-fd6b-4cb2-8865-5fd8eb34b046} diff --git a/pcsx2/pcsx2core.vcxproj.filters b/pcsx2/pcsx2core.vcxproj.filters index 059170d014..7fc1681f68 100644 --- a/pcsx2/pcsx2core.vcxproj.filters +++ b/pcsx2/pcsx2core.vcxproj.filters @@ -1287,6 +1287,9 @@ Host + + Host + @@ -2140,6 +2143,10 @@ Host + + Host + +