duckstation

duckstation, but archived from the revision just before upstream changed it to a proprietary software project, this version is the libre one
git clone https://git.neptards.moe/u3shit/duckstation.git
Log | Files | Refs | README | LICENSE

achievements.cpp (122585B)


      1 // SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin <stenzek@gmail.com>
      2 // SPDX-License-Identifier: (GPL-3.0 OR PolyForm-Strict-1.0.0)
      3 
      4 // TODO: Don't poll when booting the game, e.g. Crash Warped freaks out.
      5 
      6 #include "achievements.h"
      7 #include "achievements_private.h"
      8 #include "bios.h"
      9 #include "bus.h"
     10 #include "cpu_core.h"
     11 #include "fullscreen_ui.h"
     12 #include "host.h"
     13 #include "system.h"
     14 
     15 #include "scmversion/scmversion.h"
     16 
     17 #include "common/assert.h"
     18 #include "common/error.h"
     19 #include "common/file_system.h"
     20 #include "common/heap_array.h"
     21 #include "common/log.h"
     22 #include "common/md5_digest.h"
     23 #include "common/path.h"
     24 #include "common/scoped_guard.h"
     25 #include "common/small_string.h"
     26 #include "common/string_util.h"
     27 #include "common/timer.h"
     28 
     29 #include "util/cd_image.h"
     30 #include "util/http_downloader.h"
     31 #include "util/imgui_fullscreen.h"
     32 #include "util/imgui_manager.h"
     33 #include "util/platform_misc.h"
     34 #include "util/state_wrapper.h"
     35 
     36 #include "IconsEmoji.h"
     37 #include "IconsFontAwesome5.h"
     38 #include "IconsPromptFont.h"
     39 #include "fmt/format.h"
     40 #include "imgui.h"
     41 #include "imgui_internal.h"
     42 #include "rc_api_runtime.h"
     43 #include "rc_client.h"
     44 
     45 #include <algorithm>
     46 #include <atomic>
     47 #include <cstdarg>
     48 #include <cstdlib>
     49 #include <ctime>
     50 #include <functional>
     51 #include <string>
     52 #include <unordered_set>
     53 #include <vector>
     54 
     55 Log_SetChannel(Achievements);
     56 
     57 #ifdef ENABLE_RAINTEGRATION
     58 // RA_Interface ends up including windows.h, with its silly macros.
     59 #ifdef _WIN32
     60 #include "common/windows_headers.h"
     61 #endif
     62 #include "RA_Interface.h"
     63 #endif
     64 namespace Achievements {
     65 
     66 static constexpr const char* INFO_SOUND_NAME = "sounds/achievements/message.wav";
     67 static constexpr const char* UNLOCK_SOUND_NAME = "sounds/achievements/unlock.wav";
     68 static constexpr const char* LBSUBMIT_SOUND_NAME = "sounds/achievements/lbsubmit.wav";
     69 static constexpr const char* ACHEIVEMENT_DETAILS_URL_TEMPLATE = "https://retroachievements.org/achievement/{}";
     70 static constexpr const char* CACHE_SUBDIRECTORY_NAME = "achievement_images";
     71 
     72 static constexpr u32 LEADERBOARD_NEARBY_ENTRIES_TO_FETCH = 10;
     73 static constexpr u32 LEADERBOARD_ALL_FETCH_SIZE = 20;
     74 
     75 static constexpr float LOGIN_NOTIFICATION_TIME = 5.0f;
     76 static constexpr float ACHIEVEMENT_SUMMARY_NOTIFICATION_TIME = 5.0f;
     77 static constexpr float GAME_COMPLETE_NOTIFICATION_TIME = 20.0f;
     78 static constexpr float LEADERBOARD_STARTED_NOTIFICATION_TIME = 3.0f;
     79 static constexpr float LEADERBOARD_FAILED_NOTIFICATION_TIME = 3.0f;
     80 
     81 static constexpr float INDICATOR_FADE_IN_TIME = 0.1f;
     82 static constexpr float INDICATOR_FADE_OUT_TIME = 0.5f;
     83 
     84 // Some API calls are really slow. Set a longer timeout.
     85 static constexpr float SERVER_CALL_TIMEOUT = 60.0f;
     86 
     87 // Chrome uses 10 server calls per domain, seems reasonable.
     88 static constexpr u32 MAX_CONCURRENT_SERVER_CALLS = 10;
     89 
     90 namespace {
     91 struct LoginWithPasswordParameters
     92 {
     93   const char* username;
     94   Error* error;
     95   rc_client_async_handle_t* request;
     96   bool result;
     97 };
     98 struct LeaderboardTrackerIndicator
     99 {
    100   u32 tracker_id;
    101   std::string text;
    102   Common::Timer show_hide_time;
    103   bool active;
    104 };
    105 
    106 struct AchievementChallengeIndicator
    107 {
    108   const rc_client_achievement_t* achievement;
    109   std::string badge_path;
    110   Common::Timer show_hide_time;
    111   bool active;
    112 };
    113 
    114 struct AchievementProgressIndicator
    115 {
    116   const rc_client_achievement_t* achievement;
    117   std::string badge_path;
    118   Common::Timer show_hide_time;
    119   bool active;
    120 };
    121 } // namespace
    122 
    123 static void ReportError(std::string_view sv);
    124 template<typename... T>
    125 static void ReportFmtError(fmt::format_string<T...> fmt, T&&... args);
    126 template<typename... T>
    127 static void ReportRCError(int err, fmt::format_string<T...> fmt, T&&... args);
    128 static void ClearGameInfo();
    129 static void ClearGameHash();
    130 static std::string GetGameHash(CDImage* image);
    131 static void SetHardcoreMode(bool enabled, bool force_display_message);
    132 static bool IsLoggedInOrLoggingIn();
    133 static bool CanEnableHardcoreMode();
    134 static void ShowLoginSuccess(const rc_client_t* client);
    135 static void ShowLoginNotification();
    136 static void IdentifyGame(const std::string& path, CDImage* image);
    137 static void BeginLoadGame();
    138 static void BeginChangeDisc();
    139 static void UpdateGameSummary();
    140 static std::string GetLocalImagePath(const std::string_view image_name, int type);
    141 static void DownloadImage(std::string url, std::string cache_filename);
    142 static void UpdateGlyphRanges();
    143 
    144 static bool CreateClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http);
    145 static void DestroyClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http);
    146 static void ClientMessageCallback(const char* message, const rc_client_t* client);
    147 static uint32_t ClientReadMemory(uint32_t address, uint8_t* buffer, uint32_t num_bytes, rc_client_t* client);
    148 static void ClientServerCall(const rc_api_request_t* request, rc_client_server_callback_t callback, void* callback_data,
    149                              rc_client_t* client);
    150 
    151 static void ClientEventHandler(const rc_client_event_t* event, rc_client_t* client);
    152 static void HandleResetEvent(const rc_client_event_t* event);
    153 static void HandleUnlockEvent(const rc_client_event_t* event);
    154 static void HandleGameCompleteEvent(const rc_client_event_t* event);
    155 static void HandleLeaderboardStartedEvent(const rc_client_event_t* event);
    156 static void HandleLeaderboardFailedEvent(const rc_client_event_t* event);
    157 static void HandleLeaderboardSubmittedEvent(const rc_client_event_t* event);
    158 static void HandleLeaderboardScoreboardEvent(const rc_client_event_t* event);
    159 static void HandleLeaderboardTrackerShowEvent(const rc_client_event_t* event);
    160 static void HandleLeaderboardTrackerHideEvent(const rc_client_event_t* event);
    161 static void HandleLeaderboardTrackerUpdateEvent(const rc_client_event_t* event);
    162 static void HandleAchievementChallengeIndicatorShowEvent(const rc_client_event_t* event);
    163 static void HandleAchievementChallengeIndicatorHideEvent(const rc_client_event_t* event);
    164 static void HandleAchievementProgressIndicatorShowEvent(const rc_client_event_t* event);
    165 static void HandleAchievementProgressIndicatorHideEvent(const rc_client_event_t* event);
    166 static void HandleAchievementProgressIndicatorUpdateEvent(const rc_client_event_t* event);
    167 static void HandleServerErrorEvent(const rc_client_event_t* event);
    168 static void HandleServerDisconnectedEvent(const rc_client_event_t* event);
    169 static void HandleServerReconnectedEvent(const rc_client_event_t* event);
    170 
    171 static void ClientLoginWithTokenCallback(int result, const char* error_message, rc_client_t* client, void* userdata);
    172 static void ClientLoginWithPasswordCallback(int result, const char* error_message, rc_client_t* client, void* userdata);
    173 static void ClientLoadGameCallback(int result, const char* error_message, rc_client_t* client, void* userdata);
    174 
    175 static void DisplayHardcoreDeferredMessage();
    176 static void DisplayAchievementSummary();
    177 static void UpdateRichPresence(std::unique_lock<std::recursive_mutex>& lock);
    178 
    179 static void LeaderboardFetchNearbyCallback(int result, const char* error_message,
    180                                            rc_client_leaderboard_entry_list_t* list, rc_client_t* client,
    181                                            void* callback_userdata);
    182 static void LeaderboardFetchAllCallback(int result, const char* error_message, rc_client_leaderboard_entry_list_t* list,
    183                                         rc_client_t* client, void* callback_userdata);
    184 
    185 #ifndef __ANDROID__
    186 static void DrawAchievement(const rc_client_achievement_t* cheevo);
    187 static void DrawLeaderboardListEntry(const rc_client_leaderboard_t* lboard);
    188 static void DrawLeaderboardEntry(const rc_client_leaderboard_entry_t& entry, bool is_self, float rank_column_width,
    189                                  float name_column_width, float time_column_width, float column_spacing);
    190 #endif
    191 
    192 static bool s_hardcore_mode = false;
    193 
    194 #ifdef ENABLE_RAINTEGRATION
    195 static bool s_using_raintegration = false;
    196 #endif
    197 
    198 static std::recursive_mutex s_achievements_mutex;
    199 static rc_client_t* s_client;
    200 static std::unique_ptr<HTTPDownloader> s_http_downloader;
    201 
    202 static std::string s_game_path;
    203 static std::string s_game_hash;
    204 static std::string s_game_title;
    205 static std::string s_game_icon;
    206 static rc_client_user_game_summary_t s_game_summary;
    207 static u32 s_game_id = 0;
    208 static DynamicHeapArray<u8> s_state_buffer;
    209 
    210 static bool s_has_achievements = false;
    211 static bool s_has_leaderboards = false;
    212 static bool s_has_rich_presence = false;
    213 static std::string s_rich_presence_string;
    214 static Common::Timer s_rich_presence_poll_time;
    215 
    216 static rc_client_async_handle_t* s_login_request;
    217 static rc_client_async_handle_t* s_load_game_request;
    218 
    219 static rc_client_achievement_list_t* s_achievement_list;
    220 static rc_client_leaderboard_list_t* s_leaderboard_list;
    221 static std::vector<std::pair<const void*, std::string>> s_achievement_badge_paths;
    222 static const rc_client_leaderboard_t* s_open_leaderboard = nullptr;
    223 static rc_client_async_handle_t* s_leaderboard_fetch_handle = nullptr;
    224 static std::vector<rc_client_leaderboard_entry_list_t*> s_leaderboard_entry_lists;
    225 static rc_client_leaderboard_entry_list_t* s_leaderboard_nearby_entries;
    226 static std::vector<std::pair<const rc_client_leaderboard_entry_t*, std::string>> s_leaderboard_user_icon_paths;
    227 static bool s_is_showing_all_leaderboard_entries = false;
    228 
    229 static std::vector<LeaderboardTrackerIndicator> s_active_leaderboard_trackers;
    230 static std::vector<AchievementChallengeIndicator> s_active_challenge_indicators;
    231 static std::optional<AchievementProgressIndicator> s_active_progress_indicator;
    232 } // namespace Achievements
    233 
    234 std::unique_lock<std::recursive_mutex> Achievements::GetLock()
    235 {
    236   return std::unique_lock(s_achievements_mutex);
    237 }
    238 
    239 rc_client_t* Achievements::GetClient()
    240 {
    241   return s_client;
    242 }
    243 
    244 const rc_client_user_game_summary_t& Achievements::GetGameSummary()
    245 {
    246   return s_game_summary;
    247 }
    248 
    249 void Achievements::ReportError(std::string_view sv)
    250 {
    251   std::string error = fmt::format("Achievements error: {}", sv);
    252   ERROR_LOG(error.c_str());
    253   Host::AddOSDMessage(std::move(error), Host::OSD_CRITICAL_ERROR_DURATION);
    254 }
    255 
    256 template<typename... T>
    257 void Achievements::ReportFmtError(fmt::format_string<T...> fmt, T&&... args)
    258 {
    259   TinyString str;
    260   fmt::vformat_to(std::back_inserter(str), fmt, fmt::make_format_args(args...));
    261   ReportError(str);
    262 }
    263 
    264 template<typename... T>
    265 void Achievements::ReportRCError(int err, fmt::format_string<T...> fmt, T&&... args)
    266 {
    267   TinyString str;
    268   fmt::vformat_to(std::back_inserter(str), fmt, fmt::make_format_args(args...));
    269   str.append_format("{} ({})", rc_error_str(err), err);
    270   ReportError(str);
    271 }
    272 
    273 std::string Achievements::GetGameHash(CDImage* image)
    274 {
    275   std::string executable_name;
    276   std::vector<u8> executable_data;
    277   if (!System::ReadExecutableFromImage(image, &executable_name, &executable_data))
    278     return {};
    279 
    280   BIOS::PSEXEHeader header = {};
    281   if (executable_data.size() >= sizeof(header))
    282     std::memcpy(&header, executable_data.data(), sizeof(header));
    283   if (!BIOS::IsValidPSExeHeader(header, executable_data.size()))
    284   {
    285     ERROR_LOG("PS-EXE header is invalid in '{}' ({} bytes)", executable_name, executable_data.size());
    286     return {};
    287   }
    288 
    289   // See rcheevos hash.c - rc_hash_psx().
    290   const u32 MAX_HASH_SIZE = 64 * 1024 * 1024;
    291   const u32 hash_size =
    292     std::min(std::min<u32>(sizeof(header) + header.file_size, MAX_HASH_SIZE), static_cast<u32>(executable_data.size()));
    293 
    294   MD5Digest digest;
    295   digest.Update(executable_name.c_str(), static_cast<u32>(executable_name.size()));
    296   if (hash_size > 0)
    297     digest.Update(executable_data);
    298 
    299   u8 hash[16];
    300   digest.Final(hash);
    301 
    302   const std::string hash_str =
    303     fmt::format("{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
    304                 hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7], hash[8], hash[9], hash[10],
    305                 hash[11], hash[12], hash[13], hash[14], hash[15]);
    306 
    307   INFO_LOG("Hash for '{}' ({} bytes, {} bytes hashed): {}", executable_name, executable_data.size(), hash_size,
    308            hash_str);
    309   return hash_str;
    310 }
    311 
    312 std::string Achievements::GetLocalImagePath(const std::string_view image_name, int type)
    313 {
    314   std::string_view prefix;
    315   std::string_view suffix;
    316   switch (type)
    317   {
    318     case RC_IMAGE_TYPE_GAME:
    319       prefix = "image"; // https://media.retroachievements.org/Images/{}.png
    320       break;
    321 
    322     case RC_IMAGE_TYPE_USER:
    323       prefix = "user"; // https://media.retroachievements.org/UserPic/{}.png
    324       break;
    325 
    326     case RC_IMAGE_TYPE_ACHIEVEMENT: // https://media.retroachievements.org/Badge/{}.png
    327       prefix = "badge";
    328       break;
    329 
    330     case RC_IMAGE_TYPE_ACHIEVEMENT_LOCKED:
    331       prefix = "badge";
    332       suffix = "_lock";
    333       break;
    334 
    335     default:
    336       prefix = "badge";
    337       break;
    338   }
    339 
    340   std::string ret;
    341   if (!image_name.empty())
    342   {
    343     ret = fmt::format("{}" FS_OSPATH_SEPARATOR_STR "{}" FS_OSPATH_SEPARATOR_STR "{}_{}{}.png", EmuFolders::Cache,
    344                       CACHE_SUBDIRECTORY_NAME, prefix, Path::SanitizeFileName(image_name), suffix);
    345   }
    346 
    347   return ret;
    348 }
    349 
    350 void Achievements::DownloadImage(std::string url, std::string cache_filename)
    351 {
    352   auto callback = [cache_filename](s32 status_code, const std::string& content_type,
    353                                    HTTPDownloader::Request::Data data) {
    354     if (status_code != HTTPDownloader::HTTP_STATUS_OK)
    355       return;
    356 
    357     if (!FileSystem::WriteBinaryFile(cache_filename.c_str(), data.data(), data.size()))
    358     {
    359       ERROR_LOG("Failed to write badge image to '{}'", cache_filename);
    360       return;
    361     }
    362 
    363     ImGuiFullscreen::InvalidateCachedTexture(cache_filename);
    364   };
    365 
    366   s_http_downloader->CreateRequest(std::move(url), std::move(callback));
    367 }
    368 
    369 void Achievements::UpdateGlyphRanges()
    370 {
    371   // To avoid rasterizing all emoji fonts, we get the set of used glyphs in the emoji range for all strings in the
    372   // current game's achievement data.
    373   using CodepointSet = std::unordered_set<ImGuiManager::WCharType>;
    374   CodepointSet codepoints;
    375 
    376   static constexpr auto add_string = [](const std::string_view str, CodepointSet& codepoints) {
    377     char32_t codepoint;
    378     for (size_t offset = 0; offset < str.length();)
    379     {
    380       offset += StringUtil::DecodeUTF8(str, offset, &codepoint);
    381 
    382       // Basic Latin + Latin Supplement always included.
    383       if (codepoint != StringUtil::UNICODE_REPLACEMENT_CHARACTER && codepoint >= 0x2000)
    384         codepoints.insert(static_cast<ImGuiManager::WCharType>(codepoint));
    385     }
    386   };
    387 
    388   if (rc_client_has_rich_presence(s_client))
    389   {
    390     std::vector<const char*> rp_strings;
    391     for (;;)
    392     {
    393       rp_strings.resize(std::max<size_t>(rp_strings.size() * 2, 512));
    394 
    395       size_t count;
    396       const int err = rc_client_get_rich_presence_strings(s_client, rp_strings.data(), rp_strings.size(), &count);
    397       if (err == RC_INSUFFICIENT_BUFFER)
    398         continue;
    399       else if (err != RC_OK)
    400         rp_strings.clear();
    401       else
    402         rp_strings.resize(count);
    403 
    404       break;
    405     }
    406 
    407     for (const char* str : rp_strings)
    408       add_string(str, codepoints);
    409   }
    410 
    411   if (rc_client_has_achievements(s_client))
    412   {
    413     rc_client_achievement_list_t* const achievements =
    414       rc_client_create_achievement_list(s_client, RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL, 0);
    415     if (achievements)
    416     {
    417       for (u32 i = 0; i < achievements->num_buckets; i++)
    418       {
    419         const rc_client_achievement_bucket_t& bucket = achievements->buckets[i];
    420         for (u32 j = 0; j < bucket.num_achievements; j++)
    421         {
    422           const rc_client_achievement_t* achievement = bucket.achievements[j];
    423           if (achievement->title)
    424             add_string(achievement->title, codepoints);
    425           if (achievement->description)
    426             add_string(achievement->description, codepoints);
    427         }
    428       }
    429       rc_client_destroy_achievement_list(achievements);
    430     }
    431   }
    432 
    433   if (rc_client_has_leaderboards(s_client))
    434   {
    435     rc_client_leaderboard_list_t* const leaderboards =
    436       rc_client_create_leaderboard_list(s_client, RC_CLIENT_LEADERBOARD_LIST_GROUPING_NONE);
    437     if (leaderboards)
    438     {
    439       for (u32 i = 0; i < leaderboards->num_buckets; i++)
    440       {
    441         const rc_client_leaderboard_bucket_t& bucket = leaderboards->buckets[i];
    442         for (u32 j = 0; j < bucket.num_leaderboards; j++)
    443         {
    444           const rc_client_leaderboard_t* leaderboard = bucket.leaderboards[j];
    445           if (leaderboard->title)
    446             add_string(leaderboard->title, codepoints);
    447           if (leaderboard->description)
    448             add_string(leaderboard->description, codepoints);
    449         }
    450       }
    451       rc_client_destroy_leaderboard_list(leaderboards);
    452     }
    453   }
    454 
    455   std::vector<ImGuiManager::WCharType> sorted_codepoints;
    456   sorted_codepoints.reserve(codepoints.size());
    457   sorted_codepoints.insert(sorted_codepoints.begin(), codepoints.begin(), codepoints.end());
    458   std::sort(sorted_codepoints.begin(), sorted_codepoints.end());
    459 
    460   // Compact codepoints to ranges.
    461   ImGuiManager::SetEmojiFontRange(ImGuiManager::CompactFontRange(sorted_codepoints));
    462 }
    463 
    464 bool Achievements::IsActive()
    465 {
    466 #ifdef ENABLE_RAINTEGRATION
    467   return (s_client != nullptr) || s_using_raintegration;
    468 #else
    469   return (s_client != nullptr);
    470 #endif
    471 }
    472 
    473 bool Achievements::IsHardcoreModeActive()
    474 {
    475 #ifdef ENABLE_RAINTEGRATION
    476   if (IsUsingRAIntegration())
    477     return RA_HardcoreModeIsActive() != 0;
    478 #endif
    479 
    480   return s_hardcore_mode;
    481 }
    482 
    483 bool Achievements::HasActiveGame()
    484 {
    485   return s_game_id != 0;
    486 }
    487 
    488 u32 Achievements::GetGameID()
    489 {
    490   return s_game_id;
    491 }
    492 
    493 bool Achievements::HasAchievementsOrLeaderboards()
    494 {
    495   return s_has_achievements || s_has_leaderboards;
    496 }
    497 
    498 bool Achievements::HasAchievements()
    499 {
    500   return s_has_achievements;
    501 }
    502 
    503 bool Achievements::HasLeaderboards()
    504 {
    505   return s_has_leaderboards;
    506 }
    507 
    508 bool Achievements::HasRichPresence()
    509 {
    510   return s_has_rich_presence;
    511 }
    512 
    513 const std::string& Achievements::GetGameTitle()
    514 {
    515   return s_game_title;
    516 }
    517 
    518 const std::string& Achievements::GetGameIconPath()
    519 {
    520   return s_game_icon;
    521 }
    522 
    523 const std::string& Achievements::GetRichPresenceString()
    524 {
    525   return s_rich_presence_string;
    526 }
    527 
    528 bool Achievements::Initialize()
    529 {
    530   if (IsUsingRAIntegration())
    531     return true;
    532 
    533   auto lock = GetLock();
    534   AssertMsg(g_settings.achievements_enabled, "Achievements are enabled");
    535   Assert(!s_client && !s_http_downloader);
    536 
    537   if (!CreateClient(&s_client, &s_http_downloader))
    538     return false;
    539 
    540   // Hardcore starts off. We enable it on first boot.
    541   s_hardcore_mode = false;
    542 
    543   rc_client_set_event_handler(s_client, ClientEventHandler);
    544 
    545   rc_client_set_hardcore_enabled(s_client, s_hardcore_mode);
    546   rc_client_set_encore_mode_enabled(s_client, g_settings.achievements_encore_mode);
    547   rc_client_set_unofficial_enabled(s_client, g_settings.achievements_unofficial_test_mode);
    548   rc_client_set_spectator_mode_enabled(s_client, g_settings.achievements_spectator_mode);
    549 
    550   // Begin disc identification early, before the login finishes.
    551   if (System::IsValid())
    552     IdentifyGame(System::GetDiscPath(), nullptr);
    553 
    554   std::string username = Host::GetBaseStringSettingValue("Cheevos", "Username");
    555   std::string api_token = Host::GetBaseStringSettingValue("Cheevos", "Token");
    556   if (!username.empty() && !api_token.empty())
    557   {
    558     INFO_LOG("Attempting login with user '{}'...", username);
    559     s_login_request = rc_client_begin_login_with_token(s_client, username.c_str(), api_token.c_str(),
    560                                                        ClientLoginWithTokenCallback, nullptr);
    561   }
    562 
    563   // Hardcore mode isn't enabled when achievements first starts, if a game is already running.
    564   if (System::IsValid() && IsLoggedInOrLoggingIn() && g_settings.achievements_hardcore_mode)
    565     DisplayHardcoreDeferredMessage();
    566 
    567   return true;
    568 }
    569 
    570 bool Achievements::CreateClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http)
    571 {
    572   *http = HTTPDownloader::Create(Host::GetHTTPUserAgent());
    573   if (!*http)
    574   {
    575     Host::ReportErrorAsync("Achievements Error", "Failed to create HTTPDownloader, cannot use achievements");
    576     return false;
    577   }
    578 
    579   (*http)->SetTimeout(SERVER_CALL_TIMEOUT);
    580   (*http)->SetMaxActiveRequests(MAX_CONCURRENT_SERVER_CALLS);
    581 
    582   rc_client_t* new_client = rc_client_create(ClientReadMemory, ClientServerCall);
    583   if (!new_client)
    584   {
    585     Host::ReportErrorAsync("Achievements Error", "rc_client_create() failed, cannot use achievements");
    586     http->reset();
    587     return false;
    588   }
    589 
    590 #ifdef _DEBUG
    591   rc_client_enable_logging(new_client, RC_CLIENT_LOG_LEVEL_VERBOSE, ClientMessageCallback);
    592 #else
    593   rc_client_enable_logging(new_client, RC_CLIENT_LOG_LEVEL_INFO, ClientMessageCallback);
    594 #endif
    595 
    596   rc_client_set_userdata(new_client, http->get());
    597 
    598   *client = new_client;
    599   return true;
    600 }
    601 
    602 void Achievements::DestroyClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http)
    603 {
    604   (*http)->WaitForAllRequests();
    605 
    606   rc_client_destroy(*client);
    607   *client = nullptr;
    608 
    609   http->reset();
    610 }
    611 
    612 void Achievements::UpdateSettings(const Settings& old_config)
    613 {
    614   if (IsUsingRAIntegration())
    615     return;
    616 
    617   if (!g_settings.achievements_enabled)
    618   {
    619     // we're done here
    620     Shutdown(false);
    621     return;
    622   }
    623 
    624   if (!IsActive())
    625   {
    626     // we just got enabled
    627     Initialize();
    628     return;
    629   }
    630 
    631   if (g_settings.achievements_hardcore_mode != old_config.achievements_hardcore_mode)
    632   {
    633     // Hardcore mode can only be enabled through reset (ResetChallengeMode()).
    634     if (s_hardcore_mode && !g_settings.achievements_hardcore_mode)
    635     {
    636       ResetHardcoreMode(false);
    637     }
    638     else if (!s_hardcore_mode && g_settings.achievements_hardcore_mode)
    639     {
    640       if (HasActiveGame())
    641         DisplayHardcoreDeferredMessage();
    642     }
    643   }
    644 
    645   // These cannot be modified while a game is loaded, so just toss state and reload.
    646   if (HasActiveGame())
    647   {
    648     if (g_settings.achievements_encore_mode != old_config.achievements_encore_mode ||
    649         g_settings.achievements_spectator_mode != old_config.achievements_spectator_mode ||
    650         g_settings.achievements_unofficial_test_mode != old_config.achievements_unofficial_test_mode)
    651     {
    652       Shutdown(false);
    653       Initialize();
    654       return;
    655     }
    656   }
    657   else
    658   {
    659     if (g_settings.achievements_encore_mode != old_config.achievements_encore_mode)
    660       rc_client_set_encore_mode_enabled(s_client, g_settings.achievements_encore_mode);
    661     if (g_settings.achievements_spectator_mode != old_config.achievements_spectator_mode)
    662       rc_client_set_spectator_mode_enabled(s_client, g_settings.achievements_spectator_mode);
    663     if (g_settings.achievements_unofficial_test_mode != old_config.achievements_unofficial_test_mode)
    664       rc_client_set_unofficial_enabled(s_client, g_settings.achievements_unofficial_test_mode);
    665   }
    666 }
    667 
    668 bool Achievements::Shutdown(bool allow_cancel)
    669 {
    670 #ifdef ENABLE_RAINTEGRATION
    671   if (IsUsingRAIntegration())
    672   {
    673     if (System::IsValid() && allow_cancel && !RA_ConfirmLoadNewRom(true))
    674       return false;
    675 
    676     RA_SetPaused(false);
    677     RA_ActivateGame(0);
    678     return true;
    679   }
    680 #endif
    681 
    682   if (!IsActive())
    683     return true;
    684 
    685   auto lock = GetLock();
    686   Assert(s_client && s_http_downloader);
    687 
    688   ClearGameInfo();
    689   ClearGameHash();
    690   DisableHardcoreMode();
    691   UpdateGlyphRanges();
    692 
    693   if (s_load_game_request)
    694   {
    695     rc_client_abort_async(s_client, s_load_game_request);
    696     s_load_game_request = nullptr;
    697   }
    698   if (s_login_request)
    699   {
    700     rc_client_abort_async(s_client, s_login_request);
    701     s_login_request = nullptr;
    702   }
    703 
    704   s_hardcore_mode = false;
    705   DestroyClient(&s_client, &s_http_downloader);
    706 
    707   Host::OnAchievementsRefreshed();
    708   return true;
    709 }
    710 
    711 void Achievements::ClientMessageCallback(const char* message, const rc_client_t* client)
    712 {
    713   DEV_LOG(message);
    714 }
    715 
    716 uint32_t Achievements::ClientReadMemory(uint32_t address, uint8_t* buffer, uint32_t num_bytes, rc_client_t* client)
    717 {
    718   if ((address + num_bytes) > 0x200400U) [[unlikely]]
    719     return 0;
    720 
    721   const u8* src = (address >= 0x200000U) ? CPU::g_state.scratchpad.data() : Bus::g_ram;
    722   const u32 offset = (address & Bus::RAM_2MB_MASK); // size guarded by check above
    723 
    724   switch (num_bytes)
    725   {
    726     case 1:
    727       std::memcpy(buffer, &src[offset], 1);
    728       break;
    729     case 2:
    730       std::memcpy(buffer, &src[offset], 2);
    731       break;
    732     case 4:
    733       std::memcpy(buffer, &src[offset], 4);
    734       break;
    735     default:
    736       [[unlikely]] std::memcpy(buffer, &src[offset], num_bytes);
    737       break;
    738   }
    739 
    740   return num_bytes;
    741 }
    742 
    743 void Achievements::ClientServerCall(const rc_api_request_t* request, rc_client_server_callback_t callback,
    744                                     void* callback_data, rc_client_t* client)
    745 {
    746   HTTPDownloader::Request::Callback hd_callback =
    747     [callback, callback_data](s32 status_code, const std::string& content_type, HTTPDownloader::Request::Data data) {
    748       rc_api_server_response_t rr;
    749       rr.http_status_code = (status_code <= 0) ? (status_code == HTTPDownloader::HTTP_STATUS_CANCELLED ?
    750                                                     RC_API_SERVER_RESPONSE_CLIENT_ERROR :
    751                                                     RC_API_SERVER_RESPONSE_RETRYABLE_CLIENT_ERROR) :
    752                                                  status_code;
    753       rr.body_length = data.size();
    754       rr.body = reinterpret_cast<const char*>(data.data());
    755 
    756       callback(&rr, callback_data);
    757     };
    758 
    759   HTTPDownloader* http = static_cast<HTTPDownloader*>(rc_client_get_userdata(client));
    760 
    761   // TODO: Content-type for post
    762   if (request->post_data)
    763   {
    764     // const auto pd = std::string_view(request->post_data);
    765     // Log_DevFmt("Server POST: {}", pd.substr(0, std::min<size_t>(pd.length(), 10)));
    766     http->CreatePostRequest(request->url, request->post_data, std::move(hd_callback));
    767   }
    768   else
    769   {
    770     http->CreateRequest(request->url, std::move(hd_callback));
    771   }
    772 }
    773 
    774 void Achievements::IdleUpdate()
    775 {
    776   if (!IsActive())
    777     return;
    778 
    779 #ifdef ENABLE_RAINTEGRATION
    780   if (IsUsingRAIntegration())
    781     return;
    782 #endif
    783 
    784   const auto lock = GetLock();
    785 
    786   s_http_downloader->PollRequests();
    787   rc_client_idle(s_client);
    788 }
    789 
    790 bool Achievements::NeedsIdleUpdate()
    791 {
    792   if (!IsActive())
    793     return false;
    794 
    795   const auto lock = GetLock();
    796   return (s_http_downloader && s_http_downloader->HasAnyRequests());
    797 }
    798 
    799 void Achievements::FrameUpdate()
    800 {
    801   if (!IsActive())
    802     return;
    803 
    804 #ifdef ENABLE_RAINTEGRATION
    805   if (IsUsingRAIntegration())
    806   {
    807     RA_DoAchievementsFrame();
    808     return;
    809   }
    810 #endif
    811 
    812   auto lock = GetLock();
    813 
    814   s_http_downloader->PollRequests();
    815   rc_client_do_frame(s_client);
    816 
    817   UpdateRichPresence(lock);
    818 }
    819 
    820 void Achievements::ClientEventHandler(const rc_client_event_t* event, rc_client_t* client)
    821 {
    822   switch (event->type)
    823   {
    824     case RC_CLIENT_EVENT_RESET:
    825       HandleResetEvent(event);
    826       break;
    827 
    828     case RC_CLIENT_EVENT_ACHIEVEMENT_TRIGGERED:
    829       HandleUnlockEvent(event);
    830       break;
    831 
    832     case RC_CLIENT_EVENT_GAME_COMPLETED:
    833       HandleGameCompleteEvent(event);
    834       break;
    835 
    836     case RC_CLIENT_EVENT_LEADERBOARD_STARTED:
    837       HandleLeaderboardStartedEvent(event);
    838       break;
    839 
    840     case RC_CLIENT_EVENT_LEADERBOARD_FAILED:
    841       HandleLeaderboardFailedEvent(event);
    842       break;
    843 
    844     case RC_CLIENT_EVENT_LEADERBOARD_SUBMITTED:
    845       HandleLeaderboardSubmittedEvent(event);
    846       break;
    847 
    848     case RC_CLIENT_EVENT_LEADERBOARD_SCOREBOARD:
    849       HandleLeaderboardScoreboardEvent(event);
    850       break;
    851 
    852     case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_SHOW:
    853       HandleLeaderboardTrackerShowEvent(event);
    854       break;
    855 
    856     case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_HIDE:
    857       HandleLeaderboardTrackerHideEvent(event);
    858       break;
    859 
    860     case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_UPDATE:
    861       HandleLeaderboardTrackerUpdateEvent(event);
    862       break;
    863 
    864     case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_SHOW:
    865       HandleAchievementChallengeIndicatorShowEvent(event);
    866       break;
    867 
    868     case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_HIDE:
    869       HandleAchievementChallengeIndicatorHideEvent(event);
    870       break;
    871 
    872     case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_SHOW:
    873       HandleAchievementProgressIndicatorShowEvent(event);
    874       break;
    875 
    876     case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_HIDE:
    877       HandleAchievementProgressIndicatorHideEvent(event);
    878       break;
    879 
    880     case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_UPDATE:
    881       HandleAchievementProgressIndicatorUpdateEvent(event);
    882       break;
    883 
    884     case RC_CLIENT_EVENT_SERVER_ERROR:
    885       HandleServerErrorEvent(event);
    886       break;
    887 
    888     case RC_CLIENT_EVENT_DISCONNECTED:
    889       HandleServerDisconnectedEvent(event);
    890       break;
    891 
    892     case RC_CLIENT_EVENT_RECONNECTED:
    893       HandleServerReconnectedEvent(event);
    894       break;
    895 
    896     default:
    897       [[unlikely]] ERROR_LOG("Unhandled event: {}", event->type);
    898       break;
    899   }
    900 }
    901 
    902 void Achievements::UpdateGameSummary()
    903 {
    904   rc_client_get_user_game_summary(s_client, &s_game_summary);
    905 }
    906 
    907 void Achievements::UpdateRichPresence(std::unique_lock<std::recursive_mutex>& lock)
    908 {
    909   // Limit rich presence updates to once per second, since it could change per frame.
    910   if (!s_has_rich_presence || !s_rich_presence_poll_time.ResetIfSecondsPassed(1.0))
    911     return;
    912 
    913   char buffer[512];
    914   const size_t res = rc_client_get_rich_presence_message(s_client, buffer, std::size(buffer));
    915   const std::string_view sv(buffer, res);
    916   if (s_rich_presence_string == sv)
    917     return;
    918 
    919   s_rich_presence_string.assign(sv);
    920 
    921   INFO_LOG("Rich presence updated: {}", s_rich_presence_string);
    922   Host::OnAchievementsRefreshed();
    923 
    924   lock.unlock();
    925   System::UpdateRichPresence(false);
    926   lock.lock();
    927 }
    928 
    929 void Achievements::GameChanged(const std::string& path, CDImage* image)
    930 {
    931   std::unique_lock lock(s_achievements_mutex);
    932 
    933   if (!IsActive())
    934     return;
    935 
    936   IdentifyGame(path, image);
    937 }
    938 
    939 void Achievements::IdentifyGame(const std::string& path, CDImage* image)
    940 {
    941   if (s_game_path == path)
    942   {
    943     WARNING_LOG("Game path is unchanged.");
    944     return;
    945   }
    946 
    947   std::unique_ptr<CDImage> temp_image;
    948   if (!path.empty() && (!image || (g_settings.achievements_use_first_disc_from_playlist && image->HasSubImages() &&
    949                                    image->GetCurrentSubImage() != 0)))
    950   {
    951     temp_image = CDImage::Open(path.c_str(), g_settings.cdrom_load_image_patches, nullptr);
    952     image = temp_image.get();
    953     if (!temp_image)
    954       ERROR_LOG("Failed to open temporary CD image '{}'", path);
    955   }
    956 
    957   std::string game_hash;
    958   if (image)
    959     game_hash = GetGameHash(image);
    960 
    961   if (s_game_hash == game_hash)
    962   {
    963     // only the path has changed - different format/save state/etc.
    964     INFO_LOG("Detected path change from '{}' to '{}'", s_game_path, path);
    965     s_game_path = path;
    966     return;
    967   }
    968 
    969   ClearGameHash();
    970   s_game_path = path;
    971   s_game_hash = std::move(game_hash);
    972   s_state_buffer.deallocate();
    973 
    974 #ifdef ENABLE_RAINTEGRATION
    975   if (IsUsingRAIntegration())
    976   {
    977     RAIntegration::GameChanged();
    978     return;
    979   }
    980 #endif
    981 
    982   // shouldn't have a load game request when we're not logged in.
    983   Assert(IsLoggedInOrLoggingIn() || !s_load_game_request);
    984 
    985   // bail out if we're not logged in, just save the hash
    986   if (!IsLoggedInOrLoggingIn())
    987   {
    988     INFO_LOG("Skipping load game because we're not logged in.");
    989     DisableHardcoreMode();
    990     return;
    991   }
    992 
    993   if (!rc_client_is_game_loaded(s_client))
    994     BeginLoadGame();
    995   else
    996     BeginChangeDisc();
    997 }
    998 
    999 void Achievements::BeginLoadGame()
   1000 {
   1001   ClearGameInfo();
   1002 
   1003   if (s_game_hash.empty())
   1004   {
   1005     // when we're booting the bios, this will fail
   1006     if (!s_game_path.empty())
   1007     {
   1008       Host::AddKeyedOSDMessage(
   1009         "retroachievements_disc_read_failed",
   1010         TRANSLATE_STR("Achievements", "Failed to read executable from disc. Achievements disabled."),
   1011         Host::OSD_ERROR_DURATION);
   1012     }
   1013 
   1014     DisableHardcoreMode();
   1015     UpdateGlyphRanges();
   1016     return;
   1017   }
   1018 
   1019   s_load_game_request = rc_client_begin_load_game(s_client, s_game_hash.c_str(), ClientLoadGameCallback, nullptr);
   1020 }
   1021 
   1022 void Achievements::BeginChangeDisc()
   1023 {
   1024   // cancel previous requests
   1025   if (s_load_game_request)
   1026   {
   1027     rc_client_abort_async(s_client, s_load_game_request);
   1028     s_load_game_request = nullptr;
   1029   }
   1030 
   1031   if (s_game_hash.empty())
   1032   {
   1033     // when we're booting the bios, this will fail
   1034     if (!s_game_path.empty())
   1035     {
   1036       Host::AddKeyedOSDMessage(
   1037         "retroachievements_disc_read_failed",
   1038         TRANSLATE_STR("Achievements", "Failed to read executable from disc. Achievements disabled."),
   1039         Host::OSD_ERROR_DURATION);
   1040     }
   1041 
   1042     ClearGameInfo();
   1043     DisableHardcoreMode();
   1044     UpdateGlyphRanges();
   1045     return;
   1046   }
   1047 
   1048   s_load_game_request = rc_client_begin_change_media_from_hash(s_client, s_game_hash.c_str(), ClientLoadGameCallback,
   1049                                                                reinterpret_cast<void*>(static_cast<uintptr_t>(1)));
   1050 }
   1051 
   1052 void Achievements::ClientLoadGameCallback(int result, const char* error_message, rc_client_t* client, void* userdata)
   1053 {
   1054   const bool was_disc_change = (userdata != nullptr);
   1055 
   1056   s_load_game_request = nullptr;
   1057   s_state_buffer.deallocate();
   1058 
   1059   if (result == RC_NO_GAME_LOADED)
   1060   {
   1061     // Unknown game.
   1062     INFO_LOG("Unknown game '{}', disabling achievements.", s_game_hash);
   1063     if (was_disc_change)
   1064     {
   1065       ClearGameInfo();
   1066       UpdateGlyphRanges();
   1067     }
   1068 
   1069     DisableHardcoreMode();
   1070     return;
   1071   }
   1072   else if (result == RC_LOGIN_REQUIRED)
   1073   {
   1074     // We would've asked to re-authenticate, so leave HC on for now.
   1075     // Once we've done so, we'll reload the game.
   1076     return;
   1077   }
   1078   else if (result != RC_OK)
   1079   {
   1080     ReportFmtError("Loading game failed: {}", error_message);
   1081     if (was_disc_change)
   1082     {
   1083       ClearGameInfo();
   1084       UpdateGlyphRanges();
   1085     }
   1086 
   1087     DisableHardcoreMode();
   1088     return;
   1089   }
   1090   else if (result == RC_HARDCORE_DISABLED)
   1091   {
   1092     if (error_message)
   1093       ReportError(error_message);
   1094 
   1095     DisableHardcoreMode();
   1096   }
   1097 
   1098   const rc_client_game_t* info = rc_client_get_game_info(s_client);
   1099   if (!info)
   1100   {
   1101     ReportError("rc_client_get_game_info() returned NULL");
   1102     if (was_disc_change)
   1103     {
   1104       ClearGameInfo();
   1105       UpdateGlyphRanges();
   1106     }
   1107 
   1108     DisableHardcoreMode();
   1109     return;
   1110   }
   1111 
   1112   const bool has_achievements = rc_client_has_achievements(client);
   1113   const bool has_leaderboards = rc_client_has_leaderboards(client);
   1114 
   1115   // Only display summary if the game title has changed across discs.
   1116   const bool display_summary = (s_game_id != info->id || s_game_title != info->title);
   1117 
   1118   // If the game has a RetroAchievements entry but no achievements or leaderboards,
   1119   // enforcing hardcore mode is pointless.
   1120   if (!has_achievements && !has_leaderboards)
   1121     DisableHardcoreMode();
   1122 
   1123   // We should have matched hardcore mode state.
   1124   Assert(s_hardcore_mode == (rc_client_get_hardcore_enabled(client) != 0));
   1125 
   1126   s_game_id = info->id;
   1127   s_game_title = info->title;
   1128   s_has_achievements = has_achievements;
   1129   s_has_leaderboards = has_leaderboards;
   1130   s_has_rich_presence = rc_client_has_rich_presence(client);
   1131 
   1132   // update ranges before initializing fsui
   1133   UpdateGlyphRanges();
   1134 
   1135   // ensure fullscreen UI is ready for notifications
   1136   if (display_summary)
   1137     FullscreenUI::Initialize();
   1138 
   1139   s_game_icon = GetLocalImagePath(info->badge_name, RC_IMAGE_TYPE_GAME);
   1140   if (!s_game_icon.empty() && !FileSystem::FileExists(s_game_icon.c_str()))
   1141   {
   1142     char buf[512];
   1143     if (int err = rc_client_game_get_image_url(info, buf, std::size(buf)); err == RC_OK)
   1144     {
   1145       DownloadImage(buf, s_game_icon);
   1146     }
   1147     else
   1148     {
   1149       ReportRCError(err, "rc_client_game_get_image_url() failed: ");
   1150     }
   1151   }
   1152 
   1153   UpdateGameSummary();
   1154   if (display_summary)
   1155     DisplayAchievementSummary();
   1156 
   1157   Host::OnAchievementsRefreshed();
   1158 }
   1159 
   1160 void Achievements::ClearGameInfo()
   1161 {
   1162   ClearUIState();
   1163 
   1164   if (s_load_game_request)
   1165   {
   1166     rc_client_abort_async(s_client, s_load_game_request);
   1167     s_load_game_request = nullptr;
   1168   }
   1169   rc_client_unload_game(s_client);
   1170 
   1171   s_active_leaderboard_trackers = {};
   1172   s_active_challenge_indicators = {};
   1173   s_active_progress_indicator.reset();
   1174   s_game_id = 0;
   1175   s_game_title = {};
   1176   s_game_icon = {};
   1177   s_state_buffer.deallocate();
   1178   s_has_achievements = false;
   1179   s_has_leaderboards = false;
   1180   s_has_rich_presence = false;
   1181   s_rich_presence_string = {};
   1182   s_game_summary = {};
   1183 
   1184   Host::OnAchievementsRefreshed();
   1185 }
   1186 
   1187 void Achievements::ClearGameHash()
   1188 {
   1189   s_game_path = {};
   1190   std::string().swap(s_game_hash);
   1191 }
   1192 
   1193 void Achievements::DisplayAchievementSummary()
   1194 {
   1195   if (g_settings.achievements_notifications && FullscreenUI::Initialize())
   1196   {
   1197     std::string title;
   1198     if (IsHardcoreModeActive())
   1199       title = fmt::format(TRANSLATE_FS("Achievements", "{} (Hardcore Mode)"), s_game_title);
   1200     else
   1201       title = s_game_title;
   1202 
   1203     std::string summary;
   1204     if (s_game_summary.num_core_achievements > 0)
   1205     {
   1206       summary = fmt::format(
   1207         TRANSLATE_FS("Achievements", "{0}, {1}."),
   1208         SmallString::from_format(TRANSLATE_PLURAL_FS("Achievements", "You have unlocked {} of %n achievements",
   1209                                                      "Achievement popup", s_game_summary.num_core_achievements),
   1210                                  s_game_summary.num_unlocked_achievements),
   1211         SmallString::from_format(TRANSLATE_PLURAL_FS("Achievements", "and earned {} of %n points", "Achievement popup",
   1212                                                      s_game_summary.points_core),
   1213                                  s_game_summary.points_unlocked));
   1214     }
   1215     else
   1216     {
   1217       summary = TRANSLATE_STR("Achievements", "This game has no achievements.");
   1218     }
   1219 
   1220     ImGuiFullscreen::AddNotification("achievement_summary", ACHIEVEMENT_SUMMARY_NOTIFICATION_TIME, std::move(title),
   1221                                      std::move(summary), s_game_icon);
   1222   }
   1223 
   1224   // Technically not going through the resource API, but since we're passing this to something else, we can't.
   1225   if (g_settings.achievements_sound_effects)
   1226     PlatformMisc::PlaySoundAsync(EmuFolders::GetOverridableResourcePath(INFO_SOUND_NAME).c_str());
   1227 }
   1228 
   1229 void Achievements::DisplayHardcoreDeferredMessage()
   1230 {
   1231   if (g_settings.achievements_hardcore_mode && !s_hardcore_mode && System::IsValid() && FullscreenUI::Initialize())
   1232   {
   1233     ImGuiFullscreen::ShowToast(std::string(),
   1234                                TRANSLATE_STR("Achievements", "Hardcore mode will be enabled on system reset."),
   1235                                Host::OSD_WARNING_DURATION);
   1236   }
   1237 }
   1238 
   1239 void Achievements::HandleResetEvent(const rc_client_event_t* event)
   1240 {
   1241   // We handle system resets ourselves, but still need to reset the client's state.
   1242   INFO_LOG("Resetting runtime due to reset event");
   1243   rc_client_reset(s_client);
   1244 
   1245   if (HasActiveGame())
   1246     UpdateGameSummary();
   1247 }
   1248 
   1249 void Achievements::HandleUnlockEvent(const rc_client_event_t* event)
   1250 {
   1251   const rc_client_achievement_t* cheevo = event->achievement;
   1252   DebugAssert(cheevo);
   1253 
   1254   INFO_LOG("Achievement {} ({}) for game {} unlocked", cheevo->title, cheevo->id, s_game_id);
   1255   UpdateGameSummary();
   1256 
   1257   if (g_settings.achievements_notifications && FullscreenUI::Initialize())
   1258   {
   1259     std::string title;
   1260     if (cheevo->category == RC_CLIENT_ACHIEVEMENT_CATEGORY_UNOFFICIAL)
   1261       title = fmt::format(TRANSLATE_FS("Achievements", "{} (Unofficial)"), cheevo->title);
   1262     else
   1263       title = cheevo->title;
   1264 
   1265     std::string badge_path = GetAchievementBadgePath(cheevo, cheevo->state);
   1266 
   1267     ImGuiFullscreen::AddNotification(fmt::format("achievement_unlock_{}", cheevo->id),
   1268                                      static_cast<float>(g_settings.achievements_notification_duration),
   1269                                      std::move(title), cheevo->description, std::move(badge_path));
   1270   }
   1271 
   1272   if (g_settings.achievements_sound_effects)
   1273     PlatformMisc::PlaySoundAsync(EmuFolders::GetOverridableResourcePath(UNLOCK_SOUND_NAME).c_str());
   1274 }
   1275 
   1276 void Achievements::HandleGameCompleteEvent(const rc_client_event_t* event)
   1277 {
   1278   INFO_LOG("Game {} complete", s_game_id);
   1279   UpdateGameSummary();
   1280 
   1281   if (g_settings.achievements_notifications && FullscreenUI::Initialize())
   1282   {
   1283     std::string title = fmt::format(TRANSLATE_FS("Achievements", "Mastered {}"), s_game_title);
   1284     std::string message = fmt::format(
   1285       TRANSLATE_FS("Achievements", "{0}, {1}"),
   1286       TRANSLATE_PLURAL_STR("Achievements", "%n achievements", "Mastery popup",
   1287                            s_game_summary.num_unlocked_achievements),
   1288       TRANSLATE_PLURAL_STR("Achievements", "%n points", "Achievement points", s_game_summary.points_unlocked));
   1289 
   1290     ImGuiFullscreen::AddNotification("achievement_mastery", GAME_COMPLETE_NOTIFICATION_TIME, std::move(title),
   1291                                      std::move(message), s_game_icon);
   1292   }
   1293 }
   1294 
   1295 void Achievements::HandleLeaderboardStartedEvent(const rc_client_event_t* event)
   1296 {
   1297   DEV_LOG("Leaderboard {} ({}) started", event->leaderboard->id, event->leaderboard->title);
   1298 
   1299   if (g_settings.achievements_leaderboard_notifications && FullscreenUI::Initialize())
   1300   {
   1301     std::string title = event->leaderboard->title;
   1302     std::string message = TRANSLATE_STR("Achievements", "Leaderboard attempt started.");
   1303 
   1304     ImGuiFullscreen::AddNotification(fmt::format("leaderboard_{}", event->leaderboard->id),
   1305                                      LEADERBOARD_STARTED_NOTIFICATION_TIME, std::move(title), std::move(message),
   1306                                      s_game_icon);
   1307   }
   1308 }
   1309 
   1310 void Achievements::HandleLeaderboardFailedEvent(const rc_client_event_t* event)
   1311 {
   1312   DEV_LOG("Leaderboard {} ({}) failed", event->leaderboard->id, event->leaderboard->title);
   1313 
   1314   if (g_settings.achievements_leaderboard_notifications && FullscreenUI::Initialize())
   1315   {
   1316     std::string title = event->leaderboard->title;
   1317     std::string message = TRANSLATE_STR("Achievements", "Leaderboard attempt failed.");
   1318 
   1319     ImGuiFullscreen::AddNotification(fmt::format("leaderboard_{}", event->leaderboard->id),
   1320                                      LEADERBOARD_FAILED_NOTIFICATION_TIME, std::move(title), std::move(message),
   1321                                      s_game_icon);
   1322   }
   1323 }
   1324 
   1325 void Achievements::HandleLeaderboardSubmittedEvent(const rc_client_event_t* event)
   1326 {
   1327   DEV_LOG("Leaderboard {} ({}) submitted", event->leaderboard->id, event->leaderboard->title);
   1328 
   1329   if (g_settings.achievements_leaderboard_notifications && FullscreenUI::Initialize())
   1330   {
   1331     static const char* value_strings[NUM_RC_CLIENT_LEADERBOARD_FORMATS] = {
   1332       TRANSLATE_NOOP("Achievements", "Your Time: {}{}"),
   1333       TRANSLATE_NOOP("Achievements", "Your Score: {}{}"),
   1334       TRANSLATE_NOOP("Achievements", "Your Value: {}{}"),
   1335     };
   1336 
   1337     std::string title = event->leaderboard->title;
   1338     std::string message = fmt::format(
   1339       fmt::runtime(Host::TranslateToStringView(
   1340         "Achievements",
   1341         value_strings[std::min<u8>(event->leaderboard->format, NUM_RC_CLIENT_LEADERBOARD_FORMATS - 1)])),
   1342       event->leaderboard->tracker_value ? event->leaderboard->tracker_value : "Unknown",
   1343       g_settings.achievements_spectator_mode ? std::string_view() : TRANSLATE_SV("Achievements", " (Submitting)"));
   1344 
   1345     ImGuiFullscreen::AddNotification(fmt::format("leaderboard_{}", event->leaderboard->id),
   1346                                      static_cast<float>(g_settings.achievements_leaderboard_duration), std::move(title),
   1347                                      std::move(message), s_game_icon);
   1348   }
   1349 
   1350   if (g_settings.achievements_sound_effects)
   1351     PlatformMisc::PlaySoundAsync(EmuFolders::GetOverridableResourcePath(LBSUBMIT_SOUND_NAME).c_str());
   1352 }
   1353 
   1354 void Achievements::HandleLeaderboardScoreboardEvent(const rc_client_event_t* event)
   1355 {
   1356   DEV_LOG("Leaderboard {} scoreboard rank {} of {}", event->leaderboard_scoreboard->leaderboard_id,
   1357           event->leaderboard_scoreboard->new_rank, event->leaderboard_scoreboard->num_entries);
   1358 
   1359   if (g_settings.achievements_leaderboard_notifications && FullscreenUI::Initialize())
   1360   {
   1361     static const char* value_strings[NUM_RC_CLIENT_LEADERBOARD_FORMATS] = {
   1362       TRANSLATE_NOOP("Achievements", "Your Time: {} (Best: {})"),
   1363       TRANSLATE_NOOP("Achievements", "Your Score: {} (Best: {})"),
   1364       TRANSLATE_NOOP("Achievements", "Your Value: {} (Best: {})"),
   1365     };
   1366 
   1367     std::string title = event->leaderboard->title;
   1368     std::string message = fmt::format(
   1369       TRANSLATE_FS("Achievements", "{}\nLeaderboard Position: {} of {}"),
   1370       fmt::format(fmt::runtime(Host::TranslateToStringView(
   1371                     "Achievements",
   1372                     value_strings[std::min<u8>(event->leaderboard->format, NUM_RC_CLIENT_LEADERBOARD_FORMATS - 1)])),
   1373                   event->leaderboard_scoreboard->submitted_score, event->leaderboard_scoreboard->best_score),
   1374       event->leaderboard_scoreboard->new_rank, event->leaderboard_scoreboard->num_entries);
   1375 
   1376     ImGuiFullscreen::AddNotification(fmt::format("leaderboard_{}", event->leaderboard->id),
   1377                                      static_cast<float>(g_settings.achievements_leaderboard_duration), std::move(title),
   1378                                      std::move(message), s_game_icon);
   1379   }
   1380 }
   1381 
   1382 void Achievements::HandleLeaderboardTrackerShowEvent(const rc_client_event_t* event)
   1383 {
   1384   DEV_LOG("Showing leaderboard tracker: {}: {}", event->leaderboard_tracker->id, event->leaderboard_tracker->display);
   1385 
   1386   TinyString width_string;
   1387   width_string.append(ICON_FA_STOPWATCH);
   1388   const u32 display_len = static_cast<u32>(std::strlen(event->leaderboard_tracker->display));
   1389   for (u32 i = 0; i < display_len; i++)
   1390     width_string.append('0');
   1391 
   1392   LeaderboardTrackerIndicator indicator;
   1393   indicator.tracker_id = event->leaderboard_tracker->id;
   1394   indicator.text = event->leaderboard_tracker->display;
   1395   indicator.active = true;
   1396   s_active_leaderboard_trackers.push_back(std::move(indicator));
   1397 }
   1398 
   1399 void Achievements::HandleLeaderboardTrackerHideEvent(const rc_client_event_t* event)
   1400 {
   1401   const u32 id = event->leaderboard_tracker->id;
   1402   auto it = std::find_if(s_active_leaderboard_trackers.begin(), s_active_leaderboard_trackers.end(),
   1403                          [id](const auto& it) { return it.tracker_id == id; });
   1404   if (it == s_active_leaderboard_trackers.end())
   1405     return;
   1406 
   1407   DEV_LOG("Hiding leaderboard tracker: {}", id);
   1408   it->active = false;
   1409   it->show_hide_time.Reset();
   1410 }
   1411 
   1412 void Achievements::HandleLeaderboardTrackerUpdateEvent(const rc_client_event_t* event)
   1413 {
   1414   const u32 id = event->leaderboard_tracker->id;
   1415   auto it = std::find_if(s_active_leaderboard_trackers.begin(), s_active_leaderboard_trackers.end(),
   1416                          [id](const auto& it) { return it.tracker_id == id; });
   1417   if (it == s_active_leaderboard_trackers.end())
   1418     return;
   1419 
   1420   DEV_LOG("Updating leaderboard tracker: {}: {}", event->leaderboard_tracker->id, event->leaderboard_tracker->display);
   1421 
   1422   it->text = event->leaderboard_tracker->display;
   1423   it->active = true;
   1424 }
   1425 
   1426 void Achievements::HandleAchievementChallengeIndicatorShowEvent(const rc_client_event_t* event)
   1427 {
   1428   if (auto it =
   1429         std::find_if(s_active_challenge_indicators.begin(), s_active_challenge_indicators.end(),
   1430                      [event](const AchievementChallengeIndicator& it) { return it.achievement == event->achievement; });
   1431       it != s_active_challenge_indicators.end())
   1432   {
   1433     it->show_hide_time.Reset();
   1434     it->active = true;
   1435     return;
   1436   }
   1437 
   1438   AchievementChallengeIndicator indicator;
   1439   indicator.achievement = event->achievement;
   1440   indicator.badge_path = GetAchievementBadgePath(event->achievement, RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED);
   1441   indicator.active = true;
   1442   s_active_challenge_indicators.push_back(std::move(indicator));
   1443 
   1444   DEV_LOG("Show challenge indicator for {} ({})", event->achievement->id, event->achievement->title);
   1445 }
   1446 
   1447 void Achievements::HandleAchievementChallengeIndicatorHideEvent(const rc_client_event_t* event)
   1448 {
   1449   auto it =
   1450     std::find_if(s_active_challenge_indicators.begin(), s_active_challenge_indicators.end(),
   1451                  [event](const AchievementChallengeIndicator& it) { return it.achievement == event->achievement; });
   1452   if (it == s_active_challenge_indicators.end())
   1453     return;
   1454 
   1455   DEV_LOG("Hide challenge indicator for {} ({})", event->achievement->id, event->achievement->title);
   1456   it->show_hide_time.Reset();
   1457   it->active = false;
   1458 }
   1459 
   1460 void Achievements::HandleAchievementProgressIndicatorShowEvent(const rc_client_event_t* event)
   1461 {
   1462   DEV_LOG("Showing progress indicator: {} ({}): {}", event->achievement->id, event->achievement->title,
   1463           event->achievement->measured_progress);
   1464 
   1465   if (!s_active_progress_indicator.has_value())
   1466     s_active_progress_indicator.emplace();
   1467   else
   1468     s_active_progress_indicator->show_hide_time.Reset();
   1469 
   1470   s_active_progress_indicator->achievement = event->achievement;
   1471   s_active_progress_indicator->badge_path =
   1472     GetAchievementBadgePath(event->achievement, RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED);
   1473   s_active_progress_indicator->active = true;
   1474 }
   1475 
   1476 void Achievements::HandleAchievementProgressIndicatorHideEvent(const rc_client_event_t* event)
   1477 {
   1478   if (!s_active_progress_indicator.has_value())
   1479     return;
   1480 
   1481   DEV_LOG("Hiding progress indicator");
   1482   s_active_progress_indicator->show_hide_time.Reset();
   1483   s_active_progress_indicator->active = false;
   1484 }
   1485 
   1486 void Achievements::HandleAchievementProgressIndicatorUpdateEvent(const rc_client_event_t* event)
   1487 {
   1488   DEV_LOG("Updating progress indicator: {} ({}): {}", event->achievement->id, event->achievement->title,
   1489           event->achievement->measured_progress);
   1490   s_active_progress_indicator->achievement = event->achievement;
   1491   s_active_progress_indicator->active = true;
   1492 }
   1493 
   1494 void Achievements::HandleServerErrorEvent(const rc_client_event_t* event)
   1495 {
   1496   std::string message =
   1497     fmt::format(TRANSLATE_FS("Achievements", "Server error in {}:\n{}"),
   1498                 event->server_error->api ? event->server_error->api : "UNKNOWN",
   1499                 event->server_error->error_message ? event->server_error->error_message : "UNKNOWN");
   1500   ERROR_LOG(message.c_str());
   1501   Host::AddOSDMessage(std::move(message), Host::OSD_ERROR_DURATION);
   1502 }
   1503 
   1504 void Achievements::HandleServerDisconnectedEvent(const rc_client_event_t* event)
   1505 {
   1506   WARNING_LOG("Server disconnected.");
   1507 
   1508   if (FullscreenUI::Initialize())
   1509   {
   1510     ImGuiFullscreen::ShowToast(
   1511       TRANSLATE_STR("Achievements", "Achievements Disconnected"),
   1512       TRANSLATE_STR("Achievements",
   1513                     "An unlock request could not be completed. We will keep retrying to submit this request."),
   1514       Host::OSD_ERROR_DURATION);
   1515   }
   1516 }
   1517 
   1518 void Achievements::HandleServerReconnectedEvent(const rc_client_event_t* event)
   1519 {
   1520   WARNING_LOG("Server reconnected.");
   1521 
   1522   if (FullscreenUI::Initialize())
   1523   {
   1524     ImGuiFullscreen::ShowToast(TRANSLATE_STR("Achievements", "Achievements Reconnected"),
   1525                                TRANSLATE_STR("Achievements", "All pending unlock requests have completed."),
   1526                                Host::OSD_INFO_DURATION);
   1527   }
   1528 }
   1529 
   1530 void Achievements::ResetClient()
   1531 {
   1532 #ifdef ENABLE_RAINTEGRATION
   1533   if (IsUsingRAIntegration())
   1534   {
   1535     RA_OnReset();
   1536     return;
   1537   }
   1538 #endif
   1539 
   1540   if (!IsActive())
   1541     return;
   1542 
   1543   DEV_LOG("Reset client");
   1544   rc_client_reset(s_client);
   1545 }
   1546 
   1547 void Achievements::OnSystemPaused(bool paused)
   1548 {
   1549 #ifdef ENABLE_RAINTEGRATION
   1550   if (IsUsingRAIntegration())
   1551     RA_SetPaused(paused);
   1552 #endif
   1553 }
   1554 
   1555 void Achievements::DisableHardcoreMode()
   1556 {
   1557   if (!IsActive())
   1558     return;
   1559 
   1560 #ifdef ENABLE_RAINTEGRATION
   1561   if (IsUsingRAIntegration())
   1562   {
   1563     if (RA_HardcoreModeIsActive())
   1564       RA_DisableHardcore();
   1565 
   1566     return;
   1567   }
   1568 #endif
   1569 
   1570   if (!s_hardcore_mode)
   1571     return;
   1572 
   1573   SetHardcoreMode(false, true);
   1574 }
   1575 
   1576 bool Achievements::ResetHardcoreMode(bool is_booting)
   1577 {
   1578   if (!IsActive())
   1579     return false;
   1580 
   1581   const auto lock = GetLock();
   1582 
   1583   // If we're not logged in, don't apply hardcore mode restrictions.
   1584   // If we later log in, we'll start with it off anyway.
   1585   const bool wanted_hardcore_mode =
   1586     (IsLoggedInOrLoggingIn() || s_load_game_request) && g_settings.achievements_hardcore_mode;
   1587   if (s_hardcore_mode == wanted_hardcore_mode)
   1588     return false;
   1589 
   1590   if (!is_booting && wanted_hardcore_mode && !CanEnableHardcoreMode())
   1591     return false;
   1592 
   1593   SetHardcoreMode(wanted_hardcore_mode, false);
   1594   return true;
   1595 }
   1596 
   1597 void Achievements::SetHardcoreMode(bool enabled, bool force_display_message)
   1598 {
   1599   if (enabled == s_hardcore_mode)
   1600     return;
   1601 
   1602   // new mode
   1603   s_hardcore_mode = enabled;
   1604 
   1605   if (System::IsValid() && (HasActiveGame() || force_display_message) && FullscreenUI::Initialize())
   1606   {
   1607     ImGuiFullscreen::ShowToast(std::string(),
   1608                                enabled ? TRANSLATE_STR("Achievements", "Hardcore mode is now enabled.") :
   1609                                          TRANSLATE_STR("Achievements", "Hardcore mode is now disabled."),
   1610                                Host::OSD_INFO_DURATION);
   1611   }
   1612 
   1613   rc_client_set_hardcore_enabled(s_client, enabled);
   1614   DebugAssert((rc_client_get_hardcore_enabled(s_client) != 0) == enabled);
   1615   if (HasActiveGame())
   1616   {
   1617     UpdateGameSummary();
   1618     DisplayAchievementSummary();
   1619   }
   1620 
   1621   // Reload setting to permit cheating-like things if we were just disabled.
   1622   if (!enabled)
   1623     Host::RunOnCPUThread([]() { System::ApplySettings(false); });
   1624 
   1625   // Toss away UI state, because it's invalid now
   1626   ClearUIState();
   1627 
   1628   Host::OnAchievementsHardcoreModeChanged(enabled);
   1629 }
   1630 
   1631 bool Achievements::DoState(StateWrapper& sw)
   1632 {
   1633   // if we're inactive, we still need to skip the data (if any)
   1634   if (!IsActive())
   1635   {
   1636     u32 data_size = 0;
   1637     sw.Do(&data_size);
   1638     if (data_size > 0)
   1639       sw.SkipBytes(data_size);
   1640 
   1641     return !sw.HasError();
   1642   }
   1643 
   1644   std::unique_lock lock(s_achievements_mutex);
   1645 
   1646   if (sw.IsReading())
   1647   {
   1648     // if we're active, make sure we've downloaded and activated all the achievements
   1649     // before deserializing, otherwise that state's going to get lost.
   1650     if (!IsUsingRAIntegration() && s_load_game_request)
   1651     {
   1652       Host::DisplayLoadingScreen("Downloading achievements data...");
   1653       s_http_downloader->WaitForAllRequests();
   1654     }
   1655 
   1656     u32 data_size = 0;
   1657     sw.Do(&data_size);
   1658     if (data_size == 0)
   1659     {
   1660       // reset runtime, no data (state might've been created without cheevos)
   1661       DEV_LOG("State is missing cheevos data, resetting runtime");
   1662 #ifdef ENABLE_RAINTEGRATION
   1663       if (IsUsingRAIntegration())
   1664         RA_OnReset();
   1665       else
   1666         rc_client_reset(s_client);
   1667 #else
   1668       rc_client_reset(s_client);
   1669 #endif
   1670 
   1671       return !sw.HasError();
   1672     }
   1673 
   1674     if (data_size > s_state_buffer.size())
   1675       s_state_buffer.resize(data_size);
   1676     if (data_size > 0)
   1677       sw.DoBytes(s_state_buffer.data(), data_size);
   1678     if (sw.HasError())
   1679       return false;
   1680 
   1681 #ifdef ENABLE_RAINTEGRATION
   1682     if (IsUsingRAIntegration())
   1683     {
   1684       RA_RestoreState(reinterpret_cast<const char*>(s_state_buffer.data()));
   1685     }
   1686     else
   1687     {
   1688       const int result = rc_client_deserialize_progress_sized(s_client, s_state_buffer.data(), data_size);
   1689       if (result != RC_OK)
   1690       {
   1691         WARNING_LOG("Failed to deserialize cheevos state ({}), resetting", result);
   1692         rc_client_reset(s_client);
   1693       }
   1694     }
   1695 #endif
   1696 
   1697     return true;
   1698   }
   1699   else
   1700   {
   1701     size_t data_size;
   1702 
   1703 #ifdef ENABLE_RAINTEGRATION
   1704     if (IsUsingRAIntegration())
   1705     {
   1706       const int size = RA_CaptureState(nullptr, 0);
   1707 
   1708       data_size = (size >= 0) ? static_cast<u32>(size) : 0;
   1709       s_state_buffer.resize(data_size);
   1710 
   1711       if (data_size > 0)
   1712       {
   1713         const int result = RA_CaptureState(reinterpret_cast<char*>(s_state_buffer.data()), static_cast<int>(data_size));
   1714         if (result != static_cast<int>(data_size))
   1715         {
   1716           WARNING_LOG("Failed to serialize cheevos state from RAIntegration.");
   1717           data_size = 0;
   1718         }
   1719       }
   1720     }
   1721     else
   1722 #endif
   1723     {
   1724       data_size = rc_client_progress_size(s_client);
   1725       if (data_size > 0)
   1726       {
   1727         if (s_state_buffer.size() < data_size)
   1728           s_state_buffer.resize(data_size);
   1729 
   1730         const int result = rc_client_serialize_progress_sized(s_client, s_state_buffer.data(), data_size);
   1731         if (result != RC_OK)
   1732         {
   1733           // set data to zero, effectively serializing nothing
   1734           WARNING_LOG("Failed to serialize cheevos state ({})", result);
   1735           data_size = 0;
   1736         }
   1737       }
   1738     }
   1739 
   1740     sw.Do(&data_size);
   1741     if (data_size > 0)
   1742       sw.DoBytes(s_state_buffer.data(), data_size);
   1743 
   1744     return !sw.HasError();
   1745   }
   1746 }
   1747 
   1748 std::string Achievements::GetAchievementBadgePath(const rc_client_achievement_t* achievement, int state,
   1749                                                   bool download_if_missing)
   1750 {
   1751   const std::string path = GetLocalImagePath(achievement->badge_name, (state == RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED) ?
   1752                                                                         RC_IMAGE_TYPE_ACHIEVEMENT :
   1753                                                                         RC_IMAGE_TYPE_ACHIEVEMENT_LOCKED);
   1754   if (download_if_missing && !path.empty() && !FileSystem::FileExists(path.c_str()))
   1755   {
   1756     char buf[512];
   1757     const int res = rc_client_achievement_get_image_url(achievement, state, buf, std::size(buf));
   1758     if (res == RC_OK)
   1759       DownloadImage(buf, path);
   1760     else
   1761       ReportRCError(res, "rc_client_achievement_get_image_url() for {} failed", achievement->title);
   1762   }
   1763 
   1764   return path;
   1765 }
   1766 
   1767 std::string Achievements::GetLeaderboardUserBadgePath(const rc_client_leaderboard_entry_t* entry)
   1768 {
   1769   // TODO: maybe we should just cache these in memory...
   1770   const std::string path = GetLocalImagePath(entry->user, RC_IMAGE_TYPE_USER);
   1771 
   1772   if (!FileSystem::FileExists(path.c_str()))
   1773   {
   1774     char buf[512];
   1775     const int res = rc_client_leaderboard_entry_get_user_image_url(entry, buf, std::size(buf));
   1776     if (res == RC_OK)
   1777       DownloadImage(buf, path);
   1778     else
   1779       ReportRCError(res, "rc_client_leaderboard_entry_get_user_image_url() for {} failed", entry->user);
   1780   }
   1781 
   1782   return path;
   1783 }
   1784 
   1785 bool Achievements::IsLoggedInOrLoggingIn()
   1786 {
   1787   return (rc_client_get_user_info(s_client) != nullptr || s_login_request);
   1788 }
   1789 
   1790 bool Achievements::CanEnableHardcoreMode()
   1791 {
   1792   return (s_load_game_request || s_has_achievements || s_has_leaderboards);
   1793 }
   1794 
   1795 bool Achievements::Login(const char* username, const char* password, Error* error)
   1796 {
   1797   auto lock = GetLock();
   1798 
   1799   // We need to use a temporary client if achievements aren't currently active.
   1800   rc_client_t* client = s_client;
   1801   HTTPDownloader* http = s_http_downloader.get();
   1802   const bool is_temporary_client = (client == nullptr);
   1803   std::unique_ptr<HTTPDownloader> temporary_downloader;
   1804   ScopedGuard temporary_client_guard = [&client, is_temporary_client, &temporary_downloader]() {
   1805     if (is_temporary_client)
   1806       DestroyClient(&client, &temporary_downloader);
   1807   };
   1808   if (is_temporary_client)
   1809   {
   1810     if (!CreateClient(&client, &temporary_downloader))
   1811     {
   1812       Error::SetString(error, "Failed to create client.");
   1813       return false;
   1814     }
   1815     http = temporary_downloader.get();
   1816   }
   1817 
   1818   LoginWithPasswordParameters params = {username, error, nullptr, false};
   1819 
   1820   params.request =
   1821     rc_client_begin_login_with_password(client, username, password, ClientLoginWithPasswordCallback, &params);
   1822   if (!params.request)
   1823   {
   1824     Error::SetString(error, "Failed to create login request.");
   1825     return false;
   1826   }
   1827 
   1828   // Wait until the login request completes.
   1829   http->WaitForAllRequests();
   1830   Assert(!params.request);
   1831 
   1832   // Success? Assume the callback set the error message.
   1833   if (!params.result)
   1834     return false;
   1835 
   1836   // If we were't a temporary client, get the game loaded.
   1837   if (System::IsValid() && !is_temporary_client)
   1838     BeginLoadGame();
   1839 
   1840   return true;
   1841 }
   1842 
   1843 void Achievements::ClientLoginWithPasswordCallback(int result, const char* error_message, rc_client_t* client,
   1844                                                    void* userdata)
   1845 {
   1846   Assert(userdata);
   1847 
   1848   LoginWithPasswordParameters* params = static_cast<LoginWithPasswordParameters*>(userdata);
   1849   params->request = nullptr;
   1850 
   1851   if (result != RC_OK)
   1852   {
   1853     ERROR_LOG("Login failed: {}: {}", rc_error_str(result), error_message ? error_message : "Unknown");
   1854     Error::SetString(params->error,
   1855                      fmt::format("{}: {}", rc_error_str(result), error_message ? error_message : "Unknown"));
   1856     params->result = false;
   1857     return;
   1858   }
   1859 
   1860   // Grab the token from the client, and save it to the config.
   1861   const rc_client_user_t* user = rc_client_get_user_info(client);
   1862   if (!user || !user->token)
   1863   {
   1864     ERROR_LOG("rc_client_get_user_info() returned NULL");
   1865     Error::SetString(params->error, "rc_client_get_user_info() returned NULL");
   1866     params->result = false;
   1867     return;
   1868   }
   1869 
   1870   params->result = true;
   1871 
   1872   // Store configuration.
   1873   Host::SetBaseStringSettingValue("Cheevos", "Username", params->username);
   1874   Host::SetBaseStringSettingValue("Cheevos", "Token", user->token);
   1875   Host::SetBaseStringSettingValue("Cheevos", "LoginTimestamp", fmt::format("{}", std::time(nullptr)).c_str());
   1876   Host::CommitBaseSettingChanges();
   1877 
   1878   ShowLoginSuccess(client);
   1879 }
   1880 
   1881 void Achievements::ClientLoginWithTokenCallback(int result, const char* error_message, rc_client_t* client,
   1882                                                 void* userdata)
   1883 {
   1884   s_login_request = nullptr;
   1885 
   1886   if (result != RC_OK)
   1887   {
   1888     ReportFmtError("Login failed: {}", error_message);
   1889     Host::OnAchievementsLoginRequested(LoginRequestReason::TokenInvalid);
   1890     return;
   1891   }
   1892 
   1893   ShowLoginSuccess(client);
   1894 
   1895   if (System::IsValid())
   1896     BeginLoadGame();
   1897 }
   1898 
   1899 void Achievements::ShowLoginSuccess(const rc_client_t* client)
   1900 {
   1901   const rc_client_user_t* user = rc_client_get_user_info(client);
   1902   if (!user)
   1903     return;
   1904 
   1905   Host::OnAchievementsLoginSuccess(user->username, user->score, user->score_softcore, user->num_unread_messages);
   1906 
   1907   if (System::IsValid())
   1908   {
   1909     const auto lock = GetLock();
   1910     if (s_client == client)
   1911       Host::RunOnCPUThread(ShowLoginNotification);
   1912   }
   1913 }
   1914 
   1915 void Achievements::ShowLoginNotification()
   1916 {
   1917   const rc_client_user_t* user = rc_client_get_user_info(s_client);
   1918   if (!user)
   1919     return;
   1920 
   1921   if (g_settings.achievements_notifications && FullscreenUI::Initialize())
   1922   {
   1923     std::string badge_path = GetLoggedInUserBadgePath();
   1924     std::string title = user->display_name;
   1925 
   1926     //: Summary for login notification.
   1927     std::string summary = fmt::format(TRANSLATE_FS("Achievements", "Score: {} ({} softcore)\nUnread messages: {}"),
   1928                                       user->score, user->score_softcore, user->num_unread_messages);
   1929 
   1930     ImGuiFullscreen::AddNotification("achievements_login", LOGIN_NOTIFICATION_TIME, std::move(title),
   1931                                      std::move(summary), std::move(badge_path));
   1932   }
   1933 }
   1934 
   1935 const char* Achievements::GetLoggedInUserName()
   1936 {
   1937   const rc_client_user_t* user = rc_client_get_user_info(s_client);
   1938   if (!user) [[unlikely]]
   1939     return nullptr;
   1940 
   1941   return user->username;
   1942 }
   1943 
   1944 std::string Achievements::GetLoggedInUserBadgePath()
   1945 {
   1946   std::string badge_path;
   1947 
   1948   const rc_client_user_t* user = rc_client_get_user_info(s_client);
   1949   if (!user) [[unlikely]]
   1950     return badge_path;
   1951 
   1952   badge_path = GetLocalImagePath(user->username, RC_IMAGE_TYPE_USER);
   1953   if (!badge_path.empty() && !FileSystem::FileExists(badge_path.c_str())) [[unlikely]]
   1954   {
   1955     char url[512];
   1956     const int res = rc_client_user_get_image_url(user, url, std::size(url));
   1957     if (res == RC_OK)
   1958       DownloadImage(url, badge_path);
   1959     else
   1960       ReportRCError(res, "rc_client_user_get_image_url() failed: ");
   1961   }
   1962 
   1963   return badge_path;
   1964 }
   1965 
   1966 void Achievements::Logout()
   1967 {
   1968   if (IsActive())
   1969   {
   1970     const auto lock = GetLock();
   1971 
   1972     if (HasActiveGame())
   1973     {
   1974       ClearGameInfo();
   1975       UpdateGlyphRanges();
   1976     }
   1977 
   1978     INFO_LOG("Logging out...");
   1979     rc_client_logout(s_client);
   1980   }
   1981 
   1982   INFO_LOG("Clearing credentials...");
   1983   Host::DeleteBaseSettingValue("Cheevos", "Username");
   1984   Host::DeleteBaseSettingValue("Cheevos", "Token");
   1985   Host::DeleteBaseSettingValue("Cheevos", "LoginTimestamp");
   1986   Host::CommitBaseSettingChanges();
   1987 }
   1988 
   1989 bool Achievements::ConfirmSystemReset()
   1990 {
   1991 #ifdef ENABLE_RAINTEGRATION
   1992   if (IsUsingRAIntegration())
   1993     return RA_ConfirmLoadNewRom(false);
   1994 #endif
   1995 
   1996   return true;
   1997 }
   1998 
   1999 bool Achievements::ConfirmHardcoreModeDisable(const char* trigger)
   2000 {
   2001 #ifdef ENABLE_RAINTEGRATION
   2002   if (IsUsingRAIntegration())
   2003     return (RA_WarnDisableHardcore(trigger) != 0);
   2004 #endif
   2005 
   2006   // I really hope this doesn't deadlock :/
   2007   const bool confirmed = Host::ConfirmMessage(
   2008     TRANSLATE("Achievements", "Confirm Hardcore Mode"),
   2009     fmt::format(TRANSLATE_FS("Achievements", "{0} cannot be performed while hardcore mode is active. Do you "
   2010                                              "want to disable hardcore mode? {0} will be cancelled if you select No."),
   2011                 trigger));
   2012   if (!confirmed)
   2013     return false;
   2014 
   2015   DisableHardcoreMode();
   2016   return true;
   2017 }
   2018 
   2019 void Achievements::ConfirmHardcoreModeDisableAsync(const char* trigger, std::function<void(bool)> callback)
   2020 {
   2021 #ifndef __ANDROID__
   2022 #ifdef ENABLE_RAINTEGRATION
   2023   if (IsUsingRAIntegration())
   2024   {
   2025     const bool result = (RA_WarnDisableHardcore(trigger) != 0);
   2026     callback(result);
   2027     return;
   2028   }
   2029 #endif
   2030 
   2031   if (!FullscreenUI::Initialize())
   2032   {
   2033     Host::AddOSDMessage(fmt::format(TRANSLATE_FS("Achievements", "Cannot {} while hardcode mode is active."), trigger),
   2034                         Host::OSD_WARNING_DURATION);
   2035     callback(false);
   2036     return;
   2037   }
   2038 
   2039   auto real_callback = [callback = std::move(callback)](bool res) mutable {
   2040     // don't run the callback in the middle of rendering the UI
   2041     Host::RunOnCPUThread([callback = std::move(callback), res]() {
   2042       if (res)
   2043         DisableHardcoreMode();
   2044       callback(res);
   2045     });
   2046   };
   2047 
   2048   ImGuiFullscreen::OpenConfirmMessageDialog(
   2049     TRANSLATE_STR("Achievements", "Confirm Hardcore Mode"),
   2050     fmt::format(TRANSLATE_FS("Achievements", "{0} cannot be performed while hardcore mode is active. Do you "
   2051                                              "want to disable hardcore mode? {0} will be cancelled if you select No."),
   2052                 trigger),
   2053     std::move(real_callback), fmt::format(ICON_FA_CHECK " {}", TRANSLATE_SV("Achievements", "Yes")),
   2054     fmt::format(ICON_FA_TIMES " {}", TRANSLATE_SV("Achievements", "No")));
   2055 #else
   2056   Host::AddOSDMessage(fmt::format(TRANSLATE_FS("Achievements", "Cannot {} while hardcode mode is active."), trigger),
   2057                       Host::OSD_WARNING_DURATION);
   2058   callback(false);
   2059 #endif
   2060 }
   2061 
   2062 void Achievements::ClearUIState()
   2063 {
   2064 #ifndef __ANDROID__
   2065   if (FullscreenUI::IsAchievementsWindowOpen() || FullscreenUI::IsLeaderboardsWindowOpen())
   2066     FullscreenUI::ReturnToPreviousWindow();
   2067 
   2068   CloseLeaderboard();
   2069 #endif
   2070 
   2071   s_achievement_badge_paths = {};
   2072 
   2073   s_leaderboard_user_icon_paths = {};
   2074   s_leaderboard_entry_lists = {};
   2075   if (s_leaderboard_list)
   2076   {
   2077     rc_client_destroy_leaderboard_list(s_leaderboard_list);
   2078     s_leaderboard_list = nullptr;
   2079   }
   2080 
   2081   if (s_achievement_list)
   2082   {
   2083     rc_client_destroy_achievement_list(s_achievement_list);
   2084     s_achievement_list = nullptr;
   2085   }
   2086 }
   2087 
   2088 template<typename T>
   2089 static float IndicatorOpacity(const T& i)
   2090 {
   2091   const float elapsed = static_cast<float>(i.show_hide_time.GetTimeSeconds());
   2092   const float time = i.active ? Achievements::INDICATOR_FADE_IN_TIME : Achievements::INDICATOR_FADE_OUT_TIME;
   2093   const float opacity = (elapsed >= time) ? 1.0f : (elapsed / time);
   2094   return (i.active) ? opacity : (1.0f - opacity);
   2095 }
   2096 
   2097 void Achievements::DrawGameOverlays()
   2098 {
   2099   using ImGuiFullscreen::g_medium_font;
   2100   using ImGuiFullscreen::LayoutScale;
   2101 
   2102   if (!HasActiveGame() || !g_settings.achievements_overlays)
   2103     return;
   2104 
   2105   const auto lock = GetLock();
   2106 
   2107   const float spacing = LayoutScale(10.0f);
   2108   const float padding = LayoutScale(10.0f);
   2109   const ImVec2 image_size =
   2110     LayoutScale(ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT);
   2111   const ImGuiIO& io = ImGui::GetIO();
   2112   ImVec2 position = ImVec2(io.DisplaySize.x - padding, io.DisplaySize.y - padding);
   2113   ImDrawList* dl = ImGui::GetBackgroundDrawList();
   2114 
   2115   if (!s_active_challenge_indicators.empty())
   2116   {
   2117     const float x_advance = image_size.x + spacing;
   2118     ImVec2 current_position = ImVec2(position.x - image_size.x, position.y - image_size.y);
   2119 
   2120     for (auto it = s_active_challenge_indicators.begin(); it != s_active_challenge_indicators.end();)
   2121     {
   2122       const AchievementChallengeIndicator& indicator = *it;
   2123       const float opacity = IndicatorOpacity(indicator);
   2124       const u32 col = ImGui::GetColorU32(ImVec4(1.0f, 1.0f, 1.0f, opacity));
   2125 
   2126       GPUTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(indicator.badge_path);
   2127       if (badge)
   2128       {
   2129         dl->AddImage(badge, current_position, current_position + image_size, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f),
   2130                      col);
   2131         current_position.x -= x_advance;
   2132       }
   2133 
   2134       if (!indicator.active && opacity <= 0.01f)
   2135       {
   2136         DEV_LOG("Remove challenge indicator");
   2137         it = s_active_challenge_indicators.erase(it);
   2138       }
   2139       else
   2140       {
   2141         ++it;
   2142       }
   2143     }
   2144 
   2145     position.y -= image_size.y + padding;
   2146   }
   2147 
   2148   if (s_active_progress_indicator.has_value())
   2149   {
   2150     const AchievementProgressIndicator& indicator = s_active_progress_indicator.value();
   2151     const float opacity = IndicatorOpacity(indicator);
   2152     const u32 col = ImGui::GetColorU32(ImVec4(1.0f, 1.0f, 1.0f, opacity));
   2153 
   2154     const char* text_start = s_active_progress_indicator->achievement->measured_progress;
   2155     const char* text_end = text_start + std::strlen(text_start);
   2156     const ImVec2 text_size = g_medium_font->CalcTextSizeA(g_medium_font->FontSize, FLT_MAX, 0.0f, text_start, text_end);
   2157 
   2158     const ImVec2 box_min = ImVec2(position.x - image_size.x - text_size.x - spacing - padding * 2.0f,
   2159                                   position.y - image_size.y - padding * 2.0f);
   2160     const ImVec2 box_max = position;
   2161     const float box_rounding = LayoutScale(1.0f);
   2162 
   2163     dl->AddRectFilled(box_min, box_max, ImGui::GetColorU32(ImVec4(0.13f, 0.13f, 0.13f, opacity * 0.5f)), box_rounding);
   2164     dl->AddRect(box_min, box_max, ImGui::GetColorU32(ImVec4(0.8f, 0.8f, 0.8f, opacity)), box_rounding);
   2165 
   2166     GPUTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(indicator.badge_path);
   2167     if (badge)
   2168     {
   2169       const ImVec2 badge_pos = box_min + ImVec2(padding, padding);
   2170       dl->AddImage(badge, badge_pos, badge_pos + image_size, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), col);
   2171     }
   2172 
   2173     const ImVec2 text_pos =
   2174       box_min + ImVec2(padding + image_size.x + spacing, (box_max.y - box_min.y - text_size.y) * 0.5f);
   2175     const ImVec4 text_clip_rect(text_pos.x, text_pos.y, box_max.x, box_max.y);
   2176     dl->AddText(g_medium_font, g_medium_font->FontSize, text_pos, col, text_start, text_end, 0.0f, &text_clip_rect);
   2177 
   2178     if (!indicator.active && opacity <= 0.01f)
   2179     {
   2180       DEV_LOG("Remove progress indicator");
   2181       s_active_progress_indicator.reset();
   2182     }
   2183 
   2184     position.y -= image_size.y - padding * 3.0f;
   2185   }
   2186 
   2187   if (!s_active_leaderboard_trackers.empty())
   2188   {
   2189     for (auto it = s_active_leaderboard_trackers.begin(); it != s_active_leaderboard_trackers.end();)
   2190     {
   2191       const LeaderboardTrackerIndicator& indicator = *it;
   2192       const float opacity = IndicatorOpacity(indicator);
   2193 
   2194       TinyString width_string;
   2195       width_string.append(ICON_FA_STOPWATCH);
   2196       for (u32 i = 0; i < indicator.text.length(); i++)
   2197         width_string.append('0');
   2198       const ImVec2 size = ImGuiFullscreen::g_medium_font->CalcTextSizeA(
   2199         ImGuiFullscreen::g_medium_font->FontSize, FLT_MAX, 0.0f, width_string.c_str(), width_string.end_ptr());
   2200 
   2201       const ImVec2 box_min = ImVec2(position.x - size.x - padding * 2.0f, position.y - size.y - padding * 2.0f);
   2202       const ImVec2 box_max = position;
   2203       const float box_rounding = LayoutScale(1.0f);
   2204       dl->AddRectFilled(box_min, box_max, ImGui::GetColorU32(ImVec4(0.13f, 0.13f, 0.13f, opacity * 0.5f)),
   2205                         box_rounding);
   2206       dl->AddRect(box_min, box_max, ImGui::GetColorU32(ImVec4(0.8f, 0.8f, 0.8f, opacity)), box_rounding);
   2207 
   2208       const u32 text_col = ImGui::GetColorU32(ImVec4(1.0f, 1.0f, 1.0f, opacity));
   2209       const ImVec2 text_size = ImGuiFullscreen::g_medium_font->CalcTextSizeA(
   2210         ImGuiFullscreen::g_medium_font->FontSize, FLT_MAX, 0.0f, indicator.text.c_str(),
   2211         indicator.text.c_str() + indicator.text.length());
   2212       const ImVec2 text_pos = ImVec2(box_max.x - padding - text_size.x, box_min.y + padding);
   2213       const ImVec4 text_clip_rect(box_min.x, box_min.y, box_max.x, box_max.y);
   2214       dl->AddText(g_medium_font, g_medium_font->FontSize, text_pos, text_col, indicator.text.c_str(),
   2215                   indicator.text.c_str() + indicator.text.length(), 0.0f, &text_clip_rect);
   2216 
   2217       const ImVec2 icon_pos = ImVec2(box_min.x + padding, box_min.y + padding);
   2218       dl->AddText(g_medium_font, g_medium_font->FontSize, icon_pos, text_col, ICON_FA_STOPWATCH, nullptr, 0.0f,
   2219                   &text_clip_rect);
   2220 
   2221       if (!indicator.active && opacity <= 0.01f)
   2222       {
   2223         DEV_LOG("Remove tracker indicator");
   2224         it = s_active_leaderboard_trackers.erase(it);
   2225       }
   2226       else
   2227       {
   2228         ++it;
   2229       }
   2230 
   2231       position.x = box_min.x - padding;
   2232     }
   2233 
   2234     // Uncomment if there are any other overlays above this one.
   2235     // position.y -= image_size.y - padding * 3.0f;
   2236   }
   2237 }
   2238 
   2239 #ifndef __ANDROID__
   2240 
   2241 void Achievements::DrawPauseMenuOverlays()
   2242 {
   2243   using ImGuiFullscreen::g_large_font;
   2244   using ImGuiFullscreen::g_medium_font;
   2245   using ImGuiFullscreen::LayoutScale;
   2246 
   2247   if (!HasActiveGame())
   2248     return;
   2249 
   2250   const auto lock = GetLock();
   2251 
   2252   if (s_active_challenge_indicators.empty() && !s_active_progress_indicator.has_value())
   2253     return;
   2254 
   2255   const ImGuiIO& io = ImGui::GetIO();
   2256   ImFont* font = g_medium_font;
   2257 
   2258   const ImVec2 image_size(LayoutScale(ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY,
   2259                                       ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY));
   2260   const float start_y = LayoutScale(10.0f + 4.0f + 4.0f) + g_large_font->FontSize + (g_medium_font->FontSize * 2.0f);
   2261   const float margin = LayoutScale(10.0f);
   2262   const float spacing = LayoutScale(10.0f);
   2263   const float padding = LayoutScale(10.0f);
   2264 
   2265   const float max_text_width = ImGuiFullscreen::LayoutScale(300.0f);
   2266   const float row_width = max_text_width + padding + padding + image_size.x + spacing;
   2267   const float title_height = padding + font->FontSize + padding;
   2268 
   2269   if (!s_active_challenge_indicators.empty())
   2270   {
   2271     const ImVec2 box_min(io.DisplaySize.x - row_width - margin, start_y + margin);
   2272     const ImVec2 box_max(box_min.x + row_width,
   2273                          box_min.y + title_height +
   2274                            (static_cast<float>(s_active_challenge_indicators.size()) * (image_size.y + padding)));
   2275 
   2276     ImDrawList* dl = ImGui::GetBackgroundDrawList();
   2277     dl->AddRectFilled(box_min, box_max, IM_COL32(0x21, 0x21, 0x21, 200), LayoutScale(10.0f));
   2278     dl->AddText(font, font->FontSize, ImVec2(box_min.x + padding, box_min.y + padding), IM_COL32(255, 255, 255, 255),
   2279                 TRANSLATE("Achievements", "Active Challenge Achievements"));
   2280 
   2281     const float y_advance = image_size.y + spacing;
   2282     const float acheivement_name_offset = (image_size.y - font->FontSize) / 2.0f;
   2283     const float max_non_ellipised_text_width = max_text_width - LayoutScale(10.0f);
   2284     ImVec2 position(box_min.x + padding, box_min.y + title_height);
   2285 
   2286     for (const AchievementChallengeIndicator& indicator : s_active_challenge_indicators)
   2287     {
   2288       GPUTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(indicator.badge_path);
   2289       if (!badge)
   2290         continue;
   2291 
   2292       dl->AddImage(badge, position, position + image_size);
   2293 
   2294       const char* achievement_title = indicator.achievement->title;
   2295       const char* achievement_title_end = achievement_title + std::strlen(indicator.achievement->title);
   2296       const char* remaining_text = nullptr;
   2297       const ImVec2 text_width(font->CalcTextSizeA(font->FontSize, max_non_ellipised_text_width, 0.0f, achievement_title,
   2298                                                   achievement_title_end, &remaining_text));
   2299       const ImVec2 text_position(position.x + image_size.x + spacing, position.y + acheivement_name_offset);
   2300       const ImVec4 text_bbox(text_position.x, text_position.y, text_position.x + max_text_width,
   2301                              text_position.y + image_size.y);
   2302       const u32 text_color = IM_COL32(255, 255, 255, 255);
   2303 
   2304       if (remaining_text < achievement_title_end)
   2305       {
   2306         dl->AddText(font, font->FontSize, text_position, text_color, achievement_title, remaining_text, 0.0f,
   2307                     &text_bbox);
   2308         dl->AddText(font, font->FontSize, ImVec2(text_position.x + text_width.x, text_position.y), text_color, "...",
   2309                     nullptr, 0.0f, &text_bbox);
   2310       }
   2311       else
   2312       {
   2313         dl->AddText(font, font->FontSize, text_position, text_color, achievement_title, achievement_title_end, 0.0f,
   2314                     &text_bbox);
   2315       }
   2316 
   2317       position.y += y_advance;
   2318     }
   2319   }
   2320 }
   2321 
   2322 bool Achievements::PrepareAchievementsWindow()
   2323 {
   2324   auto lock = Achievements::GetLock();
   2325 
   2326   s_achievement_badge_paths = {};
   2327 
   2328   if (s_achievement_list)
   2329     rc_client_destroy_achievement_list(s_achievement_list);
   2330   s_achievement_list = rc_client_create_achievement_list(
   2331     s_client, RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL,
   2332     RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_PROGRESS /*RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE*/);
   2333   if (!s_achievement_list)
   2334   {
   2335     ERROR_LOG("rc_client_create_achievement_list() returned null");
   2336     return false;
   2337   }
   2338 
   2339   return true;
   2340 }
   2341 
   2342 void Achievements::DrawAchievementsWindow()
   2343 {
   2344   using ImGuiFullscreen::g_large_font;
   2345   using ImGuiFullscreen::g_medium_font;
   2346   using ImGuiFullscreen::LayoutScale;
   2347 
   2348   if (!s_achievement_list)
   2349     return;
   2350 
   2351   auto lock = Achievements::GetLock();
   2352 
   2353   static constexpr float alpha = 0.8f;
   2354   static constexpr float heading_alpha = 0.95f;
   2355   static constexpr float heading_height_unscaled = 110.0f;
   2356 
   2357   const ImVec4 background = ImGuiFullscreen::ModAlpha(ImGuiFullscreen::UIBackgroundColor, alpha);
   2358   const ImVec4 heading_background = ImGuiFullscreen::ModAlpha(ImGuiFullscreen::UIBackgroundColor, heading_alpha);
   2359   const ImVec2 display_size = ImGui::GetIO().DisplaySize;
   2360   const float heading_height = ImGuiFullscreen::LayoutScale(heading_height_unscaled);
   2361   bool close_window = false;
   2362 
   2363   if (ImGuiFullscreen::BeginFullscreenWindow(
   2364         ImVec2(), ImVec2(display_size.x, heading_height), "achievements_heading", heading_background, 0.0f, ImVec2(),
   2365         ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoScrollWithMouse))
   2366   {
   2367     ImRect bb;
   2368     bool visible, hovered;
   2369     ImGuiFullscreen::MenuButtonFrame("achievements_heading", false, heading_height_unscaled, &visible, &hovered,
   2370                                      &bb.Min, &bb.Max, 0, heading_alpha);
   2371     if (visible)
   2372     {
   2373       const float padding = ImGuiFullscreen::LayoutScale(10.0f);
   2374       const float spacing = ImGuiFullscreen::LayoutScale(10.0f);
   2375       const float image_height = ImGuiFullscreen::LayoutScale(85.0f);
   2376 
   2377       const ImVec2 icon_min(bb.Min + ImVec2(padding, padding));
   2378       const ImVec2 icon_max(icon_min + ImVec2(image_height, image_height));
   2379 
   2380       if (!s_game_icon.empty())
   2381       {
   2382         GPUTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(s_game_icon.c_str());
   2383         if (badge)
   2384         {
   2385           ImGui::GetWindowDrawList()->AddImage(badge, icon_min, icon_max, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f),
   2386                                                IM_COL32(255, 255, 255, 255));
   2387         }
   2388       }
   2389 
   2390       float left = bb.Min.x + padding + image_height + spacing;
   2391       float right = bb.Max.x - padding;
   2392       float top = bb.Min.y + padding;
   2393       ImDrawList* dl = ImGui::GetWindowDrawList();
   2394       SmallString text;
   2395       ImVec2 text_size;
   2396 
   2397       close_window = (ImGuiFullscreen::FloatingButton(ICON_FA_WINDOW_CLOSE, 10.0f, 10.0f, -1.0f, -1.0f, 1.0f, 0.0f,
   2398                                                       true, g_large_font) ||
   2399                       ImGuiFullscreen::WantsToCloseMenu());
   2400 
   2401       const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + g_large_font->FontSize));
   2402       text.assign(s_game_title);
   2403 
   2404       if (s_hardcore_mode)
   2405         text.append(TRANSLATE_SV("Achievements", " (Hardcore Mode)"));
   2406 
   2407       top += g_large_font->FontSize + spacing;
   2408 
   2409       ImGui::PushFont(g_large_font);
   2410       ImGui::RenderTextClipped(title_bb.Min, title_bb.Max, text.c_str(), text.end_ptr(), nullptr, ImVec2(0.0f, 0.0f),
   2411                                &title_bb);
   2412       ImGui::PopFont();
   2413 
   2414       const ImRect summary_bb(ImVec2(left, top), ImVec2(right, top + g_medium_font->FontSize));
   2415       if (s_game_summary.num_core_achievements > 0)
   2416       {
   2417         if (s_game_summary.num_unlocked_achievements == s_game_summary.num_core_achievements)
   2418         {
   2419           text = TRANSLATE_PLURAL_SSTR("Achievements", "You have unlocked all achievements and earned {} points!",
   2420                                        "Point count", s_game_summary.points_unlocked);
   2421         }
   2422         else
   2423         {
   2424           text.format(TRANSLATE_FS("Achievements",
   2425                                    "You have unlocked {0} of {1} achievements, earning {2} of {3} possible points."),
   2426                       s_game_summary.num_unlocked_achievements, s_game_summary.num_core_achievements,
   2427                       s_game_summary.points_unlocked, s_game_summary.points_core);
   2428         }
   2429       }
   2430       else
   2431       {
   2432         text.assign(TRANSLATE_SV("Achievements", "This game has no achievements."));
   2433       }
   2434 
   2435       top += g_medium_font->FontSize + spacing;
   2436 
   2437       ImGui::PushFont(g_medium_font);
   2438       ImGui::RenderTextClipped(summary_bb.Min, summary_bb.Max, text.c_str(), text.end_ptr(), nullptr,
   2439                                ImVec2(0.0f, 0.0f), &summary_bb);
   2440       ImGui::PopFont();
   2441 
   2442       if (s_game_summary.num_core_achievements > 0)
   2443       {
   2444         const float progress_height = ImGuiFullscreen::LayoutScale(20.0f);
   2445         const ImRect progress_bb(ImVec2(left, top), ImVec2(right, top + progress_height));
   2446         const float fraction = static_cast<float>(s_game_summary.num_unlocked_achievements) /
   2447                                static_cast<float>(s_game_summary.num_core_achievements);
   2448         dl->AddRectFilled(progress_bb.Min, progress_bb.Max, ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryDarkColor));
   2449         dl->AddRectFilled(progress_bb.Min,
   2450                           ImVec2(progress_bb.Min.x + fraction * progress_bb.GetWidth(), progress_bb.Max.y),
   2451                           ImGui::GetColorU32(ImGuiFullscreen::UISecondaryColor));
   2452 
   2453         text.format("{}%", static_cast<int>(std::round(fraction * 100.0f)));
   2454         text_size = ImGui::CalcTextSize(text.c_str(), text.end_ptr());
   2455         const ImVec2 text_pos(
   2456           progress_bb.Min.x + ((progress_bb.Max.x - progress_bb.Min.x) / 2.0f) - (text_size.x / 2.0f),
   2457           progress_bb.Min.y + ((progress_bb.Max.y - progress_bb.Min.y) / 2.0f) - (text_size.y / 2.0f));
   2458         dl->AddText(g_medium_font, g_medium_font->FontSize, text_pos,
   2459                     ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryTextColor), text.c_str(), text.end_ptr());
   2460         top += progress_height + spacing;
   2461       }
   2462     }
   2463   }
   2464   ImGuiFullscreen::EndFullscreenWindow();
   2465 
   2466   ImGui::SetNextWindowBgAlpha(alpha);
   2467 
   2468   // See note in FullscreenUI::DrawSettingsWindow().
   2469   if (ImGuiFullscreen::IsFocusResetFromWindowChange())
   2470     ImGui::SetNextWindowScroll(ImVec2(0.0f, 0.0f));
   2471 
   2472   if (ImGuiFullscreen::BeginFullscreenWindow(
   2473         ImVec2(0.0f, heading_height),
   2474         ImVec2(display_size.x, display_size.y - heading_height - LayoutScale(ImGuiFullscreen::LAYOUT_FOOTER_HEIGHT)),
   2475         "achievements", background, 0.0f, ImVec2(ImGuiFullscreen::LAYOUT_MENU_WINDOW_X_PADDING, 0.0f), 0))
   2476   {
   2477     static bool buckets_collapsed[NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS] = {};
   2478     static const char* bucket_names[NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS] = {
   2479       TRANSLATE_NOOP("Achievements", "Unknown"),           TRANSLATE_NOOP("Achievements", "Locked"),
   2480       TRANSLATE_NOOP("Achievements", "Unlocked"),          TRANSLATE_NOOP("Achievements", "Unsupported"),
   2481       TRANSLATE_NOOP("Achievements", "Unofficial"),        TRANSLATE_NOOP("Achievements", "Recently Unlocked"),
   2482       TRANSLATE_NOOP("Achievements", "Active Challenges"), TRANSLATE_NOOP("Achievements", "Almost There"),
   2483     };
   2484 
   2485     ImGuiFullscreen::ResetFocusHere();
   2486     ImGuiFullscreen::BeginMenuButtons();
   2487 
   2488     for (u32 bucket_type : {RC_CLIENT_ACHIEVEMENT_BUCKET_ACTIVE_CHALLENGE,
   2489                             RC_CLIENT_ACHIEVEMENT_BUCKET_RECENTLY_UNLOCKED, RC_CLIENT_ACHIEVEMENT_BUCKET_UNLOCKED,
   2490                             RC_CLIENT_ACHIEVEMENT_BUCKET_ALMOST_THERE, RC_CLIENT_ACHIEVEMENT_BUCKET_LOCKED,
   2491                             RC_CLIENT_ACHIEVEMENT_BUCKET_UNOFFICIAL, RC_CLIENT_ACHIEVEMENT_BUCKET_UNSUPPORTED})
   2492     {
   2493       for (u32 bucket_idx = 0; bucket_idx < s_achievement_list->num_buckets; bucket_idx++)
   2494       {
   2495         const rc_client_achievement_bucket_t& bucket = s_achievement_list->buckets[bucket_idx];
   2496         if (bucket.bucket_type != bucket_type)
   2497           continue;
   2498 
   2499         DebugAssert(bucket.bucket_type < NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS);
   2500 
   2501         // TODO: Once subsets are supported, this will need to change.
   2502         bool& bucket_collapsed = buckets_collapsed[bucket.bucket_type];
   2503         bucket_collapsed ^=
   2504           ImGuiFullscreen::MenuHeadingButton(Host::TranslateToCString("Achievements", bucket_names[bucket.bucket_type]),
   2505                                              bucket_collapsed ? ICON_FA_CHEVRON_DOWN : ICON_FA_CHEVRON_UP);
   2506         if (!bucket_collapsed)
   2507         {
   2508           for (u32 i = 0; i < bucket.num_achievements; i++)
   2509             DrawAchievement(bucket.achievements[i]);
   2510         }
   2511       }
   2512     }
   2513 
   2514     ImGuiFullscreen::EndMenuButtons();
   2515   }
   2516   ImGuiFullscreen::EndFullscreenWindow();
   2517 
   2518   FullscreenUI::SetStandardSelectionFooterText(true);
   2519 
   2520   if (close_window)
   2521     FullscreenUI::ReturnToPreviousWindow();
   2522 }
   2523 
   2524 void Achievements::DrawAchievement(const rc_client_achievement_t* cheevo)
   2525 {
   2526   using ImGuiFullscreen::g_large_font;
   2527   using ImGuiFullscreen::g_medium_font;
   2528   using ImGuiFullscreen::LayoutScale;
   2529   using ImGuiFullscreen::LayoutUnscale;
   2530 
   2531   static constexpr float alpha = 0.8f;
   2532   static constexpr float progress_height_unscaled = 20.0f;
   2533   static constexpr float progress_spacing_unscaled = 5.0f;
   2534 
   2535   const float spacing = ImGuiFullscreen::LayoutScale(4.0f);
   2536 
   2537   const bool is_unlocked = (cheevo->state == RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED);
   2538   const std::string_view measured_progress(cheevo->measured_progress);
   2539   const bool is_measured = !is_unlocked && !measured_progress.empty();
   2540   const float unlock_size = is_unlocked ? (spacing + ImGuiFullscreen::LAYOUT_MEDIUM_FONT_SIZE) : 0.0f;
   2541   const ImVec2 points_template_size(
   2542     g_medium_font->CalcTextSizeA(g_medium_font->FontSize, FLT_MAX, 0.0f, TRANSLATE("Achievements", "XXX points")));
   2543 
   2544   const size_t summary_length = std::strlen(cheevo->description);
   2545   const float summary_wrap_width =
   2546     (ImGui::GetCurrentWindow()->WorkRect.GetWidth() - (ImGui::GetStyle().FramePadding.x * 2.0f) -
   2547      LayoutScale(ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT + 30.0f) - points_template_size.x);
   2548   const ImVec2 summary_text_size(g_medium_font->CalcTextSizeA(
   2549     g_medium_font->FontSize, FLT_MAX, summary_wrap_width, cheevo->description, cheevo->description + summary_length));
   2550 
   2551   // Messy, but need to undo LayoutScale in MenuButtonFrame()...
   2552   const float extra_summary_height = LayoutUnscale(std::max(summary_text_size.y - g_medium_font->FontSize, 0.0f));
   2553 
   2554   ImRect bb;
   2555   bool visible, hovered;
   2556   const bool clicked = ImGuiFullscreen::MenuButtonFrame(
   2557     TinyString::from_format("chv_{}", cheevo->id), true,
   2558     !is_measured ? ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT + extra_summary_height + unlock_size :
   2559                    ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT + extra_summary_height + progress_height_unscaled +
   2560                      progress_spacing_unscaled,
   2561     &visible, &hovered, &bb.Min, &bb.Max, 0, alpha);
   2562   if (!visible)
   2563     return;
   2564 
   2565   std::string* badge_path;
   2566   if (const auto badge_it = std::find_if(s_achievement_badge_paths.begin(), s_achievement_badge_paths.end(),
   2567                                          [cheevo](const auto& it) { return (it.first == cheevo); });
   2568       badge_it != s_achievement_badge_paths.end())
   2569   {
   2570     badge_path = &badge_it->second;
   2571   }
   2572   else
   2573   {
   2574     std::string new_badge_path = Achievements::GetAchievementBadgePath(cheevo, cheevo->state);
   2575     badge_path = &s_achievement_badge_paths.emplace_back(cheevo, std::move(new_badge_path)).second;
   2576   }
   2577 
   2578   const ImVec2 image_size(
   2579     LayoutScale(ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT));
   2580   if (!badge_path->empty())
   2581   {
   2582     GPUTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(*badge_path);
   2583     if (badge)
   2584     {
   2585       ImGui::GetWindowDrawList()->AddImage(badge, bb.Min, bb.Min + image_size, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f),
   2586                                            IM_COL32(255, 255, 255, 255));
   2587     }
   2588   }
   2589 
   2590   SmallString text;
   2591 
   2592   const float midpoint = bb.Min.y + g_large_font->FontSize + spacing;
   2593   text = TRANSLATE_PLURAL_SSTR("Achievements", "%n points", "Achievement points", cheevo->points);
   2594   const ImVec2 points_size(
   2595     g_medium_font->CalcTextSizeA(g_medium_font->FontSize, FLT_MAX, 0.0f, text.c_str(), text.end_ptr()));
   2596   const float points_template_start = bb.Max.x - points_template_size.x;
   2597   const float points_start = points_template_start + ((points_template_size.x - points_size.x) * 0.5f);
   2598 
   2599   const char* right_icon_text;
   2600   switch (cheevo->type)
   2601   {
   2602     case RC_CLIENT_ACHIEVEMENT_TYPE_MISSABLE:
   2603       right_icon_text = ICON_PF_ACHIEVEMENTS_MISSABLE; // Missable
   2604       break;
   2605 
   2606     case RC_CLIENT_ACHIEVEMENT_TYPE_PROGRESSION:
   2607       right_icon_text = ICON_PF_ACHIEVEMENTS_PROGRESSION; // Progression
   2608       break;
   2609 
   2610     case RC_CLIENT_ACHIEVEMENT_TYPE_WIN:
   2611       right_icon_text = ICON_PF_ACHIEVEMENTS_WIN; // Win Condition
   2612       break;
   2613 
   2614       // Just use the lock for standard achievements.
   2615     case RC_CLIENT_ACHIEVEMENT_TYPE_STANDARD:
   2616     default:
   2617       right_icon_text = is_unlocked ? ICON_EMOJI_UNLOCKED : ICON_FA_LOCK;
   2618       break;
   2619   }
   2620 
   2621   const ImVec2 right_icon_size(g_large_font->CalcTextSizeA(g_large_font->FontSize, FLT_MAX, 0.0f, right_icon_text));
   2622 
   2623   const float text_start_x = bb.Min.x + image_size.x + LayoutScale(15.0f);
   2624   const ImRect title_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(points_start, midpoint));
   2625   const ImRect summary_bb(ImVec2(text_start_x, midpoint),
   2626                           ImVec2(points_start, midpoint + g_medium_font->FontSize + extra_summary_height));
   2627   const ImRect points_bb(ImVec2(points_start, midpoint), bb.Max);
   2628   const ImRect lock_bb(ImVec2(points_template_start + ((points_template_size.x - right_icon_size.x) * 0.5f), bb.Min.y),
   2629                        ImVec2(bb.Max.x, midpoint));
   2630 
   2631   ImGui::PushFont(g_large_font);
   2632   ImGui::RenderTextClipped(title_bb.Min, title_bb.Max, cheevo->title, nullptr, nullptr, ImVec2(0.0f, 0.0f), &title_bb);
   2633   ImGui::RenderTextClipped(lock_bb.Min, lock_bb.Max, right_icon_text, nullptr, &right_icon_size, ImVec2(0.0f, 0.0f),
   2634                            &lock_bb);
   2635   ImGui::PopFont();
   2636 
   2637   ImGui::PushFont(g_medium_font);
   2638   if (cheevo->description && summary_length > 0)
   2639   {
   2640     ImGui::RenderTextWrapped(summary_bb.Min, cheevo->description, cheevo->description + summary_length,
   2641                              summary_wrap_width);
   2642   }
   2643   ImGui::RenderTextClipped(points_bb.Min, points_bb.Max, text.c_str(), text.end_ptr(), &points_size, ImVec2(0.0f, 0.0f),
   2644                            &points_bb);
   2645 
   2646   if (is_unlocked)
   2647   {
   2648     TinyString date;
   2649     FullscreenUI::TimeToPrintableString(&date, cheevo->unlock_time);
   2650     text.format(TRANSLATE_FS("Achievements", "Unlocked: {}"), date);
   2651 
   2652     const ImRect unlock_bb(summary_bb.Min.x, summary_bb.Max.y + spacing, summary_bb.Max.x, bb.Max.y);
   2653     ImGui::RenderTextClipped(unlock_bb.Min, unlock_bb.Max, text.c_str(), text.end_ptr(), nullptr, ImVec2(0.0f, 0.0f),
   2654                              &unlock_bb);
   2655   }
   2656   else if (is_measured)
   2657   {
   2658     ImDrawList* dl = ImGui::GetWindowDrawList();
   2659     const float progress_height = LayoutScale(progress_height_unscaled);
   2660     const float progress_spacing = LayoutScale(progress_spacing_unscaled);
   2661     const float top = midpoint + g_medium_font->FontSize + progress_spacing;
   2662     const ImRect progress_bb(ImVec2(text_start_x, top), ImVec2(bb.Max.x, top + progress_height));
   2663     const float fraction = cheevo->measured_percent * 0.01f;
   2664     dl->AddRectFilled(progress_bb.Min, progress_bb.Max, ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryDarkColor));
   2665     dl->AddRectFilled(progress_bb.Min, ImVec2(progress_bb.Min.x + fraction * progress_bb.GetWidth(), progress_bb.Max.y),
   2666                       ImGui::GetColorU32(ImGuiFullscreen::UISecondaryColor));
   2667 
   2668     const ImVec2 text_size =
   2669       ImGui::CalcTextSize(measured_progress.data(), measured_progress.data() + measured_progress.size());
   2670     const ImVec2 text_pos(progress_bb.Min.x + ((progress_bb.Max.x - progress_bb.Min.x) / 2.0f) - (text_size.x / 2.0f),
   2671                           progress_bb.Min.y + ((progress_bb.Max.y - progress_bb.Min.y) / 2.0f) - (text_size.y / 2.0f));
   2672     dl->AddText(g_medium_font, g_medium_font->FontSize, text_pos,
   2673                 ImGui::GetColorU32(ImGuiFullscreen::UIPrimaryTextColor), measured_progress.data(),
   2674                 measured_progress.data() + measured_progress.size());
   2675   }
   2676 
   2677   if (clicked)
   2678   {
   2679     const SmallString url = SmallString::from_format(fmt::runtime(ACHEIVEMENT_DETAILS_URL_TEMPLATE), cheevo->id);
   2680     INFO_LOG("Opening achievement details: {}", url);
   2681     Host::OpenURL(url);
   2682   }
   2683 
   2684   ImGui::PopFont();
   2685 }
   2686 
   2687 bool Achievements::PrepareLeaderboardsWindow()
   2688 {
   2689   auto lock = Achievements::GetLock();
   2690   rc_client_t* const client = s_client;
   2691 
   2692   s_achievement_badge_paths = {};
   2693   CloseLeaderboard();
   2694   if (s_leaderboard_list)
   2695     rc_client_destroy_leaderboard_list(s_leaderboard_list);
   2696   s_leaderboard_list = rc_client_create_leaderboard_list(client, RC_CLIENT_LEADERBOARD_LIST_GROUPING_NONE);
   2697   if (!s_leaderboard_list)
   2698   {
   2699     ERROR_LOG("rc_client_create_leaderboard_list() returned null");
   2700     return false;
   2701   }
   2702 
   2703   return true;
   2704 }
   2705 
   2706 void Achievements::DrawLeaderboardsWindow()
   2707 {
   2708   using ImGuiFullscreen::g_large_font;
   2709   using ImGuiFullscreen::g_medium_font;
   2710   using ImGuiFullscreen::LayoutScale;
   2711 
   2712   static constexpr float alpha = 0.8f;
   2713   static constexpr float heading_alpha = 0.95f;
   2714   static constexpr float heading_height_unscaled = 110.0f;
   2715   static constexpr float tab_height_unscaled = 50.0f;
   2716 
   2717   auto lock = Achievements::GetLock();
   2718 
   2719   const bool is_leaderboard_open = (s_open_leaderboard != nullptr);
   2720   bool close_leaderboard_on_exit = false;
   2721 
   2722   ImRect bb;
   2723 
   2724   const ImVec4 background = ImGuiFullscreen::ModAlpha(ImGuiFullscreen::UIBackgroundColor, alpha);
   2725   const ImVec4 heading_background = ImGuiFullscreen::ModAlpha(ImGuiFullscreen::UIBackgroundColor, heading_alpha);
   2726   const ImVec2 display_size = ImGui::GetIO().DisplaySize;
   2727   const float padding = LayoutScale(10.0f);
   2728   const float spacing = LayoutScale(10.0f);
   2729   const float spacing_small = spacing / 2.0f;
   2730   float heading_height = LayoutScale(heading_height_unscaled);
   2731   if (is_leaderboard_open)
   2732   {
   2733     // tabs
   2734     heading_height += spacing_small + LayoutScale(tab_height_unscaled) + spacing;
   2735 
   2736     // Add space for a legend - spacing + 1 line of text + spacing + line
   2737     heading_height += LayoutScale(ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY) + spacing;
   2738   }
   2739 
   2740   const float rank_column_width =
   2741     g_large_font->CalcTextSizeA(g_large_font->FontSize, std::numeric_limits<float>::max(), -1.0f, "99999").x;
   2742   const float name_column_width =
   2743     g_large_font
   2744       ->CalcTextSizeA(g_large_font->FontSize, std::numeric_limits<float>::max(), -1.0f, "WWWWWWWWWWWWWWWWWWWWWW")
   2745       .x;
   2746   const float time_column_width =
   2747     g_large_font->CalcTextSizeA(g_large_font->FontSize, std::numeric_limits<float>::max(), -1.0f, "WWWWWWWWWWW").x;
   2748   const float column_spacing = spacing * 2.0f;
   2749 
   2750   if (ImGuiFullscreen::BeginFullscreenWindow(
   2751         ImVec2(), ImVec2(display_size.x, heading_height), "leaderboards_heading", heading_background, 0.0f, ImVec2(),
   2752         ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoScrollWithMouse))
   2753   {
   2754     bool visible, hovered;
   2755     bool pressed = ImGuiFullscreen::MenuButtonFrame("leaderboards_heading", false, heading_height_unscaled, &visible,
   2756                                                     &hovered, &bb.Min, &bb.Max, 0, alpha);
   2757     UNREFERENCED_VARIABLE(pressed);
   2758 
   2759     if (visible)
   2760     {
   2761       const float image_height = LayoutScale(85.0f);
   2762 
   2763       const ImVec2 icon_min(bb.Min + ImVec2(padding, padding));
   2764       const ImVec2 icon_max(icon_min + ImVec2(image_height, image_height));
   2765 
   2766       if (!s_game_icon.empty())
   2767       {
   2768         GPUTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(s_game_icon.c_str());
   2769         if (badge)
   2770         {
   2771           ImGui::GetWindowDrawList()->AddImage(badge, icon_min, icon_max, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f),
   2772                                                IM_COL32(255, 255, 255, 255));
   2773         }
   2774       }
   2775 
   2776       float left = bb.Min.x + padding + image_height + spacing;
   2777       float right = bb.Max.x - padding;
   2778       float top = bb.Min.y + padding;
   2779       SmallString text;
   2780 
   2781       if (!is_leaderboard_open)
   2782       {
   2783         if (ImGuiFullscreen::FloatingButton(ICON_FA_WINDOW_CLOSE, 10.0f, 10.0f, -1.0f, -1.0f, 1.0f, 0.0f, true,
   2784                                             g_large_font) ||
   2785             ImGuiFullscreen::WantsToCloseMenu())
   2786         {
   2787           FullscreenUI::ReturnToPreviousWindow();
   2788         }
   2789       }
   2790       else
   2791       {
   2792         if (ImGuiFullscreen::FloatingButton(ICON_FA_CARET_SQUARE_LEFT, 10.0f, 10.0f, -1.0f, -1.0f, 1.0f, 0.0f, true,
   2793                                             g_large_font) ||
   2794             ImGuiFullscreen::WantsToCloseMenu())
   2795         {
   2796           close_leaderboard_on_exit = true;
   2797         }
   2798       }
   2799 
   2800       const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + g_large_font->FontSize));
   2801       text.assign(Achievements::GetGameTitle());
   2802 
   2803       top += g_large_font->FontSize + spacing;
   2804 
   2805       ImGui::PushFont(g_large_font);
   2806       ImGui::RenderTextClipped(title_bb.Min, title_bb.Max, text.c_str(), text.end_ptr(), nullptr, ImVec2(0.0f, 0.0f),
   2807                                &title_bb);
   2808       ImGui::PopFont();
   2809 
   2810       if (is_leaderboard_open)
   2811       {
   2812         const ImRect subtitle_bb(ImVec2(left, top), ImVec2(right, top + g_large_font->FontSize));
   2813         text.assign(s_open_leaderboard->title);
   2814 
   2815         top += g_large_font->FontSize + spacing_small;
   2816 
   2817         ImGui::PushFont(g_large_font);
   2818         ImGui::RenderTextClipped(subtitle_bb.Min, subtitle_bb.Max, text.c_str(), text.end_ptr(), nullptr,
   2819                                  ImVec2(0.0f, 0.0f), &subtitle_bb);
   2820         ImGui::PopFont();
   2821 
   2822         text.assign(s_open_leaderboard->description);
   2823       }
   2824       else
   2825       {
   2826         u32 count = 0;
   2827         for (u32 i = 0; i < s_leaderboard_list->num_buckets; i++)
   2828           count += s_leaderboard_list->buckets[i].num_leaderboards;
   2829         text = TRANSLATE_PLURAL_SSTR("Achievements", "This game has %n leaderboards.", "Leaderboard count", count);
   2830       }
   2831 
   2832       const ImRect summary_bb(ImVec2(left, top), ImVec2(right, top + g_medium_font->FontSize));
   2833       top += g_medium_font->FontSize + spacing_small;
   2834 
   2835       ImGui::PushFont(g_medium_font);
   2836       ImGui::RenderTextClipped(summary_bb.Min, summary_bb.Max, text.c_str(), text.end_ptr(), nullptr,
   2837                                ImVec2(0.0f, 0.0f), &summary_bb);
   2838 
   2839       if (!is_leaderboard_open && !Achievements::IsHardcoreModeActive())
   2840       {
   2841         const ImRect hardcore_warning_bb(ImVec2(left, top), ImVec2(right, top + g_medium_font->FontSize));
   2842         top += g_medium_font->FontSize + spacing_small;
   2843 
   2844         ImGui::RenderTextClipped(
   2845           hardcore_warning_bb.Min, hardcore_warning_bb.Max,
   2846           TRANSLATE("Achievements",
   2847                     "Submitting scores is disabled because hardcore mode is off. Leaderboards are read-only."),
   2848           nullptr, nullptr, ImVec2(0.0f, 0.0f), &hardcore_warning_bb);
   2849       }
   2850 
   2851       ImGui::PopFont();
   2852 
   2853       if (is_leaderboard_open)
   2854       {
   2855         const float tab_width = (ImGui::GetWindowWidth() / ImGuiFullscreen::g_layout_scale) * 0.5f;
   2856         ImGui::SetCursorPos(ImVec2(0.0f, top + spacing_small));
   2857 
   2858         if (ImGui::IsKeyPressed(ImGuiKey_GamepadDpadLeft, false) ||
   2859             ImGui::IsKeyPressed(ImGuiKey_NavGamepadTweakSlow, false) ||
   2860             ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false) || ImGui::IsKeyPressed(ImGuiKey_GamepadDpadRight, false) ||
   2861             ImGui::IsKeyPressed(ImGuiKey_NavGamepadTweakFast, false) || ImGui::IsKeyPressed(ImGuiKey_RightArrow, false))
   2862         {
   2863           s_is_showing_all_leaderboard_entries = !s_is_showing_all_leaderboard_entries;
   2864           ImGuiFullscreen::QueueResetFocus(ImGuiFullscreen::FocusResetType::Other);
   2865         }
   2866 
   2867         for (const bool show_all : {false, true})
   2868         {
   2869           const char* title =
   2870             show_all ? TRANSLATE("Achievements", "Show Best") : TRANSLATE("Achievements", "Show Nearby");
   2871           if (ImGuiFullscreen::NavTab(title, s_is_showing_all_leaderboard_entries == show_all, true, tab_width,
   2872                                       tab_height_unscaled, heading_background))
   2873           {
   2874             s_is_showing_all_leaderboard_entries = show_all;
   2875           }
   2876         }
   2877 
   2878         const ImVec2 bg_pos =
   2879           ImVec2(0.0f, ImGui::GetCurrentWindow()->DC.CursorPos.y + LayoutScale(tab_height_unscaled));
   2880         const ImVec2 bg_size =
   2881           ImVec2(ImGui::GetWindowWidth(),
   2882                  spacing + LayoutScale(ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY) + spacing);
   2883         ImGui::GetWindowDrawList()->AddRectFilled(bg_pos, bg_pos + bg_size, ImGui::GetColorU32(heading_background));
   2884 
   2885         ImGui::SetCursorPos(ImVec2(0.0f, ImGui::GetCursorPosY() + LayoutScale(tab_height_unscaled) + spacing));
   2886 
   2887         pressed =
   2888           ImGuiFullscreen::MenuButtonFrame("legend", false, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY,
   2889                                            &visible, &hovered, &bb.Min, &bb.Max, 0, alpha);
   2890         UNREFERENCED_VARIABLE(pressed);
   2891 
   2892         const float midpoint = bb.Min.y + g_large_font->FontSize + LayoutScale(4.0f);
   2893         float text_start_x = bb.Min.x + LayoutScale(15.0f) + padding;
   2894 
   2895         ImGui::PushFont(g_large_font);
   2896 
   2897         const ImRect rank_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
   2898         ImGui::RenderTextClipped(rank_bb.Min, rank_bb.Max, TRANSLATE("Achievements", "Rank"), nullptr, nullptr,
   2899                                  ImVec2(0.0f, 0.0f), &rank_bb);
   2900         text_start_x += rank_column_width + column_spacing;
   2901 
   2902         const ImRect user_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
   2903         ImGui::RenderTextClipped(user_bb.Min, user_bb.Max, TRANSLATE("Achievements", "Name"), nullptr, nullptr,
   2904                                  ImVec2(0.0f, 0.0f), &user_bb);
   2905         text_start_x += name_column_width + column_spacing;
   2906 
   2907         static const char* value_headings[NUM_RC_CLIENT_LEADERBOARD_FORMATS] = {
   2908           TRANSLATE_NOOP("Achievements", "Time"),
   2909           TRANSLATE_NOOP("Achievements", "Score"),
   2910           TRANSLATE_NOOP("Achievements", "Value"),
   2911         };
   2912 
   2913         const ImRect score_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
   2914         ImGui::RenderTextClipped(
   2915           score_bb.Min, score_bb.Max,
   2916           Host::TranslateToCString(
   2917             "Achievements",
   2918             value_headings[std::min<u8>(s_open_leaderboard->format, NUM_RC_CLIENT_LEADERBOARD_FORMATS - 1)]),
   2919           nullptr, nullptr, ImVec2(0.0f, 0.0f), &score_bb);
   2920         text_start_x += time_column_width + column_spacing;
   2921 
   2922         const ImRect date_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
   2923         ImGui::RenderTextClipped(date_bb.Min, date_bb.Max, TRANSLATE("Achievements", "Date Submitted"), nullptr,
   2924                                  nullptr, ImVec2(0.0f, 0.0f), &date_bb);
   2925 
   2926         ImGui::PopFont();
   2927 
   2928         const float line_thickness = LayoutScale(1.0f);
   2929         const float line_padding = LayoutScale(5.0f);
   2930         const ImVec2 line_start(bb.Min.x, bb.Min.y + g_large_font->FontSize + line_padding);
   2931         const ImVec2 line_end(bb.Max.x, line_start.y);
   2932         ImGui::GetWindowDrawList()->AddLine(line_start, line_end, ImGui::GetColorU32(ImGuiCol_TextDisabled),
   2933                                             line_thickness);
   2934       }
   2935     }
   2936   }
   2937   ImGuiFullscreen::EndFullscreenWindow();
   2938   FullscreenUI::SetStandardSelectionFooterText(true);
   2939 
   2940   // See note in FullscreenUI::DrawSettingsWindow().
   2941   if (ImGuiFullscreen::IsFocusResetFromWindowChange())
   2942     ImGui::SetNextWindowScroll(ImVec2(0.0f, 0.0f));
   2943 
   2944   if (!is_leaderboard_open)
   2945   {
   2946     if (ImGuiFullscreen::BeginFullscreenWindow(
   2947           ImVec2(0.0f, heading_height),
   2948           ImVec2(display_size.x, display_size.y - heading_height - LayoutScale(ImGuiFullscreen::LAYOUT_FOOTER_HEIGHT)),
   2949           "leaderboards", background, 0.0f, ImVec2(ImGuiFullscreen::LAYOUT_MENU_WINDOW_X_PADDING, 0.0f), 0))
   2950     {
   2951       ImGuiFullscreen::ResetFocusHere();
   2952       ImGuiFullscreen::BeginMenuButtons();
   2953 
   2954       for (u32 bucket_index = 0; bucket_index < s_leaderboard_list->num_buckets; bucket_index++)
   2955       {
   2956         const rc_client_leaderboard_bucket_t& bucket = s_leaderboard_list->buckets[bucket_index];
   2957         for (u32 i = 0; i < bucket.num_leaderboards; i++)
   2958           DrawLeaderboardListEntry(bucket.leaderboards[i]);
   2959       }
   2960 
   2961       ImGuiFullscreen::EndMenuButtons();
   2962     }
   2963     ImGuiFullscreen::EndFullscreenWindow();
   2964   }
   2965   else
   2966   {
   2967     if (ImGuiFullscreen::BeginFullscreenWindow(
   2968           ImVec2(0.0f, heading_height),
   2969           ImVec2(display_size.x, display_size.y - heading_height - LayoutScale(ImGuiFullscreen::LAYOUT_FOOTER_HEIGHT)),
   2970           "leaderboard", background, 0.0f, ImVec2(ImGuiFullscreen::LAYOUT_MENU_WINDOW_X_PADDING, 0.0f), 0))
   2971     {
   2972       // Defer focus reset until loading finishes.
   2973       if (!s_is_showing_all_leaderboard_entries ||
   2974           (ImGuiFullscreen::IsFocusResetFromWindowChange() && !s_leaderboard_entry_lists.empty()))
   2975       {
   2976         ImGuiFullscreen::ResetFocusHere();
   2977       }
   2978 
   2979       ImGuiFullscreen::BeginMenuButtons();
   2980 
   2981       if (!s_is_showing_all_leaderboard_entries)
   2982       {
   2983         if (s_leaderboard_nearby_entries)
   2984         {
   2985           for (u32 i = 0; i < s_leaderboard_nearby_entries->num_entries; i++)
   2986           {
   2987             DrawLeaderboardEntry(s_leaderboard_nearby_entries->entries[i],
   2988                                  static_cast<s32>(i) == s_leaderboard_nearby_entries->user_index, rank_column_width,
   2989                                  name_column_width, time_column_width, column_spacing);
   2990           }
   2991         }
   2992         else
   2993         {
   2994           ImGui::PushFont(g_large_font);
   2995 
   2996           const ImVec2 pos_min(0.0f, heading_height);
   2997           const ImVec2 pos_max(display_size.x, display_size.y);
   2998           ImGui::RenderTextClipped(pos_min, pos_max,
   2999                                    TRANSLATE("Achievements", "Downloading leaderboard data, please wait..."), nullptr,
   3000                                    nullptr, ImVec2(0.5f, 0.5f));
   3001 
   3002           ImGui::PopFont();
   3003         }
   3004       }
   3005       else
   3006       {
   3007         for (const rc_client_leaderboard_entry_list_t* list : s_leaderboard_entry_lists)
   3008         {
   3009           for (u32 i = 0; i < list->num_entries; i++)
   3010           {
   3011             DrawLeaderboardEntry(list->entries[i], static_cast<s32>(i) == list->user_index, rank_column_width,
   3012                                  name_column_width, time_column_width, column_spacing);
   3013           }
   3014         }
   3015 
   3016         // Fetch next chunk if the loading indicator becomes visible (i.e. we scrolled enough).
   3017         bool visible, hovered;
   3018         ImGuiFullscreen::MenuButtonFrame(TRANSLATE("Achievements", "Loading..."), false,
   3019                                          ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY, &visible, &hovered,
   3020                                          &bb.Min, &bb.Max);
   3021         if (visible)
   3022         {
   3023           const float midpoint = bb.Min.y + g_large_font->FontSize + LayoutScale(4.0f);
   3024           const ImRect title_bb(bb.Min, ImVec2(bb.Max.x, midpoint));
   3025 
   3026           ImGui::PushFont(g_large_font);
   3027           ImGui::RenderTextClipped(title_bb.Min, title_bb.Max, TRANSLATE("Achievements", "Loading..."), nullptr,
   3028                                    nullptr, ImVec2(0, 0), &title_bb);
   3029           ImGui::PopFont();
   3030 
   3031           if (!s_leaderboard_fetch_handle)
   3032             FetchNextLeaderboardEntries();
   3033         }
   3034       }
   3035 
   3036       ImGuiFullscreen::EndMenuButtons();
   3037     }
   3038     ImGuiFullscreen::EndFullscreenWindow();
   3039   }
   3040 
   3041   if (close_leaderboard_on_exit)
   3042     CloseLeaderboard();
   3043 }
   3044 
   3045 void Achievements::DrawLeaderboardEntry(const rc_client_leaderboard_entry_t& entry, bool is_self,
   3046                                         float rank_column_width, float name_column_width, float time_column_width,
   3047                                         float column_spacing)
   3048 {
   3049   using ImGuiFullscreen::g_large_font;
   3050   using ImGuiFullscreen::LayoutScale;
   3051 
   3052   static constexpr float alpha = 0.8f;
   3053 
   3054   ImRect bb;
   3055   bool visible, hovered;
   3056   bool pressed =
   3057     ImGuiFullscreen::MenuButtonFrame(entry.user, true, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT_NO_SUMMARY, &visible,
   3058                                      &hovered, &bb.Min, &bb.Max, 0, alpha);
   3059   if (!visible)
   3060     return;
   3061 
   3062   const float midpoint = bb.Min.y + g_large_font->FontSize + LayoutScale(4.0f);
   3063   float text_start_x = bb.Min.x + LayoutScale(15.0f);
   3064   SmallString text;
   3065 
   3066   text.format("{}", entry.rank);
   3067 
   3068   ImGui::PushFont(g_large_font);
   3069 
   3070   if (is_self)
   3071     ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(255, 242, 0, 255));
   3072 
   3073   const ImRect rank_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
   3074   ImGui::RenderTextClipped(rank_bb.Min, rank_bb.Max, text.c_str(), text.end_ptr(), nullptr, ImVec2(0.0f, 0.0f),
   3075                            &rank_bb);
   3076   text_start_x += rank_column_width + column_spacing;
   3077 
   3078   const float icon_size = bb.Max.y - bb.Min.y;
   3079   const ImRect icon_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
   3080   GPUTexture* icon_tex = nullptr;
   3081   if (auto it = std::find_if(s_leaderboard_user_icon_paths.begin(), s_leaderboard_user_icon_paths.end(),
   3082                              [&entry](const auto& it) { return it.first == &entry; });
   3083       it != s_leaderboard_user_icon_paths.end())
   3084   {
   3085     if (!it->second.empty())
   3086       icon_tex = ImGuiFullscreen::GetCachedTextureAsync(it->second);
   3087   }
   3088   else
   3089   {
   3090     std::string path = Achievements::GetLeaderboardUserBadgePath(&entry);
   3091     if (!path.empty())
   3092     {
   3093       icon_tex = ImGuiFullscreen::GetCachedTextureAsync(path);
   3094       s_leaderboard_user_icon_paths.emplace_back(&entry, std::move(path));
   3095     }
   3096   }
   3097   if (icon_tex)
   3098   {
   3099     ImGui::GetWindowDrawList()->AddImage(reinterpret_cast<ImTextureID>(icon_tex), icon_bb.Min,
   3100                                          icon_bb.Min + ImVec2(icon_size, icon_size));
   3101   }
   3102 
   3103   const ImRect user_bb(ImVec2(text_start_x + column_spacing + icon_size, bb.Min.y), ImVec2(bb.Max.x, midpoint));
   3104   ImGui::RenderTextClipped(user_bb.Min, user_bb.Max, entry.user, nullptr, nullptr, ImVec2(0.0f, 0.0f), &user_bb);
   3105   text_start_x += name_column_width + column_spacing;
   3106 
   3107   const ImRect score_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
   3108   ImGui::RenderTextClipped(score_bb.Min, score_bb.Max, entry.display, nullptr, nullptr, ImVec2(0.0f, 0.0f), &score_bb);
   3109   text_start_x += time_column_width + column_spacing;
   3110 
   3111   const ImRect time_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
   3112   SmallString submit_time;
   3113   FullscreenUI::TimeToPrintableString(&submit_time, entry.submitted);
   3114   ImGui::RenderTextClipped(time_bb.Min, time_bb.Max, submit_time.c_str(), submit_time.end_ptr(), nullptr,
   3115                            ImVec2(0.0f, 0.0f), &time_bb);
   3116 
   3117   if (is_self)
   3118     ImGui::PopStyleColor();
   3119 
   3120   ImGui::PopFont();
   3121 
   3122   if (pressed)
   3123   {
   3124     // Anything?
   3125   }
   3126 }
   3127 void Achievements::DrawLeaderboardListEntry(const rc_client_leaderboard_t* lboard)
   3128 {
   3129   using ImGuiFullscreen::g_large_font;
   3130   using ImGuiFullscreen::g_medium_font;
   3131   using ImGuiFullscreen::LayoutScale;
   3132 
   3133   static constexpr float alpha = 0.8f;
   3134 
   3135   TinyString id_str;
   3136   id_str.format("{}", lboard->id);
   3137 
   3138   ImRect bb;
   3139   bool visible, hovered;
   3140   bool pressed = ImGuiFullscreen::MenuButtonFrame(id_str, true, ImGuiFullscreen::LAYOUT_MENU_BUTTON_HEIGHT, &visible,
   3141                                                   &hovered, &bb.Min, &bb.Max, 0, alpha);
   3142   if (!visible)
   3143     return;
   3144 
   3145   const float midpoint = bb.Min.y + g_large_font->FontSize + LayoutScale(4.0f);
   3146   const float text_start_x = bb.Min.x + LayoutScale(15.0f);
   3147   const ImRect title_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
   3148   const ImRect summary_bb(ImVec2(text_start_x, midpoint), bb.Max);
   3149 
   3150   ImGui::PushFont(g_large_font);
   3151   ImGui::RenderTextClipped(title_bb.Min, title_bb.Max, lboard->title, nullptr, nullptr, ImVec2(0.0f, 0.0f), &title_bb);
   3152   ImGui::PopFont();
   3153 
   3154   if (lboard->description && lboard->description[0] != '\0')
   3155   {
   3156     ImGui::PushFont(g_medium_font);
   3157     ImGui::RenderTextClipped(summary_bb.Min, summary_bb.Max, lboard->description, nullptr, nullptr, ImVec2(0.0f, 0.0f),
   3158                              &summary_bb);
   3159     ImGui::PopFont();
   3160   }
   3161 
   3162   if (pressed)
   3163     OpenLeaderboard(lboard);
   3164 }
   3165 
   3166 #endif // __ANDROID__
   3167 
   3168 void Achievements::OpenLeaderboard(const rc_client_leaderboard_t* lboard)
   3169 {
   3170   DEV_LOG("Opening leaderboard '{}' ({})", lboard->title, lboard->id);
   3171 
   3172   CloseLeaderboard();
   3173 
   3174   s_open_leaderboard = lboard;
   3175   s_is_showing_all_leaderboard_entries = false;
   3176   s_leaderboard_fetch_handle = rc_client_begin_fetch_leaderboard_entries_around_user(
   3177     s_client, lboard->id, LEADERBOARD_NEARBY_ENTRIES_TO_FETCH, LeaderboardFetchNearbyCallback, nullptr);
   3178   ImGuiFullscreen::QueueResetFocus(ImGuiFullscreen::FocusResetType::Other);
   3179 }
   3180 
   3181 bool Achievements::OpenLeaderboardById(u32 leaderboard_id)
   3182 {
   3183   const rc_client_leaderboard_t* lb = rc_client_get_leaderboard_info(s_client, leaderboard_id);
   3184   if (!lb)
   3185     return false;
   3186 
   3187   OpenLeaderboard(lb);
   3188   return true;
   3189 }
   3190 
   3191 u32 Achievements::GetOpenLeaderboardId()
   3192 {
   3193   return s_open_leaderboard ? s_open_leaderboard->id : 0;
   3194 }
   3195 
   3196 bool Achievements::IsShowingAllLeaderboardEntries()
   3197 {
   3198   return s_is_showing_all_leaderboard_entries;
   3199 }
   3200 
   3201 const std::vector<rc_client_leaderboard_entry_list_t*>& Achievements::GetLeaderboardEntryLists()
   3202 {
   3203   return s_leaderboard_entry_lists;
   3204 }
   3205 
   3206 const rc_client_leaderboard_entry_list_t* Achievements::GetLeaderboardNearbyEntries()
   3207 {
   3208   return s_leaderboard_nearby_entries;
   3209 }
   3210 
   3211 void Achievements::LeaderboardFetchNearbyCallback(int result, const char* error_message,
   3212                                                   rc_client_leaderboard_entry_list_t* list, rc_client_t* client,
   3213                                                   void* callback_userdata)
   3214 {
   3215   const auto lock = GetLock();
   3216 
   3217   s_leaderboard_fetch_handle = nullptr;
   3218 
   3219   if (result != RC_OK)
   3220   {
   3221     ImGuiFullscreen::ShowToast(TRANSLATE("Achievements", "Leaderboard download failed"), error_message);
   3222     CloseLeaderboard();
   3223     return;
   3224   }
   3225 
   3226   if (s_leaderboard_nearby_entries)
   3227     rc_client_destroy_leaderboard_entry_list(s_leaderboard_nearby_entries);
   3228   s_leaderboard_nearby_entries = list;
   3229 }
   3230 
   3231 void Achievements::LeaderboardFetchAllCallback(int result, const char* error_message,
   3232                                                rc_client_leaderboard_entry_list_t* list, rc_client_t* client,
   3233                                                void* callback_userdata)
   3234 {
   3235   const auto lock = GetLock();
   3236 
   3237   s_leaderboard_fetch_handle = nullptr;
   3238 
   3239   if (result != RC_OK)
   3240   {
   3241     ImGuiFullscreen::ShowToast(TRANSLATE("Achievements", "Leaderboard download failed"), error_message);
   3242     CloseLeaderboard();
   3243     return;
   3244   }
   3245 
   3246   s_leaderboard_entry_lists.push_back(list);
   3247 }
   3248 
   3249 void Achievements::FetchNextLeaderboardEntries()
   3250 {
   3251   u32 start = 1;
   3252   for (rc_client_leaderboard_entry_list_t* list : s_leaderboard_entry_lists)
   3253     start += list->num_entries;
   3254 
   3255   DEV_LOG("Fetching entries {} to {}", start, start + LEADERBOARD_ALL_FETCH_SIZE);
   3256 
   3257   if (s_leaderboard_fetch_handle)
   3258     rc_client_abort_async(s_client, s_leaderboard_fetch_handle);
   3259   s_leaderboard_fetch_handle = rc_client_begin_fetch_leaderboard_entries(
   3260     s_client, s_open_leaderboard->id, start, LEADERBOARD_ALL_FETCH_SIZE, LeaderboardFetchAllCallback, nullptr);
   3261 }
   3262 
   3263 void Achievements::CloseLeaderboard()
   3264 {
   3265   s_leaderboard_user_icon_paths.clear();
   3266 
   3267   for (auto iter = s_leaderboard_entry_lists.rbegin(); iter != s_leaderboard_entry_lists.rend(); ++iter)
   3268     rc_client_destroy_leaderboard_entry_list(*iter);
   3269   s_leaderboard_entry_lists.clear();
   3270 
   3271   if (s_leaderboard_nearby_entries)
   3272   {
   3273     rc_client_destroy_leaderboard_entry_list(s_leaderboard_nearby_entries);
   3274     s_leaderboard_nearby_entries = nullptr;
   3275   }
   3276 
   3277   if (s_leaderboard_fetch_handle)
   3278   {
   3279     rc_client_abort_async(s_client, s_leaderboard_fetch_handle);
   3280     s_leaderboard_fetch_handle = nullptr;
   3281   }
   3282 
   3283   s_open_leaderboard = nullptr;
   3284   ImGuiFullscreen::QueueResetFocus(ImGuiFullscreen::FocusResetType::Other);
   3285 }
   3286 
   3287 #ifdef ENABLE_RAINTEGRATION
   3288 
   3289 #include "RA_Consoles.h"
   3290 
   3291 bool Achievements::IsUsingRAIntegration()
   3292 {
   3293   return s_using_raintegration;
   3294 }
   3295 
   3296 namespace Achievements::RAIntegration {
   3297 static void InitializeRAIntegration(void* main_window_handle);
   3298 
   3299 static int RACallbackIsActive();
   3300 static void RACallbackCauseUnpause();
   3301 static void RACallbackCausePause();
   3302 static void RACallbackRebuildMenu();
   3303 static void RACallbackEstimateTitle(char* buf);
   3304 static void RACallbackResetEmulator();
   3305 static void RACallbackLoadROM(const char* unused);
   3306 static unsigned char RACallbackReadRAM(unsigned int address);
   3307 static unsigned int RACallbackReadRAMBlock(unsigned int nAddress, unsigned char* pBuffer, unsigned int nBytes);
   3308 static void RACallbackWriteRAM(unsigned int address, unsigned char value);
   3309 static unsigned char RACallbackReadScratchpad(unsigned int address);
   3310 static unsigned int RACallbackReadScratchpadBlock(unsigned int nAddress, unsigned char* pBuffer, unsigned int nBytes);
   3311 static void RACallbackWriteScratchpad(unsigned int address, unsigned char value);
   3312 
   3313 static bool s_raintegration_initialized = false;
   3314 } // namespace Achievements::RAIntegration
   3315 
   3316 void Achievements::SwitchToRAIntegration()
   3317 {
   3318   s_using_raintegration = true;
   3319 }
   3320 
   3321 void Achievements::RAIntegration::InitializeRAIntegration(void* main_window_handle)
   3322 {
   3323   RA_InitClient((HWND)main_window_handle, "DuckStation", g_scm_tag_str);
   3324   RA_SetUserAgentDetail(Host::GetHTTPUserAgent().c_str());
   3325 
   3326   RA_InstallSharedFunctions(RACallbackIsActive, RACallbackCauseUnpause, RACallbackCausePause, RACallbackRebuildMenu,
   3327                             RACallbackEstimateTitle, RACallbackResetEmulator, RACallbackLoadROM);
   3328   RA_SetConsoleID(PlayStation);
   3329 
   3330   // Apparently this has to be done early, or the memory inspector doesn't work.
   3331   // That's a bit unfortunate, because the RAM size can vary between games, and depending on the option.
   3332   RA_InstallMemoryBank(0, RACallbackReadRAM, RACallbackWriteRAM, Bus::RAM_2MB_SIZE);
   3333   RA_InstallMemoryBankBlockReader(0, RACallbackReadRAMBlock);
   3334   RA_InstallMemoryBank(1, RACallbackReadScratchpad, RACallbackWriteScratchpad, CPU::SCRATCHPAD_SIZE);
   3335   RA_InstallMemoryBankBlockReader(1, RACallbackReadScratchpadBlock);
   3336 
   3337   // Fire off a login anyway. Saves going into the menu and doing it.
   3338   RA_AttemptLogin(0);
   3339 
   3340   s_raintegration_initialized = true;
   3341 
   3342   // this is pretty lame, but we may as well persist until we exit anyway
   3343   std::atexit(RA_Shutdown);
   3344 }
   3345 
   3346 void Achievements::RAIntegration::MainWindowChanged(void* new_handle)
   3347 {
   3348   if (s_raintegration_initialized)
   3349   {
   3350     RA_UpdateHWnd((HWND)new_handle);
   3351     return;
   3352   }
   3353 
   3354   InitializeRAIntegration(new_handle);
   3355 }
   3356 
   3357 void Achievements::RAIntegration::GameChanged()
   3358 {
   3359   s_game_id = s_game_hash.empty() ? 0 : RA_IdentifyHash(s_game_hash.c_str());
   3360   RA_ActivateGame(s_game_id);
   3361 }
   3362 
   3363 std::vector<std::tuple<int, std::string, bool>> Achievements::RAIntegration::GetMenuItems()
   3364 {
   3365   std::array<RA_MenuItem, 64> items;
   3366   const int num_items = RA_GetPopupMenuItems(items.data());
   3367 
   3368   std::vector<std::tuple<int, std::string, bool>> ret;
   3369   ret.reserve(static_cast<u32>(num_items));
   3370 
   3371   for (int i = 0; i < num_items; i++)
   3372   {
   3373     const RA_MenuItem& it = items[i];
   3374     if (!it.sLabel)
   3375       ret.emplace_back(0, std::string(), false);
   3376     else
   3377       ret.emplace_back(static_cast<int>(it.nID), StringUtil::WideStringToUTF8String(it.sLabel), it.bChecked);
   3378   }
   3379 
   3380   return ret;
   3381 }
   3382 
   3383 void Achievements::RAIntegration::ActivateMenuItem(int item)
   3384 {
   3385   RA_InvokeDialog(item);
   3386 }
   3387 
   3388 int Achievements::RAIntegration::RACallbackIsActive()
   3389 {
   3390   return static_cast<int>(HasActiveGame());
   3391 }
   3392 
   3393 void Achievements::RAIntegration::RACallbackCauseUnpause()
   3394 {
   3395   Host::RunOnCPUThread([]() { System::PauseSystem(false); });
   3396 }
   3397 
   3398 void Achievements::RAIntegration::RACallbackCausePause()
   3399 {
   3400   Host::RunOnCPUThread([]() { System::PauseSystem(true); });
   3401 }
   3402 
   3403 void Achievements::RAIntegration::RACallbackRebuildMenu()
   3404 {
   3405   // unused, we build the menu on demand
   3406 }
   3407 
   3408 void Achievements::RAIntegration::RACallbackEstimateTitle(char* buf)
   3409 {
   3410   StringUtil::Strlcpy(buf, System::GetGameTitle(), 256);
   3411 }
   3412 
   3413 void Achievements::RAIntegration::RACallbackResetEmulator()
   3414 {
   3415   if (System::IsValid())
   3416     System::ResetSystem();
   3417 }
   3418 
   3419 void Achievements::RAIntegration::RACallbackLoadROM(const char* unused)
   3420 {
   3421   // unused
   3422   UNREFERENCED_PARAMETER(unused);
   3423 }
   3424 
   3425 unsigned char Achievements::RAIntegration::RACallbackReadRAM(unsigned int address)
   3426 {
   3427   if (!System::IsValid())
   3428     return 0;
   3429 
   3430   u8 value = 0;
   3431   CPU::SafeReadMemoryByte(address, &value);
   3432   return value;
   3433 }
   3434 
   3435 void Achievements::RAIntegration::RACallbackWriteRAM(unsigned int address, unsigned char value)
   3436 {
   3437   CPU::SafeWriteMemoryByte(address, value);
   3438 }
   3439 
   3440 unsigned int Achievements::RAIntegration::RACallbackReadRAMBlock(unsigned int nAddress, unsigned char* pBuffer,
   3441                                                                  unsigned int nBytes)
   3442 {
   3443   if (nAddress >= Bus::g_ram_size)
   3444     return 0;
   3445 
   3446   const u32 copy_size = std::min<u32>(Bus::g_ram_size - nAddress, nBytes);
   3447   std::memcpy(pBuffer, Bus::g_unprotected_ram + nAddress, copy_size);
   3448   return copy_size;
   3449 }
   3450 
   3451 unsigned char Achievements::RAIntegration::RACallbackReadScratchpad(unsigned int address)
   3452 {
   3453   if (!System::IsValid() || address >= CPU::SCRATCHPAD_SIZE)
   3454     return 0;
   3455 
   3456   return CPU::g_state.scratchpad[address];
   3457 }
   3458 
   3459 void Achievements::RAIntegration::RACallbackWriteScratchpad(unsigned int address, unsigned char value)
   3460 {
   3461   if (address >= CPU::SCRATCHPAD_SIZE)
   3462     return;
   3463 
   3464   CPU::g_state.scratchpad[address] = value;
   3465 }
   3466 
   3467 unsigned int Achievements::RAIntegration::RACallbackReadScratchpadBlock(unsigned int nAddress, unsigned char* pBuffer,
   3468                                                                         unsigned int nBytes)
   3469 {
   3470   if (nAddress >= CPU::SCRATCHPAD_SIZE)
   3471     return 0;
   3472 
   3473   const u32 copy_size = std::min<u32>(CPU::SCRATCHPAD_SIZE - nAddress, nBytes);
   3474   std::memcpy(pBuffer, &CPU::g_state.scratchpad[nAddress], copy_size);
   3475   return copy_size;
   3476 }
   3477 
   3478 #else
   3479 
   3480 bool Achievements::IsUsingRAIntegration()
   3481 {
   3482   return false;
   3483 }
   3484 
   3485 #endif