Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
stenzek
GitHub Repository: stenzek/duckstation
Path: blob/master/src/core/achievements.cpp
4211 views
1
// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin <[email protected]>
2
// SPDX-License-Identifier: CC-BY-NC-ND-4.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 "cheats.h"
11
#include "cpu_core.h"
12
#include "fullscreen_ui.h"
13
#include "game_list.h"
14
#include "gpu_thread.h"
15
#include "host.h"
16
#include "imgui_overlays.h"
17
#include "system.h"
18
19
#include "scmversion/scmversion.h"
20
21
#include "common/assert.h"
22
#include "common/binary_reader_writer.h"
23
#include "common/error.h"
24
#include "common/file_system.h"
25
#include "common/heap_array.h"
26
#include "common/log.h"
27
#include "common/md5_digest.h"
28
#include "common/path.h"
29
#include "common/ryml_helpers.h"
30
#include "common/scoped_guard.h"
31
#include "common/sha256_digest.h"
32
#include "common/small_string.h"
33
#include "common/string_util.h"
34
#include "common/timer.h"
35
36
#include "util/cd_image.h"
37
#include "util/http_downloader.h"
38
#include "util/imgui_fullscreen.h"
39
#include "util/imgui_manager.h"
40
#include "util/platform_misc.h"
41
#include "util/state_wrapper.h"
42
43
#include "IconsEmoji.h"
44
#include "IconsFontAwesome6.h"
45
#include "IconsPromptFont.h"
46
#include "fmt/format.h"
47
#include "imgui.h"
48
#include "imgui_internal.h"
49
#include "rc_api_runtime.h"
50
#include "rc_client.h"
51
#include "rc_consoles.h"
52
53
#include <algorithm>
54
#include <atomic>
55
#include <cstdarg>
56
#include <cstdlib>
57
#include <ctime>
58
#include <functional>
59
#include <string>
60
#include <unordered_set>
61
#include <vector>
62
63
LOG_CHANNEL(Achievements);
64
65
namespace Achievements {
66
67
static constexpr const char* INFO_SOUND_NAME = "sounds/achievements/message.wav";
68
static constexpr const char* UNLOCK_SOUND_NAME = "sounds/achievements/unlock.wav";
69
static constexpr const char* LBSUBMIT_SOUND_NAME = "sounds/achievements/lbsubmit.wav";
70
static constexpr const char* ACHEIVEMENT_DETAILS_URL_TEMPLATE = "https://retroachievements.org/achievement/{}";
71
static constexpr const char* PROFILE_DETAILS_URL_TEMPLATE = "https://retroachievements.org/user/{}";
72
static constexpr const char* CACHE_SUBDIRECTORY_NAME = "achievement_images";
73
74
static constexpr u32 LEADERBOARD_NEARBY_ENTRIES_TO_FETCH = 10;
75
static constexpr u32 LEADERBOARD_ALL_FETCH_SIZE = 20;
76
77
static constexpr float LOGIN_NOTIFICATION_TIME = 5.0f;
78
static constexpr float ACHIEVEMENT_SUMMARY_NOTIFICATION_TIME = 5.0f;
79
static constexpr float ACHIEVEMENT_SUMMARY_NOTIFICATION_TIME_HC = 10.0f;
80
static constexpr float ACHIEVEMENT_SUMMARY_UNSUPPORTED_TIME = 12.0f;
81
static constexpr float GAME_COMPLETE_NOTIFICATION_TIME = 20.0f;
82
static constexpr float LEADERBOARD_STARTED_NOTIFICATION_TIME = 3.0f;
83
static constexpr float LEADERBOARD_FAILED_NOTIFICATION_TIME = 3.0f;
84
85
static constexpr float INDICATOR_FADE_IN_TIME = 0.1f;
86
static constexpr float INDICATOR_FADE_OUT_TIME = 0.3f;
87
88
// Some API calls are really slow. Set a longer timeout.
89
static constexpr float SERVER_CALL_TIMEOUT = 60.0f;
90
91
// Chrome uses 10 server calls per domain, seems reasonable.
92
static constexpr u32 MAX_CONCURRENT_SERVER_CALLS = 10;
93
94
namespace {
95
96
struct LoginWithPasswordParameters
97
{
98
const char* username;
99
Error* error;
100
rc_client_async_handle_t* request;
101
bool result;
102
};
103
104
struct LeaderboardTrackerIndicator
105
{
106
u32 tracker_id;
107
std::string text;
108
float opacity;
109
bool active;
110
};
111
112
struct AchievementChallengeIndicator
113
{
114
const rc_client_achievement_t* achievement;
115
std::string badge_path;
116
float time_remaining;
117
float opacity;
118
bool active;
119
};
120
121
struct AchievementProgressIndicator
122
{
123
const rc_client_achievement_t* achievement;
124
std::string badge_path;
125
float opacity;
126
bool active;
127
};
128
129
} // namespace
130
131
static TinyString GameHashToString(const GameHash& hash);
132
133
static void ReportError(std::string_view sv);
134
template<typename... T>
135
static void ReportFmtError(fmt::format_string<T...> fmt, T&&... args);
136
template<typename... T>
137
static void ReportRCError(int err, fmt::format_string<T...> fmt, T&&... args);
138
static void ClearGameInfo();
139
static void ClearGameHash();
140
static bool HasSavedCredentials();
141
static bool TryLoggingInWithToken();
142
static void EnableHardcodeMode(bool display_message, bool display_game_summary);
143
static void OnHardcoreModeChanged(bool enabled, bool display_message, bool display_game_summary);
144
static bool IsRAIntegrationInitializing();
145
static void FinishInitialize();
146
static void FinishLogin(const rc_client_t* client);
147
static void ShowLoginNotification();
148
static bool IdentifyGame(CDImage* image);
149
static bool IdentifyCurrentGame();
150
static void BeginLoadGame();
151
static void UpdateGameSummary(bool update_progress_database);
152
static std::string GetImageURL(const char* image_name, u32 type);
153
static std::string GetLocalImagePath(const std::string_view image_name, u32 type);
154
static void DownloadImage(std::string url, std::string cache_path);
155
static const std::string& GetCachedAchievementBadgePath(const rc_client_achievement_t* achievement, bool locked);
156
157
static TinyString DecryptLoginToken(std::string_view encrypted_token, std::string_view username);
158
static TinyString EncryptLoginToken(std::string_view token, std::string_view username);
159
160
static bool CreateClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http);
161
static void DestroyClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http);
162
static void ClientMessageCallback(const char* message, const rc_client_t* client);
163
static uint32_t ClientReadMemory(uint32_t address, uint8_t* buffer, uint32_t num_bytes, rc_client_t* client);
164
static void ClientServerCall(const rc_api_request_t* request, rc_client_server_callback_t callback, void* callback_data,
165
rc_client_t* client);
166
167
static void ClientEventHandler(const rc_client_event_t* event, rc_client_t* client);
168
static void HandleResetEvent(const rc_client_event_t* event);
169
static void HandleUnlockEvent(const rc_client_event_t* event);
170
static void HandleGameCompleteEvent(const rc_client_event_t* event);
171
static void HandleSubsetCompleteEvent(const rc_client_event_t* event);
172
static void HandleLeaderboardStartedEvent(const rc_client_event_t* event);
173
static void HandleLeaderboardFailedEvent(const rc_client_event_t* event);
174
static void HandleLeaderboardSubmittedEvent(const rc_client_event_t* event);
175
static void HandleLeaderboardScoreboardEvent(const rc_client_event_t* event);
176
static void HandleLeaderboardTrackerShowEvent(const rc_client_event_t* event);
177
static void HandleLeaderboardTrackerHideEvent(const rc_client_event_t* event);
178
static void HandleLeaderboardTrackerUpdateEvent(const rc_client_event_t* event);
179
static void HandleAchievementChallengeIndicatorShowEvent(const rc_client_event_t* event);
180
static void HandleAchievementChallengeIndicatorHideEvent(const rc_client_event_t* event);
181
static void HandleAchievementProgressIndicatorShowEvent(const rc_client_event_t* event);
182
static void HandleAchievementProgressIndicatorHideEvent(const rc_client_event_t* event);
183
static void HandleAchievementProgressIndicatorUpdateEvent(const rc_client_event_t* event);
184
static void HandleServerErrorEvent(const rc_client_event_t* event);
185
static void HandleServerDisconnectedEvent(const rc_client_event_t* event);
186
static void HandleServerReconnectedEvent(const rc_client_event_t* event);
187
188
static void ClientLoginWithTokenCallback(int result, const char* error_message, rc_client_t* client, void* userdata);
189
static void ClientLoginWithPasswordCallback(int result, const char* error_message, rc_client_t* client, void* userdata);
190
static void ClientLoadGameCallback(int result, const char* error_message, rc_client_t* client, void* userdata);
191
192
static void DisplayHardcoreDeferredMessage();
193
static void DisplayAchievementSummary();
194
static void UpdateRichPresence(std::unique_lock<std::recursive_mutex>& lock);
195
196
static void LeaderboardFetchNearbyCallback(int result, const char* error_message,
197
rc_client_leaderboard_entry_list_t* list, rc_client_t* client,
198
void* callback_userdata);
199
static void LeaderboardFetchAllCallback(int result, const char* error_message, rc_client_leaderboard_entry_list_t* list,
200
rc_client_t* client, void* callback_userdata);
201
202
#ifndef __ANDROID__
203
static void DrawAchievement(const rc_client_achievement_t* cheevo);
204
static void DrawLeaderboardListEntry(const rc_client_leaderboard_t* lboard);
205
static void DrawLeaderboardEntry(const rc_client_leaderboard_entry_t& entry, u32 index, bool is_self,
206
float rank_column_width, float name_column_width, float time_column_width,
207
float column_spacing);
208
#endif
209
210
static std::string GetHashDatabasePath();
211
static std::string GetProgressDatabasePath();
212
static void PreloadHashDatabase();
213
static bool LoadHashDatabase(const std::string& path, Error* error);
214
static bool CreateHashDatabaseFromSeedDatabase(const std::string& path, Error* error);
215
static void BeginRefreshHashDatabase();
216
static void FinishRefreshHashDatabase();
217
static void CancelHashDatabaseRequests();
218
219
static void FetchHashLibraryCallback(int result, const char* error_message, rc_client_hash_library_t* list,
220
rc_client_t* client, void* callback_userdata);
221
static void FetchAllProgressCallback(int result, const char* error_message, rc_client_all_user_progress_t* list,
222
rc_client_t* client, void* callback_userdata);
223
static void RefreshAllProgressCallback(int result, const char* error_message, rc_client_all_user_progress_t* list,
224
rc_client_t* client, void* callback_userdata);
225
226
static void BuildHashDatabase(const rc_client_hash_library_t* hashlib, const rc_client_all_user_progress_t* allprog);
227
static bool SortAndSaveHashDatabase(Error* error);
228
229
static FileSystem::ManagedCFilePtr OpenProgressDatabase(bool for_write, bool truncate, Error* error);
230
static void BuildProgressDatabase(const rc_client_all_user_progress_t* allprog);
231
static void UpdateProgressDatabase();
232
static void ClearProgressDatabase();
233
234
#ifdef RC_CLIENT_SUPPORTS_RAINTEGRATION
235
236
static void BeginLoadRAIntegration();
237
static void UnloadRAIntegration();
238
239
#endif
240
241
namespace {
242
243
struct PauseMenuAchievementInfo
244
{
245
std::string title;
246
std::string description;
247
std::string badge_path;
248
};
249
250
struct State
251
{
252
rc_client_t* client = nullptr;
253
bool has_achievements = false;
254
bool has_leaderboards = false;
255
bool has_rich_presence = false;
256
257
std::recursive_mutex mutex; // large
258
259
std::string rich_presence_string;
260
Timer::Value rich_presence_poll_time = 0;
261
262
std::vector<LeaderboardTrackerIndicator> active_leaderboard_trackers;
263
std::vector<AchievementChallengeIndicator> active_challenge_indicators;
264
std::optional<AchievementProgressIndicator> active_progress_indicator;
265
266
rc_client_user_game_summary_t game_summary = {};
267
u32 game_id = 0;
268
269
std::unique_ptr<HTTPDownloader> http_downloader;
270
271
std::string game_path;
272
std::string game_title;
273
std::string game_icon;
274
std::string game_icon_url;
275
std::optional<GameHash> game_hash;
276
277
rc_client_async_handle_t* login_request = nullptr;
278
rc_client_async_handle_t* load_game_request = nullptr;
279
280
rc_client_achievement_list_t* achievement_list = nullptr;
281
std::vector<std::tuple<const void*, std::string, bool>> achievement_badge_paths;
282
283
std::optional<PauseMenuAchievementInfo> most_recent_unlock = {};
284
std::optional<PauseMenuAchievementInfo> achievement_nearest_completion = {};
285
286
rc_client_leaderboard_list_t* leaderboard_list = nullptr;
287
const rc_client_leaderboard_t* open_leaderboard = nullptr;
288
rc_client_async_handle_t* leaderboard_fetch_handle = nullptr;
289
std::vector<rc_client_leaderboard_entry_list_t*> leaderboard_entry_lists;
290
std::vector<std::pair<const rc_client_leaderboard_entry_t*, std::string>> leaderboard_user_icon_paths;
291
rc_client_leaderboard_entry_list_t* leaderboard_nearby_entries;
292
bool is_showing_all_leaderboard_entries = false;
293
294
bool hashdb_loaded = false;
295
std::vector<HashDatabaseEntry> hashdb_entries;
296
297
rc_client_async_handle_t* fetch_hash_library_request = nullptr;
298
rc_client_hash_library_t* fetch_hash_library_result = nullptr;
299
rc_client_async_handle_t* fetch_all_progress_request = nullptr;
300
rc_client_all_user_progress_t* fetch_all_progress_result = nullptr;
301
rc_client_async_handle_t* refresh_all_progress_request = nullptr;
302
303
#ifdef RC_CLIENT_SUPPORTS_RAINTEGRATION
304
rc_client_async_handle_t* load_raintegration_request = nullptr;
305
bool using_raintegration = false;
306
bool raintegration_loading = false;
307
#endif
308
};
309
310
} // namespace
311
312
ALIGN_TO_CACHE_LINE static State s_state;
313
314
} // namespace Achievements
315
316
TinyString Achievements::GameHashToString(const GameHash& hash)
317
{
318
return TinyString::from_format(
319
"{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", hash[0],
320
hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7], hash[8], hash[9], hash[10], hash[11], hash[12],
321
hash[13], hash[14], hash[15]);
322
}
323
324
std::unique_lock<std::recursive_mutex> Achievements::GetLock()
325
{
326
return std::unique_lock(s_state.mutex);
327
}
328
329
rc_client_t* Achievements::GetClient()
330
{
331
return s_state.client;
332
}
333
334
const rc_client_user_game_summary_t& Achievements::GetGameSummary()
335
{
336
return s_state.game_summary;
337
}
338
339
void Achievements::ReportError(std::string_view sv)
340
{
341
ERROR_LOG(sv);
342
Host::AddIconOSDWarning(std::string(), ICON_EMOJI_WARNING, std::string(sv), Host::OSD_CRITICAL_ERROR_DURATION);
343
}
344
345
template<typename... T>
346
void Achievements::ReportFmtError(fmt::format_string<T...> fmt, T&&... args)
347
{
348
TinyString str;
349
fmt::vformat_to(std::back_inserter(str), fmt, fmt::make_format_args(args...));
350
ReportError(str);
351
}
352
353
template<typename... T>
354
void Achievements::ReportRCError(int err, fmt::format_string<T...> fmt, T&&... args)
355
{
356
TinyString str;
357
fmt::vformat_to(std::back_inserter(str), fmt, fmt::make_format_args(args...));
358
str.append_format("{} ({})", rc_error_str(err), err);
359
ReportError(str);
360
}
361
362
std::optional<Achievements::GameHash> Achievements::GetGameHash(CDImage* image, u32* bytes_hashed)
363
{
364
std::optional<GameHash> ret;
365
366
std::string executable_name;
367
std::vector<u8> executable_data;
368
if (!System::ReadExecutableFromImage(image, &executable_name, &executable_data))
369
return ret;
370
371
return GetGameHash(executable_name, executable_data, bytes_hashed);
372
}
373
374
std::optional<Achievements::GameHash> Achievements::GetGameHash(const std::string_view executable_name,
375
std::span<const u8> executable_data,
376
u32* bytes_hashed /* = nullptr */)
377
{
378
std::optional<GameHash> ret;
379
380
// NOTE: Assumes executable_data is aligned to 4 bytes at least.. it should be.
381
const BIOS::PSEXEHeader* header = reinterpret_cast<const BIOS::PSEXEHeader*>(executable_data.data());
382
if (executable_data.size() < sizeof(BIOS::PSEXEHeader) || !BIOS::IsValidPSExeHeader(*header, executable_data.size()))
383
{
384
ERROR_LOG("PS-EXE header is invalid in '{}' ({} bytes)", executable_name, executable_data.size());
385
return ret;
386
}
387
388
const u32 hash_size = std::min(header->file_size + 2048, static_cast<u32>(executable_data.size()));
389
390
MD5Digest digest;
391
digest.Update(executable_name.data(), static_cast<u32>(executable_name.size()));
392
if (hash_size > 0)
393
digest.Update(executable_data.data(), hash_size);
394
395
ret = GameHash();
396
digest.Final(ret.value());
397
398
if (bytes_hashed)
399
*bytes_hashed = hash_size;
400
401
return ret;
402
}
403
404
std::string Achievements::GetImageURL(const char* image_name, u32 type)
405
{
406
std::string ret;
407
408
const rc_api_fetch_image_request_t image_request = {.image_name = image_name, .image_type = type};
409
rc_api_request_t request;
410
int result = rc_api_init_fetch_image_request(&request, &image_request);
411
if (result == RC_OK)
412
ret = request.url;
413
414
rc_api_destroy_request(&request);
415
return ret;
416
}
417
418
std::string Achievements::GetLocalImagePath(const std::string_view image_name, u32 type)
419
{
420
std::string_view prefix;
421
std::string_view suffix;
422
switch (type)
423
{
424
case RC_IMAGE_TYPE_GAME:
425
prefix = "image"; // https://media.retroachievements.org/Images/{}.png
426
break;
427
428
case RC_IMAGE_TYPE_USER:
429
prefix = "user"; // https://media.retroachievements.org/UserPic/{}.png
430
break;
431
432
case RC_IMAGE_TYPE_ACHIEVEMENT: // https://media.retroachievements.org/Badge/{}.png
433
prefix = "badge";
434
break;
435
436
case RC_IMAGE_TYPE_ACHIEVEMENT_LOCKED:
437
prefix = "badge";
438
suffix = "_lock";
439
break;
440
441
default:
442
prefix = "badge";
443
break;
444
}
445
446
std::string ret;
447
if (!image_name.empty())
448
{
449
ret = fmt::format("{}" FS_OSPATH_SEPARATOR_STR "{}" FS_OSPATH_SEPARATOR_STR "{}_{}{}.png", EmuFolders::Cache,
450
CACHE_SUBDIRECTORY_NAME, prefix, Path::SanitizeFileName(image_name), suffix);
451
}
452
453
return ret;
454
}
455
456
void Achievements::DownloadImage(std::string url, std::string cache_path)
457
{
458
auto callback = [cache_path = std::move(cache_path)](s32 status_code, const Error& error,
459
const std::string& content_type,
460
HTTPDownloader::Request::Data data) mutable {
461
if (status_code != HTTPDownloader::HTTP_STATUS_OK)
462
{
463
ERROR_LOG("Failed to download badge '{}': {}", Path::GetFileName(cache_path), error.GetDescription());
464
return;
465
}
466
467
Error write_error;
468
if (!FileSystem::WriteBinaryFile(cache_path.c_str(), data, &write_error))
469
{
470
ERROR_LOG("Failed to write badge image to '{}': {}", cache_path, write_error.GetDescription());
471
return;
472
}
473
474
GPUThread::RunOnThread(
475
[cache_path = std::move(cache_path)]() { ImGuiFullscreen::InvalidateCachedTexture(cache_path); });
476
};
477
478
s_state.http_downloader->CreateRequest(std::move(url), std::move(callback));
479
}
480
481
bool Achievements::IsActive()
482
{
483
return (s_state.client != nullptr);
484
}
485
486
bool Achievements::IsHardcoreModeActive()
487
{
488
if (!s_state.client)
489
return false;
490
491
const auto lock = GetLock();
492
return rc_client_get_hardcore_enabled(s_state.client);
493
}
494
495
bool Achievements::HasActiveGame()
496
{
497
return s_state.game_id != 0;
498
}
499
500
u32 Achievements::GetGameID()
501
{
502
return s_state.game_id;
503
}
504
505
bool Achievements::HasAchievementsOrLeaderboards()
506
{
507
return s_state.has_achievements || s_state.has_leaderboards;
508
}
509
510
bool Achievements::HasAchievements()
511
{
512
return s_state.has_achievements;
513
}
514
515
bool Achievements::HasLeaderboards()
516
{
517
return s_state.has_leaderboards;
518
}
519
520
bool Achievements::HasRichPresence()
521
{
522
return s_state.has_rich_presence;
523
}
524
525
const std::string& Achievements::GetGameTitle()
526
{
527
return s_state.game_title;
528
}
529
530
const std::string& Achievements::GetGamePath()
531
{
532
return s_state.game_path;
533
}
534
535
const std::string& Achievements::GetGameIconPath()
536
{
537
return s_state.game_icon;
538
}
539
540
const std::string& Achievements::GetGameIconURL()
541
{
542
return s_state.game_icon_url;
543
}
544
545
const std::string& Achievements::GetRichPresenceString()
546
{
547
return s_state.rich_presence_string;
548
}
549
550
bool Achievements::Initialize()
551
{
552
auto lock = GetLock();
553
AssertMsg(g_settings.achievements_enabled, "Achievements are enabled");
554
Assert(!s_state.client && !s_state.http_downloader);
555
556
if (!CreateClient(&s_state.client, &s_state.http_downloader))
557
return false;
558
559
rc_client_set_event_handler(s_state.client, ClientEventHandler);
560
561
#ifdef RC_CLIENT_SUPPORTS_RAINTEGRATION
562
if (g_settings.achievements_use_raintegration)
563
BeginLoadRAIntegration();
564
#endif
565
566
// Hardcore starts off. We enable it on first boot.
567
rc_client_set_hardcore_enabled(s_state.client, false);
568
rc_client_set_encore_mode_enabled(s_state.client, g_settings.achievements_encore_mode);
569
rc_client_set_unofficial_enabled(s_state.client, g_settings.achievements_unofficial_test_mode);
570
rc_client_set_spectator_mode_enabled(s_state.client, g_settings.achievements_spectator_mode);
571
572
// We can't do an internal client login while using RAIntegration, since the two will conflict.
573
if (!IsRAIntegrationInitializing())
574
FinishInitialize();
575
576
return true;
577
}
578
579
void Achievements::FinishInitialize()
580
{
581
// Start logging in. This can take a while.
582
TryLoggingInWithToken();
583
584
// Are we running a game?
585
if (System::IsValid())
586
{
587
IdentifyCurrentGame();
588
BeginLoadGame();
589
590
// Hardcore mode isn't enabled when achievements first starts, if a game is already running.
591
if (IsLoggedInOrLoggingIn() && g_settings.achievements_hardcore_mode)
592
DisplayHardcoreDeferredMessage();
593
}
594
595
Host::OnAchievementsActiveChanged(true);
596
}
597
598
bool Achievements::CreateClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http)
599
{
600
rc_client_t* new_client = rc_client_create(ClientReadMemory, ClientServerCall);
601
if (!new_client)
602
{
603
Host::ReportErrorAsync("Achievements Error", "rc_client_create() failed, cannot use achievements");
604
return false;
605
}
606
607
rc_client_enable_logging(
608
new_client, (Log::GetLogLevel() >= Log::Level::Verbose) ? RC_CLIENT_LOG_LEVEL_VERBOSE : RC_CLIENT_LOG_LEVEL_INFO,
609
ClientMessageCallback);
610
611
char rc_client_user_agent[128];
612
rc_client_get_user_agent_clause(new_client, rc_client_user_agent, std::size(rc_client_user_agent));
613
*http = HTTPDownloader::Create(fmt::format("{} {}", Host::GetHTTPUserAgent(), rc_client_user_agent));
614
if (!*http)
615
{
616
Host::ReportErrorAsync("Achievements Error", "Failed to create HTTPDownloader, cannot use achievements");
617
rc_client_destroy(new_client);
618
return false;
619
}
620
621
(*http)->SetTimeout(SERVER_CALL_TIMEOUT);
622
(*http)->SetMaxActiveRequests(MAX_CONCURRENT_SERVER_CALLS);
623
624
rc_client_set_userdata(new_client, http->get());
625
*client = new_client;
626
return true;
627
}
628
629
void Achievements::DestroyClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http)
630
{
631
(*http)->WaitForAllRequests();
632
633
rc_client_destroy(*client);
634
*client = nullptr;
635
636
http->reset();
637
}
638
639
bool Achievements::HasSavedCredentials()
640
{
641
const TinyString username = Host::GetTinyStringSettingValue("Cheevos", "Username");
642
const TinyString api_token = Host::GetTinyStringSettingValue("Cheevos", "Token");
643
return (!username.empty() && !api_token.empty());
644
}
645
646
bool Achievements::TryLoggingInWithToken()
647
{
648
const TinyString username = Host::GetTinyStringSettingValue("Cheevos", "Username");
649
const TinyString api_token = Host::GetTinyStringSettingValue("Cheevos", "Token");
650
if (username.empty() || api_token.empty())
651
return false;
652
653
INFO_LOG("Attempting token login with user '{}'...", username);
654
655
// If we can't decrypt the token, it was an old config and we need to re-login.
656
if (const TinyString decrypted_api_token = DecryptLoginToken(api_token, username); !decrypted_api_token.empty())
657
{
658
s_state.login_request = rc_client_begin_login_with_token(
659
s_state.client, username.c_str(), decrypted_api_token.c_str(), ClientLoginWithTokenCallback, nullptr);
660
if (!s_state.login_request)
661
{
662
WARNING_LOG("Creating login request failed.");
663
return false;
664
}
665
666
return true;
667
}
668
else
669
{
670
WARNING_LOG("Invalid encrypted login token, requesitng a new one.");
671
Host::OnAchievementsLoginRequested(LoginRequestReason::TokenInvalid);
672
return false;
673
}
674
}
675
676
void Achievements::UpdateSettings(const Settings& old_config)
677
{
678
if (!g_settings.achievements_enabled)
679
{
680
// we're done here
681
Shutdown();
682
return;
683
}
684
685
if (!IsActive())
686
{
687
// we just got enabled
688
Initialize();
689
return;
690
}
691
692
#ifdef RC_CLIENT_SUPPORTS_RAINTEGRATION
693
if (g_settings.achievements_use_raintegration != old_config.achievements_use_raintegration)
694
{
695
// RAIntegration requires a full client reload?
696
Shutdown();
697
Initialize();
698
return;
699
}
700
#endif
701
702
if (g_settings.achievements_hardcore_mode != old_config.achievements_hardcore_mode)
703
{
704
// Enables have to wait for reset, disables can go through immediately.
705
if (g_settings.achievements_hardcore_mode)
706
DisplayHardcoreDeferredMessage();
707
else
708
DisableHardcoreMode(true, true);
709
}
710
711
// These cannot be modified while a game is loaded, so just toss state and reload.
712
auto lock = GetLock();
713
if (HasActiveGame())
714
{
715
lock.unlock();
716
if (g_settings.achievements_encore_mode != old_config.achievements_encore_mode ||
717
g_settings.achievements_spectator_mode != old_config.achievements_spectator_mode ||
718
g_settings.achievements_unofficial_test_mode != old_config.achievements_unofficial_test_mode)
719
{
720
Shutdown();
721
Initialize();
722
return;
723
}
724
}
725
else
726
{
727
if (g_settings.achievements_encore_mode != old_config.achievements_encore_mode)
728
rc_client_set_encore_mode_enabled(s_state.client, g_settings.achievements_encore_mode);
729
if (g_settings.achievements_spectator_mode != old_config.achievements_spectator_mode)
730
rc_client_set_spectator_mode_enabled(s_state.client, g_settings.achievements_spectator_mode);
731
if (g_settings.achievements_unofficial_test_mode != old_config.achievements_unofficial_test_mode)
732
rc_client_set_unofficial_enabled(s_state.client, g_settings.achievements_unofficial_test_mode);
733
}
734
735
if (!g_settings.achievements_leaderboard_trackers)
736
s_state.active_leaderboard_trackers.clear();
737
738
if (!g_settings.achievements_progress_indicators)
739
s_state.active_progress_indicator.reset();
740
}
741
742
void Achievements::Shutdown()
743
{
744
if (!IsActive())
745
return;
746
747
auto lock = GetLock();
748
Assert(s_state.client && s_state.http_downloader);
749
750
ClearGameInfo();
751
ClearGameHash();
752
DisableHardcoreMode(false, false);
753
CancelHashDatabaseRequests();
754
755
if (s_state.login_request)
756
{
757
rc_client_abort_async(s_state.client, s_state.login_request);
758
s_state.login_request = nullptr;
759
}
760
761
#ifdef RC_CLIENT_SUPPORTS_RAINTEGRATION
762
if (s_state.using_raintegration)
763
{
764
UnloadRAIntegration();
765
return;
766
}
767
#endif
768
769
DestroyClient(&s_state.client, &s_state.http_downloader);
770
Host::OnAchievementsActiveChanged(false);
771
}
772
773
void Achievements::ClientMessageCallback(const char* message, const rc_client_t* client)
774
{
775
DEV_LOG(message);
776
}
777
778
uint32_t Achievements::ClientReadMemory(uint32_t address, uint8_t* buffer, uint32_t num_bytes, rc_client_t* client)
779
{
780
if ((address + num_bytes) > 0x200400U) [[unlikely]]
781
return 0;
782
783
const u8* src = (address >= 0x200000U) ? CPU::g_state.scratchpad.data() : Bus::g_ram;
784
const u32 offset = (address & Bus::RAM_2MB_MASK); // size guarded by check above
785
786
switch (num_bytes)
787
{
788
case 1:
789
std::memcpy(buffer, &src[offset], 1);
790
break;
791
case 2:
792
std::memcpy(buffer, &src[offset], 2);
793
break;
794
case 4:
795
std::memcpy(buffer, &src[offset], 4);
796
break;
797
default:
798
[[unlikely]] std::memcpy(buffer, &src[offset], num_bytes);
799
break;
800
}
801
802
return num_bytes;
803
}
804
805
void Achievements::ClientServerCall(const rc_api_request_t* request, rc_client_server_callback_t callback,
806
void* callback_data, rc_client_t* client)
807
{
808
HTTPDownloader::Request::Callback hd_callback = [callback, callback_data](s32 status_code, const Error& error,
809
const std::string& content_type,
810
HTTPDownloader::Request::Data data) {
811
if (status_code != HTTPDownloader::HTTP_STATUS_OK)
812
ERROR_LOG("Server call failed: {}", error.GetDescription());
813
814
rc_api_server_response_t rr;
815
rr.http_status_code = (status_code <= 0) ? (status_code == HTTPDownloader::HTTP_STATUS_CANCELLED ?
816
RC_API_SERVER_RESPONSE_CLIENT_ERROR :
817
RC_API_SERVER_RESPONSE_RETRYABLE_CLIENT_ERROR) :
818
status_code;
819
rr.body_length = data.size();
820
rr.body = data.empty() ? nullptr : reinterpret_cast<const char*>(data.data());
821
822
callback(&rr, callback_data);
823
};
824
825
HTTPDownloader* http = static_cast<HTTPDownloader*>(rc_client_get_userdata(client));
826
827
// TODO: Content-type for post
828
if (request->post_data)
829
{
830
// const auto pd = std::string_view(request->post_data);
831
// Log_DevFmt("Server POST: {}", pd.substr(0, std::min<size_t>(pd.length(), 10)));
832
http->CreatePostRequest(request->url, request->post_data, std::move(hd_callback));
833
}
834
else
835
{
836
http->CreateRequest(request->url, std::move(hd_callback));
837
}
838
}
839
840
void Achievements::IdleUpdate()
841
{
842
if (!IsActive())
843
return;
844
845
const auto lock = GetLock();
846
847
s_state.http_downloader->PollRequests();
848
rc_client_idle(s_state.client);
849
}
850
851
bool Achievements::NeedsIdleUpdate()
852
{
853
if (!IsActive())
854
return false;
855
856
const auto lock = GetLock();
857
return (s_state.http_downloader && s_state.http_downloader->HasAnyRequests());
858
}
859
860
void Achievements::FrameUpdate()
861
{
862
if (!IsActive())
863
return;
864
865
auto lock = GetLock();
866
867
s_state.http_downloader->PollRequests();
868
rc_client_do_frame(s_state.client);
869
870
UpdateRichPresence(lock);
871
}
872
873
void Achievements::ClientEventHandler(const rc_client_event_t* event, rc_client_t* client)
874
{
875
switch (event->type)
876
{
877
case RC_CLIENT_EVENT_RESET:
878
HandleResetEvent(event);
879
break;
880
881
case RC_CLIENT_EVENT_ACHIEVEMENT_TRIGGERED:
882
HandleUnlockEvent(event);
883
break;
884
885
case RC_CLIENT_EVENT_GAME_COMPLETED:
886
HandleGameCompleteEvent(event);
887
break;
888
889
case RC_CLIENT_EVENT_SUBSET_COMPLETED:
890
HandleSubsetCompleteEvent(event);
891
break;
892
893
case RC_CLIENT_EVENT_LEADERBOARD_STARTED:
894
HandleLeaderboardStartedEvent(event);
895
break;
896
897
case RC_CLIENT_EVENT_LEADERBOARD_FAILED:
898
HandleLeaderboardFailedEvent(event);
899
break;
900
901
case RC_CLIENT_EVENT_LEADERBOARD_SUBMITTED:
902
HandleLeaderboardSubmittedEvent(event);
903
break;
904
905
case RC_CLIENT_EVENT_LEADERBOARD_SCOREBOARD:
906
HandleLeaderboardScoreboardEvent(event);
907
break;
908
909
case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_SHOW:
910
HandleLeaderboardTrackerShowEvent(event);
911
break;
912
913
case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_HIDE:
914
HandleLeaderboardTrackerHideEvent(event);
915
break;
916
917
case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_UPDATE:
918
HandleLeaderboardTrackerUpdateEvent(event);
919
break;
920
921
case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_SHOW:
922
HandleAchievementChallengeIndicatorShowEvent(event);
923
break;
924
925
case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_HIDE:
926
HandleAchievementChallengeIndicatorHideEvent(event);
927
break;
928
929
case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_SHOW:
930
HandleAchievementProgressIndicatorShowEvent(event);
931
break;
932
933
case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_HIDE:
934
HandleAchievementProgressIndicatorHideEvent(event);
935
break;
936
937
case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_UPDATE:
938
HandleAchievementProgressIndicatorUpdateEvent(event);
939
break;
940
941
case RC_CLIENT_EVENT_SERVER_ERROR:
942
HandleServerErrorEvent(event);
943
break;
944
945
case RC_CLIENT_EVENT_DISCONNECTED:
946
HandleServerDisconnectedEvent(event);
947
break;
948
949
case RC_CLIENT_EVENT_RECONNECTED:
950
HandleServerReconnectedEvent(event);
951
break;
952
953
default:
954
[[unlikely]] ERROR_LOG("Unhandled event: {}", event->type);
955
break;
956
}
957
}
958
959
void Achievements::UpdateGameSummary(bool update_progress_database)
960
{
961
rc_client_get_user_game_summary(s_state.client, &s_state.game_summary);
962
963
if (update_progress_database)
964
UpdateProgressDatabase();
965
}
966
967
void Achievements::UpdateRecentUnlockAndAlmostThere()
968
{
969
const auto lock = GetLock();
970
if (!HasActiveGame())
971
{
972
s_state.most_recent_unlock.reset();
973
s_state.achievement_nearest_completion.reset();
974
return;
975
}
976
977
rc_client_achievement_list_t* const achievements = rc_client_create_achievement_list(
978
s_state.client, RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL, RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_PROGRESS);
979
if (!achievements)
980
{
981
s_state.most_recent_unlock.reset();
982
s_state.achievement_nearest_completion.reset();
983
return;
984
}
985
986
const rc_client_achievement_t* most_recent_unlock = nullptr;
987
const rc_client_achievement_t* nearest_completion = nullptr;
988
989
for (u32 i = 0; i < achievements->num_buckets; i++)
990
{
991
const rc_client_achievement_bucket_t& bucket = achievements->buckets[i];
992
for (u32 j = 0; j < bucket.num_achievements; j++)
993
{
994
const rc_client_achievement_t* achievement = bucket.achievements[j];
995
996
if (achievement->state == RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED)
997
{
998
if (!most_recent_unlock || achievement->unlock_time > most_recent_unlock->unlock_time)
999
most_recent_unlock = achievement;
1000
}
1001
else
1002
{
1003
// find the achievement with the greatest normalized progress, but skip anything below 80%,
1004
// matching the rc_client definition of "almost there"
1005
const float percent_cutoff = 80.0f;
1006
if (achievement->measured_percent >= percent_cutoff &&
1007
(!nearest_completion || achievement->measured_percent > nearest_completion->measured_percent))
1008
{
1009
nearest_completion = achievement;
1010
}
1011
}
1012
}
1013
}
1014
1015
// have to take a copy because with RAIntegration the achievement pointer does not persist
1016
static constexpr auto cache_info = [](const rc_client_achievement_t* achievement,
1017
std::optional<PauseMenuAchievementInfo>& info) {
1018
if (!achievement)
1019
{
1020
info.reset();
1021
return;
1022
}
1023
1024
if (!info.has_value())
1025
info.emplace();
1026
1027
info->title = achievement->title;
1028
info->description = achievement->description;
1029
info->badge_path = GetAchievementBadgePath(achievement, false);
1030
};
1031
1032
cache_info(most_recent_unlock, s_state.most_recent_unlock);
1033
cache_info(nearest_completion, s_state.achievement_nearest_completion);
1034
1035
rc_client_destroy_achievement_list(achievements);
1036
}
1037
1038
void Achievements::UpdateRichPresence(std::unique_lock<std::recursive_mutex>& lock)
1039
{
1040
// Limit rich presence updates to once per second, since it could change per frame.
1041
if (!s_state.has_rich_presence)
1042
return;
1043
1044
const Timer::Value now = Timer::GetCurrentValue();
1045
if (Timer::ConvertValueToSeconds(now - s_state.rich_presence_poll_time) < 1)
1046
return;
1047
1048
s_state.rich_presence_poll_time = now;
1049
1050
char buffer[512];
1051
const size_t res = rc_client_get_rich_presence_message(s_state.client, buffer, std::size(buffer));
1052
const std::string_view sv(buffer, res);
1053
if (s_state.rich_presence_string == sv)
1054
return;
1055
1056
s_state.rich_presence_string.assign(sv);
1057
1058
INFO_LOG("Rich presence updated: {}", s_state.rich_presence_string);
1059
Host::OnAchievementsRefreshed();
1060
1061
lock.unlock();
1062
System::UpdateRichPresence(false);
1063
lock.lock();
1064
}
1065
1066
void Achievements::OnSystemStarting(CDImage* image, bool disable_hardcore_mode)
1067
{
1068
std::unique_lock lock(s_state.mutex);
1069
1070
if (!IsActive() || IsRAIntegrationInitializing())
1071
return;
1072
1073
// if we're not logged in, and there's no login request, retry logging in
1074
// this'll happen if we had no network connection on startup, but gained it before starting a game.
1075
if (!IsLoggedInOrLoggingIn())
1076
{
1077
WARNING_LOG("Not logged in on game booting, trying again.");
1078
TryLoggingInWithToken();
1079
}
1080
1081
// HC should have been disabled, we're now enabling it
1082
// RAIntegration can enable hardcode mode outside of us, so we need to double-check
1083
if (rc_client_get_hardcore_enabled(s_state.client))
1084
{
1085
WARNING_LOG("Hardcore mode was enabled on system starting.");
1086
OnHardcoreModeChanged(true, false, false);
1087
}
1088
else
1089
{
1090
// only enable hardcore mode if we're logged in, or waiting for a login response
1091
if (!disable_hardcore_mode && g_settings.achievements_hardcore_mode && IsLoggedInOrLoggingIn())
1092
EnableHardcodeMode(false, false);
1093
}
1094
1095
// now we can finally identify the game
1096
IdentifyGame(image);
1097
BeginLoadGame();
1098
}
1099
1100
void Achievements::OnSystemDestroyed()
1101
{
1102
ClearGameInfo();
1103
ClearGameHash();
1104
DisableHardcoreMode(false, false);
1105
}
1106
1107
void Achievements::OnSystemReset()
1108
{
1109
const auto lock = GetLock();
1110
if (!IsActive() || IsRAIntegrationInitializing())
1111
return;
1112
1113
// Do we need to enable hardcore mode?
1114
if (System::IsValid() && g_settings.achievements_hardcore_mode && !rc_client_get_hardcore_enabled(s_state.client))
1115
{
1116
// This will raise the silly reset event, but we can safely ignore that since we're immediately resetting the client
1117
DEV_LOG("Enabling hardcore mode after reset");
1118
EnableHardcodeMode(true, true);
1119
}
1120
1121
DEV_LOG("Reset client");
1122
rc_client_reset(s_state.client);
1123
}
1124
1125
void Achievements::GameChanged(CDImage* image)
1126
{
1127
std::unique_lock lock(s_state.mutex);
1128
1129
if (!IsActive() || IsRAIntegrationInitializing())
1130
return;
1131
1132
// disc changed?
1133
if (!IdentifyGame(image))
1134
return;
1135
1136
// cancel previous requests
1137
if (s_state.load_game_request)
1138
{
1139
rc_client_abort_async(s_state.client, s_state.load_game_request);
1140
s_state.load_game_request = nullptr;
1141
}
1142
1143
// Use a hash that will never match if we removed the disc. See rc_client_begin_change_media().
1144
TinyString game_hash_str;
1145
if (s_state.game_hash.has_value())
1146
game_hash_str = GameHashToString(s_state.game_hash.value());
1147
else
1148
game_hash_str = "[NO HASH]";
1149
1150
s_state.load_game_request = rc_client_begin_change_media_from_hash(
1151
s_state.client, game_hash_str.c_str(), ClientLoadGameCallback, reinterpret_cast<void*>(static_cast<uintptr_t>(1)));
1152
}
1153
1154
bool Achievements::IdentifyGame(CDImage* image)
1155
{
1156
if (s_state.game_path == (image ? std::string_view(image->GetPath()) : std::string_view()))
1157
{
1158
WARNING_LOG("Game path is unchanged.");
1159
return false;
1160
}
1161
1162
std::optional<GameHash> game_hash;
1163
if (image)
1164
{
1165
u32 bytes_hashed;
1166
game_hash = GetGameHash(image, &bytes_hashed);
1167
if (game_hash.has_value())
1168
{
1169
INFO_COLOR_LOG(StrongOrange, "RA Hash: {} ({} bytes hashed)", GameHashToString(game_hash.value()), bytes_hashed);
1170
}
1171
else
1172
{
1173
// If we are starting with this game and it's bad, notify the user that this is why.
1174
Host::AddIconOSDWarning(
1175
"AchievementsHashFailed", ICON_EMOJI_WARNING,
1176
TRANSLATE_STR("Achievements", "Failed to read executable from disc. Achievements disabled."),
1177
Host::OSD_ERROR_DURATION);
1178
}
1179
}
1180
1181
s_state.game_path = image ? image->GetPath() : std::string();
1182
1183
if (s_state.game_hash == game_hash)
1184
{
1185
// only the path has changed - different format/save state/etc.
1186
INFO_LOG("Detected path change to '{}'", s_state.game_path);
1187
return false;
1188
}
1189
1190
s_state.game_hash = game_hash;
1191
return true;
1192
}
1193
1194
bool Achievements::IdentifyCurrentGame()
1195
{
1196
DebugAssert(System::IsValid());
1197
1198
// this crap is only needed because we can't grab the image from the reader...
1199
std::unique_ptr<CDImage> temp_image;
1200
if (const std::string& disc_path = System::GetGamePath(); !disc_path.empty())
1201
{
1202
Error error;
1203
temp_image = CDImage::Open(disc_path.c_str(), g_settings.cdrom_load_image_patches, &error);
1204
if (!temp_image)
1205
ERROR_LOG("Failed to open disc for late game identification: {}", error.GetDescription());
1206
}
1207
1208
return IdentifyGame(temp_image.get());
1209
}
1210
1211
void Achievements::BeginLoadGame()
1212
{
1213
if (!s_state.game_hash.has_value())
1214
{
1215
// no need to go through ClientLoadGameCallback, just bail out straight away
1216
DisableHardcoreMode(false, false);
1217
return;
1218
}
1219
1220
s_state.load_game_request = rc_client_begin_load_game(s_state.client, GameHashToString(s_state.game_hash.value()),
1221
ClientLoadGameCallback, nullptr);
1222
}
1223
1224
void Achievements::ClientLoadGameCallback(int result, const char* error_message, rc_client_t* client, void* userdata)
1225
{
1226
const bool was_disc_change = (userdata != nullptr);
1227
1228
s_state.load_game_request = nullptr;
1229
1230
if (result == RC_NO_GAME_LOADED)
1231
{
1232
// Unknown game.
1233
INFO_LOG("Unknown game '{}', disabling achievements.", GameHashToString(s_state.game_hash.value()));
1234
if (was_disc_change)
1235
ClearGameInfo();
1236
1237
DisableHardcoreMode(false, false);
1238
return;
1239
}
1240
else if (result == RC_LOGIN_REQUIRED)
1241
{
1242
// We would've asked to re-authenticate, so leave HC on for now.
1243
// Once we've done so, we'll reload the game.
1244
if (!HasSavedCredentials())
1245
{
1246
DisableHardcoreMode(false, false);
1247
return;
1248
}
1249
1250
return;
1251
}
1252
else if (result == RC_HARDCORE_DISABLED)
1253
{
1254
if (error_message)
1255
ReportError(error_message);
1256
1257
OnHardcoreModeChanged(false, true, false);
1258
return;
1259
}
1260
else if (result != RC_OK)
1261
{
1262
ReportFmtError("Loading game failed: {}", error_message);
1263
if (was_disc_change)
1264
ClearGameInfo();
1265
1266
DisableHardcoreMode(false, false);
1267
return;
1268
}
1269
1270
const rc_client_game_t* info = rc_client_get_game_info(s_state.client);
1271
if (!info)
1272
{
1273
ReportError("rc_client_get_game_info() returned NULL");
1274
if (was_disc_change)
1275
ClearGameInfo();
1276
1277
DisableHardcoreMode(false, false);
1278
return;
1279
}
1280
1281
const bool has_achievements = rc_client_has_achievements(client);
1282
const bool has_leaderboards = rc_client_has_leaderboards(client, false);
1283
1284
// Only display summary if the game title has changed across discs.
1285
const bool display_summary = (s_state.game_id != info->id || s_state.game_title != info->title);
1286
1287
// If the game has a RetroAchievements entry but no achievements or leaderboards, enforcing hardcore mode
1288
// is pointless. Have to re-query leaderboards because hidden should still trip HC.
1289
if (!has_achievements && !rc_client_has_leaderboards(client, true))
1290
DisableHardcoreMode(false, false);
1291
1292
s_state.game_id = info->id;
1293
s_state.game_title = info->title;
1294
s_state.has_achievements = has_achievements;
1295
s_state.has_leaderboards = has_leaderboards;
1296
s_state.has_rich_presence = rc_client_has_rich_presence(client);
1297
1298
// ensure fullscreen UI is ready for notifications
1299
if (display_summary)
1300
GPUThread::RunOnThread(&FullscreenUI::Initialize);
1301
1302
s_state.game_icon_url =
1303
info->badge_url ? std::string(info->badge_url) : GetImageURL(info->badge_name, RC_IMAGE_TYPE_GAME);
1304
s_state.game_icon = GetLocalImagePath(info->badge_name, RC_IMAGE_TYPE_GAME);
1305
if (!s_state.game_icon.empty() && !s_state.game_icon_url.empty() &&
1306
!FileSystem::FileExists(s_state.game_icon.c_str()))
1307
DownloadImage(s_state.game_icon_url, s_state.game_icon);
1308
1309
// update progress database on first load, in case it was played on another PC
1310
UpdateGameSummary(true);
1311
1312
if (display_summary)
1313
DisplayAchievementSummary();
1314
1315
Host::OnAchievementsRefreshed();
1316
}
1317
1318
void Achievements::ClearGameInfo()
1319
{
1320
ClearUIState();
1321
1322
if (s_state.load_game_request)
1323
{
1324
rc_client_abort_async(s_state.client, s_state.load_game_request);
1325
s_state.load_game_request = nullptr;
1326
}
1327
rc_client_unload_game(s_state.client);
1328
1329
s_state.active_leaderboard_trackers = {};
1330
s_state.active_challenge_indicators = {};
1331
s_state.active_progress_indicator.reset();
1332
s_state.game_id = 0;
1333
s_state.game_title = {};
1334
s_state.game_icon = {};
1335
s_state.game_icon_url = {};
1336
s_state.has_achievements = false;
1337
s_state.has_leaderboards = false;
1338
s_state.has_rich_presence = false;
1339
s_state.rich_presence_string = {};
1340
s_state.game_summary = {};
1341
1342
Host::OnAchievementsRefreshed();
1343
}
1344
1345
void Achievements::ClearGameHash()
1346
{
1347
s_state.game_path = {};
1348
s_state.game_hash.reset();
1349
}
1350
1351
void Achievements::DisplayAchievementSummary()
1352
{
1353
if (g_settings.achievements_notifications)
1354
{
1355
SmallString summary;
1356
if (s_state.game_summary.num_core_achievements > 0)
1357
{
1358
summary.format(
1359
TRANSLATE_FS("Achievements", "{0}, {1}."),
1360
SmallString::from_format(TRANSLATE_PLURAL_FS("Achievements", "You have unlocked {} of %n achievements",
1361
"Achievement popup", s_state.game_summary.num_core_achievements),
1362
s_state.game_summary.num_unlocked_achievements),
1363
SmallString::from_format(TRANSLATE_PLURAL_FS("Achievements", "and earned {} of %n points", "Achievement popup",
1364
s_state.game_summary.points_core),
1365
s_state.game_summary.points_unlocked));
1366
1367
summary.append('\n');
1368
if (IsHardcoreModeActive())
1369
{
1370
summary.append(
1371
TRANSLATE_SV("Achievements", "Hardcore mode is enabled. Cheats and save states are unavailable."));
1372
}
1373
else
1374
{
1375
summary.append(TRANSLATE_SV("Achievements", "Hardcore mode is disabled. Leaderboards will not be tracked."));
1376
}
1377
}
1378
else
1379
{
1380
summary.assign(TRANSLATE_SV("Achievements", "This game has no achievements."));
1381
}
1382
1383
GPUThread::RunOnThread([title = s_state.game_title, summary = std::string(summary.view()), icon = s_state.game_icon,
1384
time = IsHardcoreModeActive() ? ACHIEVEMENT_SUMMARY_NOTIFICATION_TIME_HC :
1385
ACHIEVEMENT_SUMMARY_NOTIFICATION_TIME]() mutable {
1386
if (!FullscreenUI::Initialize())
1387
return;
1388
1389
ImGuiFullscreen::AddNotification("AchievementsSummary", time, std::move(title), std::move(summary),
1390
std::move(icon));
1391
});
1392
1393
if (s_state.game_summary.num_unsupported_achievements > 0)
1394
{
1395
GPUThread::RunOnThread([num_unsupported = s_state.game_summary.num_unsupported_achievements]() mutable {
1396
if (!FullscreenUI::Initialize())
1397
return;
1398
1399
ImGuiFullscreen::AddNotification("UnsupportedAchievements", ACHIEVEMENT_SUMMARY_UNSUPPORTED_TIME,
1400
TRANSLATE_STR("Achievements", "Unsupported Achievements"),
1401
TRANSLATE_PLURAL_STR("Achievements",
1402
"%n achievements are not supported by DuckStation.",
1403
"Achievement popup", num_unsupported),
1404
"images/warning.svg");
1405
});
1406
}
1407
}
1408
1409
// Technically not going through the resource API, but since we're passing this to something else, we can't.
1410
if (g_settings.achievements_sound_effects)
1411
PlatformMisc::PlaySoundAsync(EmuFolders::GetOverridableResourcePath(INFO_SOUND_NAME).c_str());
1412
}
1413
1414
void Achievements::DisplayHardcoreDeferredMessage()
1415
{
1416
if (g_settings.achievements_hardcore_mode && System::IsValid())
1417
{
1418
GPUThread::RunOnThread([]() {
1419
if (!FullscreenUI::Initialize())
1420
return;
1421
1422
ImGuiFullscreen::ShowToast(std::string(),
1423
TRANSLATE_STR("Achievements", "Hardcore mode will be enabled on system reset."),
1424
Host::OSD_WARNING_DURATION);
1425
});
1426
}
1427
}
1428
1429
void Achievements::HandleResetEvent(const rc_client_event_t* event)
1430
{
1431
WARNING_LOG("Ignoring RC_CLIENT_EVENT_RESET.");
1432
}
1433
1434
void Achievements::HandleUnlockEvent(const rc_client_event_t* event)
1435
{
1436
const rc_client_achievement_t* cheevo = event->achievement;
1437
DebugAssert(cheevo);
1438
1439
INFO_LOG("Achievement {} ({}) for game {} unlocked", cheevo->title, cheevo->id, s_state.game_id);
1440
UpdateGameSummary(true);
1441
1442
if (g_settings.achievements_notifications)
1443
{
1444
std::string title;
1445
if (cheevo->category == RC_CLIENT_ACHIEVEMENT_CATEGORY_UNOFFICIAL)
1446
title = fmt::format(TRANSLATE_FS("Achievements", "{} (Unofficial)"), cheevo->title);
1447
else
1448
title = cheevo->title;
1449
1450
std::string badge_path = GetAchievementBadgePath(cheevo, false);
1451
1452
GPUThread::RunOnThread([id = cheevo->id, duration = g_settings.achievements_notification_duration,
1453
title = std::move(title), description = std::string(cheevo->description),
1454
badge_path = std::move(badge_path)]() mutable {
1455
if (!FullscreenUI::Initialize())
1456
return;
1457
1458
ImGuiFullscreen::AddNotification(fmt::format("achievement_unlock_{}", id), static_cast<float>(duration),
1459
std::move(title), std::move(description), std::move(badge_path));
1460
});
1461
}
1462
1463
if (g_settings.achievements_sound_effects)
1464
PlatformMisc::PlaySoundAsync(EmuFolders::GetOverridableResourcePath(UNLOCK_SOUND_NAME).c_str());
1465
}
1466
1467
void Achievements::HandleGameCompleteEvent(const rc_client_event_t* event)
1468
{
1469
INFO_LOG("Game {} complete", s_state.game_id);
1470
UpdateGameSummary(false);
1471
1472
if (g_settings.achievements_notifications)
1473
{
1474
std::string message = fmt::format(
1475
TRANSLATE_FS("Achievements", "Game complete.\n{0}, {1}."),
1476
TRANSLATE_PLURAL_STR("Achievements", "%n achievements", "Mastery popup",
1477
s_state.game_summary.num_unlocked_achievements),
1478
TRANSLATE_PLURAL_STR("Achievements", "%n points", "Achievement points", s_state.game_summary.points_unlocked));
1479
1480
GPUThread::RunOnThread(
1481
[title = s_state.game_title, message = std::move(message), icon = s_state.game_icon]() mutable {
1482
if (!FullscreenUI::Initialize())
1483
return;
1484
1485
ImGuiFullscreen::AddNotification("achievement_mastery", GAME_COMPLETE_NOTIFICATION_TIME, std::move(title),
1486
std::move(message), std::move(icon));
1487
});
1488
}
1489
}
1490
1491
void Achievements::HandleSubsetCompleteEvent(const rc_client_event_t* event)
1492
{
1493
INFO_LOG("Subset {} ({}) complete", event->subset->title, event->subset->id);
1494
UpdateGameSummary(false);
1495
1496
if (g_settings.achievements_notifications && event->subset->badge_name[0] != '\0')
1497
{
1498
// Need to grab the icon for the subset.
1499
std::string badge_path = GetLocalImagePath(event->subset->badge_name, RC_IMAGE_TYPE_GAME);
1500
if (!FileSystem::FileExists(badge_path.c_str()))
1501
{
1502
std::string url;
1503
if (IsUsingRAIntegration() || !event->subset->badge_url)
1504
url = GetImageURL(event->subset->badge_name, RC_IMAGE_TYPE_GAME);
1505
else
1506
url = event->subset->badge_url;
1507
DownloadImage(std::move(url), badge_path);
1508
}
1509
1510
std::string title = event->subset->title;
1511
std::string message = fmt::format(
1512
TRANSLATE_FS("Achievements", "Subset complete.\n{0}, {1}."),
1513
TRANSLATE_PLURAL_STR("Achievements", "%n achievements", "Mastery popup",
1514
s_state.game_summary.num_unlocked_achievements),
1515
TRANSLATE_PLURAL_STR("Achievements", "%n points", "Achievement points", s_state.game_summary.points_unlocked));
1516
1517
GPUThread::RunOnThread(
1518
[title = std::move(title), message = std::move(message), badge_path = std::move(badge_path)]() mutable {
1519
if (!FullscreenUI::Initialize())
1520
return;
1521
1522
ImGuiFullscreen::AddNotification("achievement_mastery", GAME_COMPLETE_NOTIFICATION_TIME, std::move(title),
1523
std::move(message), std::move(badge_path));
1524
});
1525
}
1526
}
1527
1528
void Achievements::HandleLeaderboardStartedEvent(const rc_client_event_t* event)
1529
{
1530
DEV_LOG("Leaderboard {} ({}) started", event->leaderboard->id, event->leaderboard->title);
1531
1532
if (g_settings.achievements_leaderboard_notifications)
1533
{
1534
std::string title = event->leaderboard->title;
1535
std::string message = TRANSLATE_STR("Achievements", "Leaderboard attempt started.");
1536
1537
GPUThread::RunOnThread([id = event->leaderboard->id, title = std::move(title), message = std::move(message),
1538
icon = s_state.game_icon]() mutable {
1539
if (!FullscreenUI::Initialize())
1540
return;
1541
1542
ImGuiFullscreen::AddNotification(fmt::format("leaderboard_{}", id), LEADERBOARD_STARTED_NOTIFICATION_TIME,
1543
std::move(title), std::move(message), std::move(icon));
1544
});
1545
}
1546
}
1547
1548
void Achievements::HandleLeaderboardFailedEvent(const rc_client_event_t* event)
1549
{
1550
DEV_LOG("Leaderboard {} ({}) failed", event->leaderboard->id, event->leaderboard->title);
1551
1552
if (g_settings.achievements_leaderboard_notifications)
1553
{
1554
std::string title = event->leaderboard->title;
1555
std::string message = TRANSLATE_STR("Achievements", "Leaderboard attempt failed.");
1556
1557
GPUThread::RunOnThread([id = event->leaderboard->id, title = std::move(title), message = std::move(message),
1558
icon = s_state.game_icon]() mutable {
1559
if (!FullscreenUI::Initialize())
1560
return;
1561
1562
ImGuiFullscreen::AddNotification(fmt::format("leaderboard_{}", id), LEADERBOARD_FAILED_NOTIFICATION_TIME,
1563
std::move(title), std::move(message), std::move(icon));
1564
});
1565
}
1566
}
1567
1568
void Achievements::HandleLeaderboardSubmittedEvent(const rc_client_event_t* event)
1569
{
1570
DEV_LOG("Leaderboard {} ({}) submitted", event->leaderboard->id, event->leaderboard->title);
1571
1572
if (g_settings.achievements_leaderboard_notifications)
1573
{
1574
static const char* value_strings[NUM_RC_CLIENT_LEADERBOARD_FORMATS] = {
1575
TRANSLATE_NOOP("Achievements", "Your Time: {}{}"),
1576
TRANSLATE_NOOP("Achievements", "Your Score: {}{}"),
1577
TRANSLATE_NOOP("Achievements", "Your Value: {}{}"),
1578
};
1579
1580
std::string title = event->leaderboard->title;
1581
std::string message = fmt::format(
1582
fmt::runtime(Host::TranslateToStringView(
1583
"Achievements",
1584
value_strings[std::min<u8>(event->leaderboard->format, NUM_RC_CLIENT_LEADERBOARD_FORMATS - 1)])),
1585
event->leaderboard->tracker_value ? event->leaderboard->tracker_value : "Unknown",
1586
g_settings.achievements_spectator_mode ? std::string_view() : TRANSLATE_SV("Achievements", " (Submitting)"));
1587
1588
GPUThread::RunOnThread([id = event->leaderboard->id, title = std::move(title), message = std::move(message),
1589
icon = s_state.game_icon]() mutable {
1590
if (!FullscreenUI::Initialize())
1591
return;
1592
ImGuiFullscreen::AddNotification(fmt::format("leaderboard_{}", id),
1593
static_cast<float>(g_settings.achievements_leaderboard_duration),
1594
std::move(title), std::move(message), std::move(icon));
1595
});
1596
}
1597
1598
if (g_settings.achievements_sound_effects)
1599
PlatformMisc::PlaySoundAsync(EmuFolders::GetOverridableResourcePath(LBSUBMIT_SOUND_NAME).c_str());
1600
}
1601
1602
void Achievements::HandleLeaderboardScoreboardEvent(const rc_client_event_t* event)
1603
{
1604
DEV_LOG("Leaderboard {} scoreboard rank {} of {}", event->leaderboard_scoreboard->leaderboard_id,
1605
event->leaderboard_scoreboard->new_rank, event->leaderboard_scoreboard->num_entries);
1606
1607
if (g_settings.achievements_leaderboard_notifications)
1608
{
1609
static const char* value_strings[NUM_RC_CLIENT_LEADERBOARD_FORMATS] = {
1610
TRANSLATE_NOOP("Achievements", "Your Time: {} (Best: {})"),
1611
TRANSLATE_NOOP("Achievements", "Your Score: {} (Best: {})"),
1612
TRANSLATE_NOOP("Achievements", "Your Value: {} (Best: {})"),
1613
};
1614
1615
std::string title = event->leaderboard->title;
1616
std::string message = fmt::format(
1617
TRANSLATE_FS("Achievements", "{}\nLeaderboard Position: {} of {}"),
1618
fmt::format(fmt::runtime(Host::TranslateToStringView(
1619
"Achievements",
1620
value_strings[std::min<u8>(event->leaderboard->format, NUM_RC_CLIENT_LEADERBOARD_FORMATS - 1)])),
1621
event->leaderboard_scoreboard->submitted_score, event->leaderboard_scoreboard->best_score),
1622
event->leaderboard_scoreboard->new_rank, event->leaderboard_scoreboard->num_entries);
1623
1624
GPUThread::RunOnThread([id = event->leaderboard->id, title = std::move(title), message = std::move(message),
1625
icon = s_state.game_icon]() mutable {
1626
if (!FullscreenUI::Initialize())
1627
return;
1628
1629
ImGuiFullscreen::AddNotification(fmt::format("leaderboard_{}", id),
1630
static_cast<float>(g_settings.achievements_leaderboard_duration),
1631
std::move(title), std::move(message), std::move(icon));
1632
});
1633
}
1634
}
1635
1636
void Achievements::HandleLeaderboardTrackerShowEvent(const rc_client_event_t* event)
1637
{
1638
DEV_LOG("Showing leaderboard tracker: {}: {}", event->leaderboard_tracker->id, event->leaderboard_tracker->display);
1639
1640
if (!g_settings.achievements_leaderboard_trackers)
1641
return;
1642
1643
const u32 id = event->leaderboard_tracker->id;
1644
auto it = std::find_if(s_state.active_leaderboard_trackers.begin(), s_state.active_leaderboard_trackers.end(),
1645
[id](const auto& it) { return it.tracker_id == id; });
1646
if (it != s_state.active_leaderboard_trackers.end())
1647
{
1648
WARNING_LOG("Leaderboard tracker {} already active", id);
1649
it->text = event->leaderboard_tracker->display;
1650
it->active = true;
1651
return;
1652
}
1653
1654
s_state.active_leaderboard_trackers.push_back(LeaderboardTrackerIndicator{
1655
.tracker_id = id,
1656
.text = event->leaderboard_tracker->display,
1657
.opacity = 0.0f,
1658
.active = true,
1659
});
1660
}
1661
1662
void Achievements::HandleLeaderboardTrackerHideEvent(const rc_client_event_t* event)
1663
{
1664
const u32 id = event->leaderboard_tracker->id;
1665
DEV_LOG("Hiding leaderboard tracker: {}", id);
1666
1667
auto it = std::find_if(s_state.active_leaderboard_trackers.begin(), s_state.active_leaderboard_trackers.end(),
1668
[id](const auto& it) { return it.tracker_id == id; });
1669
if (it == s_state.active_leaderboard_trackers.end())
1670
return;
1671
1672
it->active = false;
1673
}
1674
1675
void Achievements::HandleLeaderboardTrackerUpdateEvent(const rc_client_event_t* event)
1676
{
1677
const u32 id = event->leaderboard_tracker->id;
1678
DEV_LOG("Updating leaderboard tracker: {}: {}", id, event->leaderboard_tracker->display);
1679
1680
auto it = std::find_if(s_state.active_leaderboard_trackers.begin(), s_state.active_leaderboard_trackers.end(),
1681
[id](const auto& it) { return it.tracker_id == id; });
1682
if (it == s_state.active_leaderboard_trackers.end())
1683
return;
1684
1685
it->text = event->leaderboard_tracker->display;
1686
it->active = true;
1687
}
1688
1689
void Achievements::HandleAchievementChallengeIndicatorShowEvent(const rc_client_event_t* event)
1690
{
1691
if (const auto it =
1692
std::find_if(s_state.active_challenge_indicators.begin(), s_state.active_challenge_indicators.end(),
1693
[event](const AchievementChallengeIndicator& it) { return it.achievement == event->achievement; });
1694
it != s_state.active_challenge_indicators.end())
1695
{
1696
it->active = true;
1697
return;
1698
}
1699
1700
std::string badge_path = GetAchievementBadgePath(event->achievement, false);
1701
1702
// we still track these even if the option is disabled, so that they can be displayed in the pause menu
1703
if (g_settings.achievements_challenge_indicator_mode == AchievementChallengeIndicatorMode::Notification)
1704
{
1705
std::string description = fmt::format(TRANSLATE_FS("Achievements", "Challenge Started: {}"),
1706
event->achievement->description ? event->achievement->description : "");
1707
GPUThread::RunOnThread([title = std::string(event->achievement->title), description = std::move(description),
1708
badge_path, id = event->achievement->id]() mutable {
1709
if (!FullscreenUI::Initialize())
1710
return;
1711
1712
ImGuiFullscreen::AddNotification(fmt::format("AchievementChallenge{}", id), LEADERBOARD_STARTED_NOTIFICATION_TIME,
1713
std::move(title), std::move(description), std::move(badge_path));
1714
});
1715
}
1716
1717
s_state.active_challenge_indicators.push_back(
1718
AchievementChallengeIndicator{.achievement = event->achievement,
1719
.badge_path = std::move(badge_path),
1720
.time_remaining = LEADERBOARD_STARTED_NOTIFICATION_TIME,
1721
.opacity = 0.0f,
1722
.active = true});
1723
1724
DEV_LOG("Show challenge indicator for {} ({})", event->achievement->id, event->achievement->title);
1725
}
1726
1727
void Achievements::HandleAchievementChallengeIndicatorHideEvent(const rc_client_event_t* event)
1728
{
1729
auto it =
1730
std::find_if(s_state.active_challenge_indicators.begin(), s_state.active_challenge_indicators.end(),
1731
[event](const AchievementChallengeIndicator& it) { return it.achievement == event->achievement; });
1732
if (it == s_state.active_challenge_indicators.end())
1733
return;
1734
1735
DEV_LOG("Hide challenge indicator for {} ({})", event->achievement->id, event->achievement->title);
1736
1737
if (g_settings.achievements_challenge_indicator_mode == AchievementChallengeIndicatorMode::Notification ||
1738
g_settings.achievements_challenge_indicator_mode == AchievementChallengeIndicatorMode::Disabled)
1739
{
1740
// remove it here, because it won't naturally decay
1741
s_state.active_challenge_indicators.erase(it);
1742
return;
1743
}
1744
1745
it->active = false;
1746
}
1747
1748
void Achievements::HandleAchievementProgressIndicatorShowEvent(const rc_client_event_t* event)
1749
{
1750
DEV_LOG("Showing progress indicator: {} ({}): {}", event->achievement->id, event->achievement->title,
1751
event->achievement->measured_progress);
1752
1753
if (!g_settings.achievements_progress_indicators)
1754
return;
1755
1756
if (!s_state.active_progress_indicator.has_value())
1757
s_state.active_progress_indicator.emplace();
1758
1759
s_state.active_progress_indicator->achievement = event->achievement;
1760
s_state.active_progress_indicator->badge_path = GetAchievementBadgePath(event->achievement, false);
1761
s_state.active_progress_indicator->opacity = 0.0f;
1762
s_state.active_progress_indicator->active = true;
1763
}
1764
1765
void Achievements::HandleAchievementProgressIndicatorHideEvent(const rc_client_event_t* event)
1766
{
1767
if (!s_state.active_progress_indicator.has_value())
1768
return;
1769
1770
DEV_LOG("Hiding progress indicator");
1771
1772
if (!g_settings.achievements_progress_indicators)
1773
{
1774
s_state.active_progress_indicator.reset();
1775
return;
1776
}
1777
1778
s_state.active_progress_indicator->active = false;
1779
}
1780
1781
void Achievements::HandleAchievementProgressIndicatorUpdateEvent(const rc_client_event_t* event)
1782
{
1783
DEV_LOG("Updating progress indicator: {} ({}): {}", event->achievement->id, event->achievement->title,
1784
event->achievement->measured_progress);
1785
if (!s_state.active_progress_indicator.has_value())
1786
return;
1787
1788
s_state.active_progress_indicator->achievement = event->achievement;
1789
s_state.active_progress_indicator->active = true;
1790
}
1791
1792
void Achievements::HandleServerErrorEvent(const rc_client_event_t* event)
1793
{
1794
std::string message =
1795
fmt::format(TRANSLATE_FS("Achievements", "Server error in {}:\n{}"),
1796
event->server_error->api ? event->server_error->api : "UNKNOWN",
1797
event->server_error->error_message ? event->server_error->error_message : "UNKNOWN");
1798
ERROR_LOG(message.c_str());
1799
Host::AddOSDMessage(std::move(message), Host::OSD_ERROR_DURATION);
1800
}
1801
1802
void Achievements::HandleServerDisconnectedEvent(const rc_client_event_t* event)
1803
{
1804
WARNING_LOG("Server disconnected.");
1805
1806
GPUThread::RunOnThread([]() {
1807
if (!FullscreenUI::Initialize())
1808
return;
1809
1810
ImGuiFullscreen::ShowToast(
1811
TRANSLATE_STR("Achievements", "Achievements Disconnected"),
1812
TRANSLATE_STR("Achievements",
1813
"An unlock request could not be completed. We will keep retrying to submit this request."),
1814
Host::OSD_ERROR_DURATION);
1815
});
1816
}
1817
1818
void Achievements::HandleServerReconnectedEvent(const rc_client_event_t* event)
1819
{
1820
WARNING_LOG("Server reconnected.");
1821
1822
GPUThread::RunOnThread([]() {
1823
if (!FullscreenUI::Initialize())
1824
return;
1825
1826
ImGuiFullscreen::ShowToast(TRANSLATE_STR("Achievements", "Achievements Reconnected"),
1827
TRANSLATE_STR("Achievements", "All pending unlock requests have completed."),
1828
Host::OSD_INFO_DURATION);
1829
});
1830
}
1831
1832
void Achievements::EnableHardcodeMode(bool display_message, bool display_game_summary)
1833
{
1834
DebugAssert(IsActive());
1835
if (rc_client_get_hardcore_enabled(s_state.client))
1836
return;
1837
1838
rc_client_set_hardcore_enabled(s_state.client, true);
1839
OnHardcoreModeChanged(true, display_message, display_game_summary);
1840
}
1841
1842
void Achievements::DisableHardcoreMode(bool show_message, bool display_game_summary)
1843
{
1844
if (!IsActive())
1845
return;
1846
1847
const auto lock = GetLock();
1848
if (!rc_client_get_hardcore_enabled(s_state.client))
1849
return;
1850
1851
rc_client_set_hardcore_enabled(s_state.client, false);
1852
OnHardcoreModeChanged(false, show_message, display_game_summary);
1853
}
1854
1855
void Achievements::OnHardcoreModeChanged(bool enabled, bool display_message, bool display_game_summary)
1856
{
1857
INFO_COLOR_LOG(StrongYellow, "Hardcore mode/restrictions are now {}.", enabled ? "ACTIVE" : "inactive");
1858
1859
if (System::IsValid() && display_message)
1860
{
1861
GPUThread::RunOnThread([enabled]() {
1862
if (!FullscreenUI::Initialize())
1863
return;
1864
1865
ImGuiFullscreen::ShowToast(std::string(),
1866
enabled ? TRANSLATE_STR("Achievements", "Hardcore mode is now enabled.") :
1867
TRANSLATE_STR("Achievements", "Hardcore mode is now disabled."),
1868
Host::OSD_INFO_DURATION);
1869
});
1870
}
1871
1872
if (HasActiveGame() && display_game_summary)
1873
{
1874
UpdateGameSummary(true);
1875
DisplayAchievementSummary();
1876
}
1877
1878
DebugAssert((rc_client_get_hardcore_enabled(s_state.client) != 0) == enabled);
1879
1880
// Reload setting to permit cheating-like things if we were just disabled.
1881
if (System::IsValid())
1882
{
1883
// Make sure a pre-existing cheat file hasn't been loaded when resetting after enabling HC mode.
1884
Cheats::ReloadCheats(true, true, false, true, true);
1885
1886
// Defer settings update in case something is using it.
1887
Host::RunOnCPUThread([]() { System::ApplySettings(false); });
1888
}
1889
else if (System::GetState() == System::State::Starting)
1890
{
1891
// Initial HC enable, activate restrictions.
1892
System::ApplySettings(false);
1893
}
1894
1895
// Toss away UI state, because it's invalid now
1896
ClearUIState();
1897
1898
Host::OnAchievementsHardcoreModeChanged(enabled);
1899
}
1900
1901
bool Achievements::DoState(StateWrapper& sw)
1902
{
1903
static constexpr u32 REQUIRED_VERSION = 56;
1904
1905
// if we're inactive, we still need to skip the data (if any)
1906
if (!IsActive())
1907
{
1908
u32 data_size = 0;
1909
sw.DoEx(&data_size, REQUIRED_VERSION, 0u);
1910
if (data_size > 0)
1911
sw.SkipBytes(data_size);
1912
1913
return !sw.HasError();
1914
}
1915
1916
std::unique_lock lock(s_state.mutex);
1917
1918
if (sw.IsReading())
1919
{
1920
// if we're active, make sure we've downloaded and activated all the achievements
1921
// before deserializing, otherwise that state's going to get lost.
1922
if (s_state.load_game_request)
1923
{
1924
// Messy because GPU-thread, but at least it looks pretty.
1925
GPUThread::RunOnThread([]() {
1926
FullscreenUI::OpenLoadingScreen(System::GetImageForLoadingScreen(GPUThread::GetGamePath()),
1927
TRANSLATE_SV("Achievements", "Downloading achievements data..."));
1928
});
1929
1930
s_state.http_downloader->WaitForAllRequests();
1931
1932
GPUThread::RunOnThread([]() { FullscreenUI::CloseLoadingScreen(); });
1933
}
1934
1935
u32 data_size = 0;
1936
sw.DoEx(&data_size, REQUIRED_VERSION, 0u);
1937
if (data_size == 0)
1938
{
1939
// reset runtime, no data (state might've been created without cheevos)
1940
WARNING_LOG("State is missing cheevos data, resetting runtime");
1941
rc_client_reset(s_state.client);
1942
1943
return !sw.HasError();
1944
}
1945
1946
const std::span<u8> data = sw.GetDeferredBytes(data_size);
1947
if (sw.HasError())
1948
return false;
1949
1950
const int result = rc_client_deserialize_progress_sized(s_state.client, data.data(), data_size);
1951
if (result != RC_OK)
1952
{
1953
WARNING_LOG("Failed to deserialize cheevos state ({}), resetting", result);
1954
rc_client_reset(s_state.client);
1955
}
1956
1957
return true;
1958
}
1959
else
1960
{
1961
const size_t size_pos = sw.GetPosition();
1962
1963
u32 data_size = static_cast<u32>(rc_client_progress_size(s_state.client));
1964
sw.Do(&data_size);
1965
1966
if (data_size > 0)
1967
{
1968
const std::span<u8> data = sw.GetDeferredBytes(data_size);
1969
if (!sw.HasError()) [[likely]]
1970
{
1971
const int result = rc_client_serialize_progress_sized(s_state.client, data.data(), data_size);
1972
if (result != RC_OK)
1973
{
1974
// set data to zero, effectively serializing nothing
1975
WARNING_LOG("Failed to serialize cheevos state ({})", result);
1976
data_size = 0;
1977
sw.SetPosition(size_pos);
1978
sw.Do(&data_size);
1979
}
1980
}
1981
}
1982
1983
return !sw.HasError();
1984
}
1985
}
1986
1987
std::string Achievements::GetAchievementBadgePath(const rc_client_achievement_t* achievement, bool locked,
1988
bool download_if_missing)
1989
{
1990
const u32 image_type = locked ? RC_IMAGE_TYPE_ACHIEVEMENT_LOCKED : RC_IMAGE_TYPE_ACHIEVEMENT;
1991
const std::string path = GetLocalImagePath(achievement->badge_name, image_type);
1992
if (download_if_missing && !path.empty() && !FileSystem::FileExists(path.c_str()))
1993
{
1994
std::string url;
1995
const char* url_ptr;
1996
1997
// RAIntegration doesn't set the URL fields.
1998
if (IsUsingRAIntegration() || !(url_ptr = locked ? achievement->badge_locked_url : achievement->badge_url))
1999
url = GetImageURL(achievement->badge_name, image_type);
2000
else
2001
url = std::string(url_ptr);
2002
2003
if (url.empty()) [[unlikely]]
2004
ReportFmtError("Acheivement {} with badge name {} has no badge URL", achievement->id, achievement->badge_name);
2005
else
2006
DownloadImage(std::string(url), path);
2007
}
2008
2009
return path;
2010
}
2011
2012
const std::string& Achievements::GetCachedAchievementBadgePath(const rc_client_achievement_t* achievement, bool locked)
2013
{
2014
for (const auto& [l_cheevo, l_path, l_state] : s_state.achievement_badge_paths)
2015
{
2016
if (l_cheevo == achievement && l_state == locked)
2017
return l_path;
2018
}
2019
2020
std::string path = GetAchievementBadgePath(achievement, locked);
2021
return std::get<1>(s_state.achievement_badge_paths.emplace_back(achievement, std::move(path), locked));
2022
}
2023
2024
std::string Achievements::GetLeaderboardUserBadgePath(const rc_client_leaderboard_entry_t* entry)
2025
{
2026
const std::string path = GetLocalImagePath(entry->user, RC_IMAGE_TYPE_USER);
2027
if (!FileSystem::FileExists(path.c_str()))
2028
{
2029
std::string url = GetImageURL(entry->user, RC_IMAGE_TYPE_USER);
2030
if (!url.empty())
2031
DownloadImage(std::move(url), path);
2032
}
2033
2034
return path;
2035
}
2036
2037
bool Achievements::IsLoggedIn()
2038
{
2039
return (rc_client_get_user_info(s_state.client) != nullptr);
2040
}
2041
2042
bool Achievements::IsLoggedInOrLoggingIn()
2043
{
2044
return (IsLoggedIn() || s_state.login_request);
2045
}
2046
2047
bool Achievements::Login(const char* username, const char* password, Error* error)
2048
{
2049
auto lock = GetLock();
2050
2051
// We need to use a temporary client if achievements aren't currently active.
2052
rc_client_t* client = s_state.client;
2053
HTTPDownloader* http = s_state.http_downloader.get();
2054
const bool is_temporary_client = (client == nullptr);
2055
std::unique_ptr<HTTPDownloader> temporary_downloader;
2056
ScopedGuard temporary_client_guard = [&client, is_temporary_client, &temporary_downloader]() {
2057
if (is_temporary_client)
2058
DestroyClient(&client, &temporary_downloader);
2059
};
2060
if (is_temporary_client)
2061
{
2062
if (!CreateClient(&client, &temporary_downloader))
2063
{
2064
Error::SetString(error, "Failed to create client.");
2065
return false;
2066
}
2067
http = temporary_downloader.get();
2068
}
2069
2070
LoginWithPasswordParameters params = {username, error, nullptr, false};
2071
2072
params.request =
2073
rc_client_begin_login_with_password(client, username, password, ClientLoginWithPasswordCallback, &params);
2074
if (!params.request)
2075
{
2076
Error::SetString(error, "Failed to create login request.");
2077
return false;
2078
}
2079
2080
// Wait until the login request completes.
2081
http->WaitForAllRequestsWithYield([&lock]() { lock.unlock(); }, [&lock]() { lock.lock(); });
2082
Assert(!params.request);
2083
2084
// Success? Assume the callback set the error message.
2085
if (!params.result)
2086
return false;
2087
2088
// If we were't a temporary client, get the game loaded.
2089
if (System::IsValid() && !is_temporary_client)
2090
{
2091
IdentifyCurrentGame();
2092
BeginLoadGame();
2093
}
2094
2095
return true;
2096
}
2097
2098
void Achievements::ClientLoginWithPasswordCallback(int result, const char* error_message, rc_client_t* client,
2099
void* userdata)
2100
{
2101
Assert(userdata);
2102
2103
LoginWithPasswordParameters* params = static_cast<LoginWithPasswordParameters*>(userdata);
2104
params->request = nullptr;
2105
2106
if (result != RC_OK)
2107
{
2108
ERROR_LOG("Login failed: {}: {}", rc_error_str(result), error_message ? error_message : "Unknown");
2109
Error::SetString(params->error,
2110
fmt::format("{}: {}", rc_error_str(result), error_message ? error_message : "Unknown"));
2111
params->result = false;
2112
return;
2113
}
2114
2115
// Grab the token from the client, and save it to the config.
2116
const rc_client_user_t* user = rc_client_get_user_info(client);
2117
if (!user || !user->token)
2118
{
2119
ERROR_LOG("rc_client_get_user_info() returned NULL");
2120
Error::SetString(params->error, "rc_client_get_user_info() returned NULL");
2121
params->result = false;
2122
return;
2123
}
2124
2125
params->result = true;
2126
2127
// Store configuration.
2128
Host::SetBaseStringSettingValue("Cheevos", "Username", params->username);
2129
Host::SetBaseStringSettingValue("Cheevos", "Token", EncryptLoginToken(user->token, params->username));
2130
Host::SetBaseStringSettingValue("Cheevos", "LoginTimestamp", fmt::format("{}", std::time(nullptr)).c_str());
2131
Host::CommitBaseSettingChanges();
2132
2133
FinishLogin(client);
2134
}
2135
2136
void Achievements::ClientLoginWithTokenCallback(int result, const char* error_message, rc_client_t* client,
2137
void* userdata)
2138
{
2139
s_state.login_request = nullptr;
2140
2141
if (result == RC_INVALID_CREDENTIALS || result == RC_EXPIRED_TOKEN)
2142
{
2143
ERROR_LOG("Login failed due to invalid token: {}: {}", rc_error_str(result), error_message);
2144
Host::OnAchievementsLoginRequested(LoginRequestReason::TokenInvalid);
2145
return;
2146
}
2147
else if (result != RC_OK)
2148
{
2149
ERROR_LOG("Login failed: {}: {}", rc_error_str(result), error_message);
2150
2151
// only display user error if they've started a game
2152
if (System::IsValid())
2153
{
2154
std::string message = fmt::format(
2155
TRANSLATE_FS("Achievements", "Achievement unlocks will not be submitted for this session.\nError: {}"),
2156
error_message);
2157
GPUThread::RunOnThread([message = std::move(message)]() mutable {
2158
if (!GPUThread::HasGPUBackend() || !FullscreenUI::Initialize())
2159
return;
2160
2161
ImGuiFullscreen::AddNotification("AchievementsLoginFailed", Host::OSD_ERROR_DURATION,
2162
TRANSLATE_STR("Achievements", "RetroAchievements Login Failed"),
2163
std::move(message), "images/warning.svg");
2164
});
2165
}
2166
2167
return;
2168
}
2169
2170
FinishLogin(client);
2171
}
2172
2173
void Achievements::FinishLogin(const rc_client_t* client)
2174
{
2175
const rc_client_user_t* user = rc_client_get_user_info(client);
2176
if (!user)
2177
return;
2178
2179
PreloadHashDatabase();
2180
2181
Host::OnAchievementsLoginSuccess(user->username, user->score, user->score_softcore, user->num_unread_messages);
2182
2183
if (System::IsValid())
2184
{
2185
const auto lock = GetLock();
2186
if (s_state.client == client)
2187
Host::RunOnCPUThread(ShowLoginNotification);
2188
}
2189
}
2190
2191
void Achievements::ShowLoginNotification()
2192
{
2193
const rc_client_user_t* user = rc_client_get_user_info(s_state.client);
2194
if (!user)
2195
return;
2196
2197
if (g_settings.achievements_notifications)
2198
{
2199
std::string badge_path = GetLoggedInUserBadgePath();
2200
std::string title = user->display_name;
2201
2202
//: Summary for login notification.
2203
std::string summary = fmt::format(TRANSLATE_FS("Achievements", "Score: {} ({} softcore)\nUnread messages: {}"),
2204
user->score, user->score_softcore, user->num_unread_messages);
2205
2206
GPUThread::RunOnThread(
2207
[title = std::move(title), summary = std::move(summary), badge_path = std::move(badge_path)]() mutable {
2208
if (!FullscreenUI::Initialize())
2209
return;
2210
2211
ImGuiFullscreen::AddNotification("achievements_login", LOGIN_NOTIFICATION_TIME, std::move(title),
2212
std::move(summary), std::move(badge_path));
2213
});
2214
}
2215
}
2216
2217
const char* Achievements::GetLoggedInUserName()
2218
{
2219
const rc_client_user_t* user = rc_client_get_user_info(s_state.client);
2220
if (!user) [[unlikely]]
2221
return nullptr;
2222
2223
return user->username;
2224
}
2225
2226
std::string Achievements::GetLoggedInUserBadgePath()
2227
{
2228
std::string badge_path;
2229
2230
const rc_client_user_t* user = rc_client_get_user_info(s_state.client);
2231
if (!user) [[unlikely]]
2232
return badge_path;
2233
2234
badge_path = GetLocalImagePath(user->username, RC_IMAGE_TYPE_USER);
2235
if (!badge_path.empty() && !FileSystem::FileExists(badge_path.c_str())) [[unlikely]]
2236
{
2237
std::string url;
2238
if (IsUsingRAIntegration() || !user->avatar_url)
2239
url = GetImageURL(user->username, RC_IMAGE_TYPE_USER);
2240
else
2241
url = user->avatar_url;
2242
2243
DownloadImage(std::move(url), badge_path);
2244
}
2245
2246
return badge_path;
2247
}
2248
2249
SmallString Achievements::GetLoggedInUserPointsSummary()
2250
{
2251
SmallString ret;
2252
2253
const rc_client_user_t* user = rc_client_get_user_info(s_state.client);
2254
if (!user) [[unlikely]]
2255
return ret;
2256
2257
//: Score summary, shown in Big Picture mode.
2258
ret.format(TRANSLATE_FS("Achievements", "Score: {} ({} softcore)"), user->score, user->score_softcore);
2259
return ret;
2260
}
2261
2262
u32 Achievements::GetPauseThrottleFrames()
2263
{
2264
if (!IsActive() || !IsHardcoreModeActive())
2265
return 0;
2266
2267
u32 frames_remaining = 0;
2268
return rc_client_can_pause(s_state.client, &frames_remaining) ? 0 : frames_remaining;
2269
}
2270
2271
void Achievements::Logout()
2272
{
2273
if (IsActive())
2274
{
2275
const auto lock = GetLock();
2276
2277
if (HasActiveGame())
2278
{
2279
ClearGameInfo();
2280
DisableHardcoreMode(false, false);
2281
}
2282
2283
CancelHashDatabaseRequests();
2284
2285
INFO_LOG("Logging out...");
2286
rc_client_logout(s_state.client);
2287
}
2288
2289
INFO_LOG("Clearing credentials...");
2290
Host::DeleteBaseSettingValue("Cheevos", "Username");
2291
Host::DeleteBaseSettingValue("Cheevos", "Token");
2292
Host::DeleteBaseSettingValue("Cheevos", "LoginTimestamp");
2293
Host::CommitBaseSettingChanges();
2294
ClearProgressDatabase();
2295
}
2296
2297
bool Achievements::ConfirmHardcoreModeDisable(const char* trigger)
2298
{
2299
// I really hope this doesn't deadlock :/
2300
const bool confirmed = Host::ConfirmMessage(
2301
TRANSLATE("Achievements", "Confirm Hardcore Mode Disable"),
2302
fmt::format(TRANSLATE_FS("Achievements", "{0} cannot be performed while hardcore mode is active. Do you "
2303
"want to disable hardcore mode? {0} will be cancelled if you select No."),
2304
trigger));
2305
if (!confirmed)
2306
return false;
2307
2308
DisableHardcoreMode(true, true);
2309
return true;
2310
}
2311
2312
void Achievements::ConfirmHardcoreModeDisableAsync(const char* trigger, std::function<void(bool)> callback)
2313
{
2314
Host::ConfirmMessageAsync(
2315
TRANSLATE_STR("Achievements", "Confirm Hardcore Mode Disable"),
2316
fmt::format(TRANSLATE_FS("Achievements", "{0} cannot be performed while hardcore mode is active. Do you want to "
2317
"disable hardcore mode? {0} will be cancelled if you select No."),
2318
trigger),
2319
[callback = std::move(callback)](bool res) mutable {
2320
// don't run the callback in the middle of rendering the UI
2321
Host::RunOnCPUThread([callback = std::move(callback), res]() {
2322
if (res)
2323
DisableHardcoreMode(true, true);
2324
callback(res);
2325
});
2326
});
2327
}
2328
2329
void Achievements::ClearUIState()
2330
{
2331
if (!FullscreenUI::IsInitialized())
2332
return;
2333
2334
CloseLeaderboard();
2335
2336
s_state.achievement_badge_paths = {};
2337
2338
s_state.leaderboard_user_icon_paths = {};
2339
s_state.leaderboard_entry_lists = {};
2340
if (s_state.leaderboard_list)
2341
{
2342
rc_client_destroy_leaderboard_list(s_state.leaderboard_list);
2343
s_state.leaderboard_list = nullptr;
2344
}
2345
2346
if (s_state.achievement_list)
2347
{
2348
rc_client_destroy_achievement_list(s_state.achievement_list);
2349
s_state.achievement_list = nullptr;
2350
}
2351
2352
s_state.most_recent_unlock.reset();
2353
s_state.achievement_nearest_completion.reset();
2354
}
2355
2356
static float IndicatorOpacity(float delta_time, bool active, float& opacity)
2357
{
2358
float target, rate;
2359
if (active)
2360
{
2361
target = 1.0f;
2362
rate = Achievements::INDICATOR_FADE_IN_TIME;
2363
}
2364
else
2365
{
2366
target = 0.0f;
2367
rate = -Achievements::INDICATOR_FADE_OUT_TIME;
2368
}
2369
2370
if (opacity != target)
2371
opacity = ImSaturate(opacity + (delta_time / rate));
2372
2373
return opacity;
2374
}
2375
2376
void Achievements::DrawGameOverlays()
2377
{
2378
using ImGuiFullscreen::LayoutScale;
2379
using ImGuiFullscreen::ModAlpha;
2380
using ImGuiFullscreen::RenderShadowedTextClipped;
2381
using ImGuiFullscreen::UIStyle;
2382
2383
if (!HasActiveGame())
2384
return;
2385
2386
const auto lock = GetLock();
2387
2388
constexpr float bg_opacity = 0.8f;
2389
2390
const float margin =
2391
std::max(ImCeil(ImGuiManager::GetScreenMargin() * ImGuiManager::GetGlobalScale()), LayoutScale(10.0f));
2392
const float spacing = LayoutScale(10.0f);
2393
const float padding = LayoutScale(10.0f);
2394
const float rounding = LayoutScale(10.0f);
2395
const ImVec2 image_size = LayoutScale(50.0f, 50.0f);
2396
const ImGuiIO& io = ImGui::GetIO();
2397
ImVec2 position = ImVec2(io.DisplaySize.x - margin, io.DisplaySize.y - margin);
2398
ImDrawList* dl = ImGui::GetBackgroundDrawList();
2399
2400
if (!s_state.active_challenge_indicators.empty() &&
2401
(g_settings.achievements_challenge_indicator_mode == AchievementChallengeIndicatorMode::PersistentIcon ||
2402
g_settings.achievements_challenge_indicator_mode == AchievementChallengeIndicatorMode::TemporaryIcon))
2403
{
2404
const bool use_time_remaining =
2405
(g_settings.achievements_challenge_indicator_mode == AchievementChallengeIndicatorMode::TemporaryIcon);
2406
const float x_advance = image_size.x + spacing;
2407
ImVec2 current_position = ImVec2(position.x - image_size.x, position.y - image_size.y);
2408
2409
for (auto it = s_state.active_challenge_indicators.begin(); it != s_state.active_challenge_indicators.end();)
2410
{
2411
AchievementChallengeIndicator& indicator = *it;
2412
bool active = indicator.active;
2413
if (use_time_remaining)
2414
{
2415
indicator.time_remaining = std::max(indicator.time_remaining - io.DeltaTime, 0.0f);
2416
active = (indicator.time_remaining > 0.0f);
2417
}
2418
2419
const float opacity = IndicatorOpacity(io.DeltaTime, active, indicator.opacity);
2420
2421
GPUTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(indicator.badge_path);
2422
if (badge)
2423
{
2424
dl->AddImage(badge, current_position, current_position + image_size, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f),
2425
ImGui::GetColorU32(ImVec4(1.0f, 1.0f, 1.0f, opacity)));
2426
current_position.x -= x_advance;
2427
}
2428
2429
if (!indicator.active && opacity <= 0.01f)
2430
{
2431
DEV_LOG("Remove challenge indicator");
2432
it = s_state.active_challenge_indicators.erase(it);
2433
}
2434
else
2435
{
2436
++it;
2437
}
2438
}
2439
2440
position.y -= image_size.y + padding;
2441
}
2442
2443
if (s_state.active_progress_indicator.has_value())
2444
{
2445
AchievementProgressIndicator& indicator = s_state.active_progress_indicator.value();
2446
const float opacity = IndicatorOpacity(io.DeltaTime, indicator.active, indicator.opacity);
2447
2448
const std::string_view text = s_state.active_progress_indicator->achievement->measured_progress;
2449
const ImVec2 text_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight, FLT_MAX,
2450
0.0f, IMSTR_START_END(text));
2451
2452
const ImVec2 box_min = ImVec2(position.x - image_size.x - text_size.x - spacing - padding * 2.0f,
2453
position.y - image_size.y - padding * 2.0f);
2454
const ImVec2 box_max = position;
2455
2456
dl->AddRectFilled(box_min, box_max,
2457
ImGui::GetColorU32(ModAlpha(UIStyle.ToastBackgroundColor, opacity * bg_opacity)), rounding);
2458
2459
GPUTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(indicator.badge_path);
2460
if (badge)
2461
{
2462
const ImVec2 badge_pos = box_min + ImVec2(padding, padding);
2463
dl->AddImage(badge, badge_pos, badge_pos + image_size, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f),
2464
ImGui::GetColorU32(ImVec4(1.0f, 1.0f, 1.0f, opacity)));
2465
}
2466
2467
const ImVec2 text_pos =
2468
box_min + ImVec2(padding + image_size.x + spacing, (box_max.y - box_min.y - text_size.y) * 0.5f);
2469
const ImRect text_clip_rect(text_pos, box_max);
2470
RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, text_pos, box_max,
2471
ImGui::GetColorU32(ModAlpha(UIStyle.ToastTextColor, opacity)), text, &text_size,
2472
ImVec2(0.0f, 0.0f), 0.0f, &text_clip_rect);
2473
2474
if (!indicator.active && opacity <= 0.01f)
2475
{
2476
DEV_LOG("Remove progress indicator");
2477
s_state.active_progress_indicator.reset();
2478
}
2479
2480
position.y -= image_size.y + padding * 3.0f;
2481
}
2482
2483
if (!s_state.active_leaderboard_trackers.empty())
2484
{
2485
for (auto it = s_state.active_leaderboard_trackers.begin(); it != s_state.active_leaderboard_trackers.end();)
2486
{
2487
LeaderboardTrackerIndicator& indicator = *it;
2488
const float opacity = IndicatorOpacity(io.DeltaTime, indicator.active, indicator.opacity);
2489
2490
TinyString width_string;
2491
width_string.append(ICON_FA_STOPWATCH);
2492
for (u32 i = 0; i < indicator.text.length(); i++)
2493
width_string.append('0');
2494
const ImVec2 size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight, FLT_MAX, 0.0f,
2495
IMSTR_START_END(width_string));
2496
2497
const ImRect box(ImVec2(position.x - size.x - padding * 2.0f, position.y - size.y - padding * 2.0f), position);
2498
dl->AddRectFilled(box.Min, box.Max,
2499
ImGui::GetColorU32(ModAlpha(UIStyle.ToastBackgroundColor, opacity * bg_opacity)), rounding);
2500
2501
const u32 text_col = ImGui::GetColorU32(ModAlpha(UIStyle.ToastTextColor, opacity));
2502
const ImVec2 text_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight, FLT_MAX,
2503
0.0f, IMSTR_START_END(indicator.text));
2504
const ImVec2 text_pos = ImVec2(box.Max.x - padding - text_size.x, box.Min.y + padding);
2505
RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, text_pos, box.Max,
2506
text_col, indicator.text, &text_size, ImVec2(0.0f, 0.0f), 0.0f, &box);
2507
2508
const ImVec2 icon_pos = ImVec2(box.Min.x + padding, box.Min.y + padding);
2509
RenderShadowedTextClipped(dl, UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, icon_pos, box.Max,
2510
text_col, ICON_FA_STOPWATCH, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &box);
2511
2512
if (!indicator.active && opacity <= 0.01f)
2513
{
2514
DEV_LOG("Remove tracker indicator");
2515
it = s_state.active_leaderboard_trackers.erase(it);
2516
}
2517
else
2518
{
2519
++it;
2520
}
2521
2522
position.x = box.Min.x - padding;
2523
}
2524
2525
// Uncomment if there are any other overlays above this one.
2526
// position.y -= image_size.y - padding * 3.0f;
2527
}
2528
}
2529
2530
#ifndef __ANDROID__
2531
2532
void Achievements::DrawPauseMenuOverlays(float start_pos_y)
2533
{
2534
using ImGuiFullscreen::DarkerColor;
2535
using ImGuiFullscreen::LayoutScale;
2536
using ImGuiFullscreen::ModAlpha;
2537
using ImGuiFullscreen::UIStyle;
2538
2539
if (!HasActiveGame() || !HasAchievements())
2540
return;
2541
2542
const auto lock = GetLock();
2543
2544
const ImVec2& display_size = ImGui::GetIO().DisplaySize;
2545
const float box_margin = LayoutScale(20.0f);
2546
const float box_width = LayoutScale(450.0f);
2547
const float box_padding = LayoutScale(15.0f);
2548
const float box_content_width = box_width - box_padding - box_padding;
2549
const float box_rounding = LayoutScale(20.0f);
2550
const u32 box_background_color = ImGui::GetColorU32(ModAlpha(UIStyle.BackgroundColor, 0.8f));
2551
const ImU32 title_text_color = ImGui::GetColorU32(UIStyle.BackgroundTextColor) | IM_COL32_A_MASK;
2552
const ImU32 text_color = ImGui::GetColorU32(DarkerColor(UIStyle.BackgroundTextColor)) | IM_COL32_A_MASK;
2553
const float paragraph_spacing = LayoutScale(10.0f);
2554
const float text_spacing = LayoutScale(2.0f);
2555
2556
const float progress_height = LayoutScale(20.0f);
2557
const float progress_rounding = LayoutScale(5.0f);
2558
const float badge_size = LayoutScale(40.0f);
2559
const float badge_text_width = box_content_width - badge_size - (text_spacing * 3.0f);
2560
const bool disconnected = rc_client_is_disconnected(s_state.client);
2561
const int pending_count = disconnected ? rc_client_get_award_achievement_pending_count(s_state.client) : 0;
2562
2563
ImDrawList* dl = ImGui::GetBackgroundDrawList();
2564
2565
const auto get_achievement_height = [&badge_size, &badge_text_width, &text_spacing](std::string_view description,
2566
bool show_measured) {
2567
const ImVec2 description_size =
2568
description.empty() ? ImVec2(0.0f, 0.0f) :
2569
UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight, FLT_MAX,
2570
badge_text_width, IMSTR_START_END(description));
2571
const float text_height = UIStyle.MediumFontSize + text_spacing + description_size.y;
2572
return std::max(text_height, badge_size);
2573
};
2574
2575
float box_height =
2576
box_padding + box_padding + UIStyle.MediumFontSize + paragraph_spacing + progress_height + paragraph_spacing;
2577
if (pending_count > 0)
2578
{
2579
box_height += UIStyle.MediumFontSize + paragraph_spacing;
2580
}
2581
if (s_state.most_recent_unlock.has_value())
2582
{
2583
box_height += UIStyle.MediumFontSize + paragraph_spacing +
2584
get_achievement_height(s_state.most_recent_unlock->description, false) +
2585
(s_state.achievement_nearest_completion ? (paragraph_spacing + paragraph_spacing) : 0.0f);
2586
}
2587
if (s_state.achievement_nearest_completion.has_value())
2588
{
2589
box_height += UIStyle.MediumFontSize + paragraph_spacing +
2590
get_achievement_height(s_state.achievement_nearest_completion->description, true);
2591
}
2592
2593
ImVec2 box_min = ImVec2(display_size.x - box_width - box_margin, start_pos_y + box_margin);
2594
ImVec2 box_max = ImVec2(box_min.x + box_width, box_min.y + box_height);
2595
ImVec2 text_pos = ImVec2(box_min.x + box_padding, box_min.y + box_padding);
2596
ImVec2 text_size;
2597
2598
dl->AddRectFilled(box_min, box_max, box_background_color, box_rounding);
2599
2600
const auto draw_achievement_with_summary = [&box_max, &badge_text_width, &dl, &title_text_color, &text_color,
2601
&text_spacing, &text_pos,
2602
&badge_size](std::string_view title, std::string_view description,
2603
const std::string& badge_path, bool show_measured) {
2604
const ImVec2 image_max = ImVec2(text_pos.x + badge_size, text_pos.y + badge_size);
2605
ImVec2 badge_text_pos = ImVec2(image_max.x + (text_spacing * 3.0f), text_pos.y);
2606
const ImVec4 clip_rect = ImVec4(badge_text_pos.x, badge_text_pos.y, badge_text_pos.x + badge_text_width, box_max.y);
2607
const ImVec2 description_size =
2608
description.empty() ? ImVec2(0.0f, 0.0f) :
2609
UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight, FLT_MAX,
2610
badge_text_width, IMSTR_START_END(description));
2611
2612
GPUTexture* badge_tex = ImGuiFullscreen::GetCachedTextureAsync(badge_path);
2613
dl->AddImage(badge_tex, text_pos, image_max);
2614
2615
if (!title.empty())
2616
{
2617
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, badge_text_pos, title_text_color,
2618
IMSTR_START_END(title), 0.0f, &clip_rect);
2619
badge_text_pos.y += UIStyle.MediumFontSize + text_spacing;
2620
}
2621
2622
if (!description.empty())
2623
{
2624
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, badge_text_pos, text_color,
2625
IMSTR_START_END(description), badge_text_width, &clip_rect);
2626
badge_text_pos.y += description_size.y;
2627
}
2628
2629
text_pos.y = badge_text_pos.y;
2630
};
2631
2632
TinyString buffer;
2633
2634
// title
2635
{
2636
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, text_pos, text_color,
2637
TRANSLATE_DISAMBIG("Achievements", "Achievements Unlocked", "Pause Menu"));
2638
const float unlocked_fraction = static_cast<float>(s_state.game_summary.num_unlocked_achievements) /
2639
static_cast<float>(s_state.game_summary.num_core_achievements);
2640
buffer.format("{}%", static_cast<u32>(std::round(unlocked_fraction * 100.0f)));
2641
text_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.BoldFontWeight, FLT_MAX, 0.0f,
2642
IMSTR_START_END(buffer));
2643
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight,
2644
ImVec2(text_pos.x + (box_content_width - text_size.x), text_pos.y), text_color,
2645
IMSTR_START_END(buffer));
2646
text_pos.y += UIStyle.MediumFontSize + paragraph_spacing;
2647
2648
const ImRect progress_bb(text_pos, text_pos + ImVec2(box_content_width, progress_height));
2649
dl->AddRectFilled(progress_bb.Min, progress_bb.Max, ImGui::GetColorU32(UIStyle.PrimaryDarkColor),
2650
progress_rounding);
2651
if (s_state.game_summary.num_unlocked_achievements > 0)
2652
{
2653
ImGui::RenderRectFilledRangeH(dl, progress_bb, ImGui::GetColorU32(DarkerColor(UIStyle.SecondaryColor)), 0.0f,
2654
unlocked_fraction, progress_rounding);
2655
}
2656
2657
buffer.format("{}/{}", s_state.game_summary.num_unlocked_achievements, s_state.game_summary.num_core_achievements);
2658
text_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.BoldFontWeight, FLT_MAX, 0.0f,
2659
IMSTR_START_END(buffer));
2660
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight,
2661
ImVec2(progress_bb.Min.x + ((progress_bb.Max.x - progress_bb.Min.x) / 2.0f) - (text_size.x / 2.0f),
2662
progress_bb.Min.y + ((progress_bb.Max.y - progress_bb.Min.y) / 2.0f) - (text_size.y / 2.0f)),
2663
ImGui::GetColorU32(UIStyle.PrimaryTextColor), IMSTR_START_END(buffer));
2664
text_pos.y += progress_height + paragraph_spacing;
2665
2666
if (pending_count > 0)
2667
{
2668
buffer.format(ICON_EMOJI_WARNING " {}",
2669
TRANSLATE_PLURAL_SSTR("Achievements", "%n unlocks have not been confirmed by the server.",
2670
"Pause Menu", pending_count));
2671
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, text_pos, title_text_color,
2672
IMSTR_START_END(buffer));
2673
text_pos.y += UIStyle.MediumFontSize + paragraph_spacing;
2674
}
2675
}
2676
2677
if (s_state.most_recent_unlock.has_value())
2678
{
2679
buffer.format(ICON_FA_LOCK_OPEN " {}", TRANSLATE_DISAMBIG_SV("Achievements", "Most Recent", "Pause Menu"));
2680
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, text_pos, text_color,
2681
IMSTR_START_END(buffer));
2682
text_pos.y += UIStyle.MediumFontSize + paragraph_spacing;
2683
2684
draw_achievement_with_summary(s_state.most_recent_unlock->title, s_state.most_recent_unlock->description,
2685
s_state.most_recent_unlock->badge_path, false);
2686
2687
// extra spacing if we have two
2688
text_pos.y += s_state.achievement_nearest_completion ? (paragraph_spacing + paragraph_spacing) : 0.0f;
2689
}
2690
2691
if (s_state.achievement_nearest_completion.has_value())
2692
{
2693
buffer.format(ICON_FA_LOCK " {}", TRANSLATE_DISAMBIG_SV("Achievements", "Nearest Completion", "Pause Menu"));
2694
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, text_pos, text_color,
2695
IMSTR_START_END(buffer));
2696
text_pos.y += UIStyle.MediumFontSize + paragraph_spacing;
2697
2698
draw_achievement_with_summary(s_state.achievement_nearest_completion->title,
2699
s_state.achievement_nearest_completion->description,
2700
s_state.achievement_nearest_completion->badge_path, true);
2701
text_pos.y += paragraph_spacing;
2702
}
2703
2704
// Challenge indicators
2705
2706
if (!s_state.active_challenge_indicators.empty())
2707
{
2708
box_height = box_padding + box_padding + UIStyle.MediumFontSize;
2709
for (size_t i = 0; i < s_state.active_challenge_indicators.size(); i++)
2710
{
2711
const AchievementChallengeIndicator& indicator = s_state.active_challenge_indicators[i];
2712
box_height += paragraph_spacing + get_achievement_height(indicator.achievement->description, false) +
2713
((i == s_state.active_challenge_indicators.size() - 1) ? paragraph_spacing : 0.0f);
2714
}
2715
2716
box_min = ImVec2(box_min.x, box_max.y + box_margin);
2717
box_max = ImVec2(box_min.x + box_width, box_min.y + box_height);
2718
text_pos = ImVec2(box_min.x + box_padding, box_min.y + box_padding);
2719
2720
dl->AddRectFilled(box_min, box_max, box_background_color, box_rounding);
2721
2722
buffer.format(ICON_FA_STOPWATCH " {}",
2723
TRANSLATE_DISAMBIG_SV("Achievements", "Active Challenge Achievements", "Pause Menu"));
2724
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, text_pos, text_color,
2725
IMSTR_START_END(buffer));
2726
text_pos.y += UIStyle.MediumFontSize;
2727
2728
for (const AchievementChallengeIndicator& indicator : s_state.active_challenge_indicators)
2729
{
2730
text_pos.y += paragraph_spacing;
2731
draw_achievement_with_summary(indicator.achievement->title, indicator.achievement->description,
2732
indicator.badge_path, false);
2733
text_pos.y += paragraph_spacing;
2734
}
2735
}
2736
}
2737
2738
bool Achievements::PrepareAchievementsWindow()
2739
{
2740
auto lock = Achievements::GetLock();
2741
2742
s_state.achievement_badge_paths = {};
2743
2744
if (s_state.achievement_list)
2745
rc_client_destroy_achievement_list(s_state.achievement_list);
2746
s_state.achievement_list = rc_client_create_achievement_list(
2747
s_state.client, RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL,
2748
RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_PROGRESS /*RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE*/);
2749
if (!s_state.achievement_list)
2750
{
2751
ERROR_LOG("rc_client_create_achievement_list() returned null");
2752
return false;
2753
}
2754
2755
// sort unlocked achievements by unlock time
2756
for (size_t i = 0; i < s_state.achievement_list->num_buckets; i++)
2757
{
2758
const rc_client_achievement_bucket_t* bucket = &s_state.achievement_list->buckets[i];
2759
if (bucket->bucket_type == RC_CLIENT_ACHIEVEMENT_BUCKET_UNLOCKED)
2760
{
2761
std::sort(bucket->achievements, bucket->achievements + bucket->num_achievements,
2762
[](const rc_client_achievement_t* a, const rc_client_achievement_t* b) {
2763
return a->unlock_time > b->unlock_time;
2764
});
2765
}
2766
}
2767
2768
return true;
2769
}
2770
2771
void Achievements::DrawAchievementsWindow()
2772
{
2773
using ImGuiFullscreen::LayoutScale;
2774
using ImGuiFullscreen::RenderShadowedTextClipped;
2775
using ImGuiFullscreen::UIStyle;
2776
2777
const auto lock = Achievements::GetLock();
2778
2779
// achievements can get turned off via the main UI
2780
if (!s_state.achievement_list)
2781
{
2782
FullscreenUI::ReturnToPreviousWindow();
2783
return;
2784
}
2785
2786
static constexpr float alpha = 0.8f;
2787
static constexpr float heading_alpha = 0.95f;
2788
const float heading_height_unscaled =
2789
((s_state.game_summary.beaten_time > 0 || s_state.game_summary.completed_time) ? 122.0f : 102.0f) +
2790
((s_state.game_summary.num_unsupported_achievements > 0) ? 20.0f : 0.0f);
2791
2792
const ImVec4 background = ImGuiFullscreen::ModAlpha(UIStyle.BackgroundColor, alpha);
2793
const ImVec4 heading_background = ImGuiFullscreen::ModAlpha(UIStyle.BackgroundColor, heading_alpha);
2794
const ImVec2 display_size = ImGui::GetIO().DisplaySize;
2795
const float heading_height = LayoutScale(heading_height_unscaled);
2796
bool close_window = false;
2797
2798
if (ImGuiFullscreen::BeginFullscreenWindow(ImVec2(), ImVec2(display_size.x, heading_height), "achievements_heading",
2799
heading_background, 0.0f, ImVec2(10.0f, 10.0f),
2800
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDecoration |
2801
ImGuiWindowFlags_NoScrollWithMouse))
2802
{
2803
const ImVec2 pos = ImGui::GetCursorScreenPos() + ImGui::GetStyle().FramePadding;
2804
const float spacing = ImGuiFullscreen::LayoutScale(ImGuiFullscreen::LAYOUT_MENU_ITEM_TITLE_SUMMARY_SPACING);
2805
const float image_size = LayoutScale(75.0f);
2806
2807
if (!s_state.game_icon.empty())
2808
{
2809
GPUTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(s_state.game_icon);
2810
if (badge)
2811
{
2812
ImGui::GetWindowDrawList()->AddImage(badge, pos, pos + ImVec2(image_size, image_size), ImVec2(0.0f, 0.0f),
2813
ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255));
2814
}
2815
}
2816
2817
float left = pos.x + image_size + LayoutScale(10.0f);
2818
float right = pos.x + ImGuiFullscreen::GetMenuButtonAvailableWidth();
2819
float top = pos.y;
2820
ImDrawList* dl = ImGui::GetWindowDrawList();
2821
SmallString text;
2822
ImVec2 text_size;
2823
2824
close_window = (ImGuiFullscreen::FloatingButton(ICON_FA_SQUARE_XMARK, 10.0f, 10.0f, 1.0f, 0.0f, true) ||
2825
ImGuiFullscreen::WantsToCloseMenu());
2826
2827
const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.LargeFontSize));
2828
text.assign(s_state.game_title);
2829
2830
if (rc_client_get_hardcore_enabled(s_state.client))
2831
text.append(TRANSLATE_SV("Achievements", " (Hardcore Mode)"));
2832
2833
top += UIStyle.LargeFontSize + spacing;
2834
2835
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, title_bb.Min, title_bb.Max,
2836
ImGui::GetColorU32(ImGuiCol_Text), text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &title_bb);
2837
2838
const ImRect summary_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.MediumFontSize));
2839
if (s_state.game_summary.num_core_achievements > 0)
2840
{
2841
text.assign(ICON_EMOJI_UNLOCKED " ");
2842
if (s_state.game_summary.num_unlocked_achievements == s_state.game_summary.num_core_achievements)
2843
{
2844
text.append(TRANSLATE_PLURAL_SSTR("Achievements", "You have unlocked all achievements and earned %n points!",
2845
"Point count", s_state.game_summary.points_unlocked));
2846
}
2847
else
2848
{
2849
text.append_format(TRANSLATE_FS("Achievements",
2850
"You have unlocked {0} of {1} achievements, earning {2} of {3} possible points."),
2851
s_state.game_summary.num_unlocked_achievements, s_state.game_summary.num_core_achievements,
2852
s_state.game_summary.points_unlocked, s_state.game_summary.points_core);
2853
}
2854
}
2855
else
2856
{
2857
text.format(ICON_FA_BAN " {}", TRANSLATE_SV("Achievements", "This game has no achievements."));
2858
}
2859
2860
top += UIStyle.MediumFontSize + spacing;
2861
2862
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, summary_bb.Min,
2863
summary_bb.Max,
2864
ImGui::GetColorU32(ImGuiFullscreen::DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text])),
2865
text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &summary_bb);
2866
2867
if (s_state.game_summary.num_unsupported_achievements)
2868
{
2869
text.format("{} {}", ICON_EMOJI_WARNING,
2870
TRANSLATE_PLURAL_SSTR(
2871
"Achievements", "%n achievements are not supported by DuckStation and cannot be unlocked.",
2872
"Unsupported achievement count", s_state.game_summary.num_unsupported_achievements));
2873
2874
const ImRect unsupported_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.MediumFontSize));
2875
RenderShadowedTextClipped(
2876
UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, unsupported_bb.Min, unsupported_bb.Max,
2877
ImGui::GetColorU32(ImGuiFullscreen::DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text])), text, nullptr,
2878
ImVec2(0.0f, 0.0f), 0.0f, &unsupported_bb);
2879
2880
top += UIStyle.MediumFontSize + spacing;
2881
}
2882
2883
if (s_state.game_summary.beaten_time > 0 || s_state.game_summary.completed_time > 0)
2884
{
2885
text.assign(ICON_EMOJI_CHECKMARK_BUTTON " ");
2886
2887
if (s_state.game_summary.beaten_time > 0)
2888
{
2889
const std::string beaten_time =
2890
Host::FormatNumber(Host::NumberFormatType::ShortDate, static_cast<s64>(s_state.game_summary.beaten_time));
2891
if (s_state.game_summary.completed_time > 0)
2892
{
2893
const std::string completion_time =
2894
Host::FormatNumber(Host::NumberFormatType::ShortDate, static_cast<s64>(s_state.game_summary.beaten_time));
2895
text.append_format(TRANSLATE_FS("Achievements", "Game was beaten on {0}, and completed on {1}."), beaten_time,
2896
completion_time);
2897
}
2898
else
2899
{
2900
text.append_format(TRANSLATE_FS("Achievements", "Game was beaten on {0}."), beaten_time);
2901
}
2902
}
2903
else
2904
{
2905
const std::string completion_time =
2906
Host::FormatNumber(Host::NumberFormatType::ShortDate, static_cast<s64>(s_state.game_summary.completed_time));
2907
text.append_format(TRANSLATE_FS("Achievements", "Game was completed on {0}."), completion_time);
2908
}
2909
2910
const ImRect beaten_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.MediumFontSize));
2911
RenderShadowedTextClipped(
2912
UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, beaten_bb.Min, beaten_bb.Max,
2913
ImGui::GetColorU32(ImGuiFullscreen::DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text])), text, nullptr,
2914
ImVec2(0.0f, 0.0f), 0.0f, &beaten_bb);
2915
2916
top += UIStyle.MediumFontSize + spacing;
2917
}
2918
2919
if (s_state.game_summary.num_core_achievements > 0)
2920
{
2921
const float progress_height = LayoutScale(20.0f);
2922
const float progress_rounding = LayoutScale(5.0f);
2923
const ImRect progress_bb(ImVec2(left, top), ImVec2(right, top + progress_height));
2924
const float fraction = static_cast<float>(s_state.game_summary.num_unlocked_achievements) /
2925
static_cast<float>(s_state.game_summary.num_core_achievements);
2926
dl->AddRectFilled(progress_bb.Min, progress_bb.Max, ImGui::GetColorU32(UIStyle.PrimaryDarkColor),
2927
progress_rounding);
2928
if (s_state.game_summary.num_unlocked_achievements > 0)
2929
{
2930
ImGui::RenderRectFilledRangeH(dl, progress_bb, ImGui::GetColorU32(UIStyle.SecondaryColor), 0.0f, fraction,
2931
progress_rounding);
2932
}
2933
2934
text.format("{}%", static_cast<u32>(std::round(fraction * 100.0f)));
2935
text_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.BoldFontWeight, FLT_MAX, 0.0f,
2936
IMSTR_START_END(text));
2937
const ImVec2 text_pos(progress_bb.Min.x + ((progress_bb.Max.x - progress_bb.Min.x) / 2.0f) - (text_size.x / 2.0f),
2938
progress_bb.Min.y + ((progress_bb.Max.y - progress_bb.Min.y) / 2.0f) -
2939
(text_size.y / 2.0f));
2940
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, text_pos,
2941
ImGui::GetColorU32(UIStyle.PrimaryTextColor), IMSTR_START_END(text));
2942
// top += progress_height + spacing;
2943
}
2944
}
2945
ImGuiFullscreen::EndFullscreenWindow();
2946
2947
// See note in FullscreenUI::DrawSettingsWindow().
2948
if (ImGuiFullscreen::IsFocusResetFromWindowChange())
2949
ImGui::SetNextWindowScroll(ImVec2(0.0f, 0.0f));
2950
2951
if (ImGuiFullscreen::BeginFullscreenWindow(
2952
ImVec2(0.0f, heading_height),
2953
ImVec2(display_size.x, display_size.y - heading_height - LayoutScale(ImGuiFullscreen::LAYOUT_FOOTER_HEIGHT)),
2954
"achievements", background, 0.0f,
2955
ImVec2(ImGuiFullscreen::LAYOUT_MENU_WINDOW_X_PADDING, ImGuiFullscreen::LAYOUT_MENU_WINDOW_Y_PADDING), 0))
2956
{
2957
static bool buckets_collapsed[NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS] = {};
2958
static constexpr std::pair<const char*, const char*> bucket_names[NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS] = {
2959
{ICON_FA_TRIANGLE_EXCLAMATION, TRANSLATE_NOOP("Achievements", "Unknown")},
2960
{ICON_FA_LOCK, TRANSLATE_NOOP("Achievements", "Locked")},
2961
{ICON_FA_UNLOCK, TRANSLATE_NOOP("Achievements", "Unlocked")},
2962
{ICON_FA_TRIANGLE_EXCLAMATION, TRANSLATE_NOOP("Achievements", "Unsupported")},
2963
{ICON_FA_CIRCLE_QUESTION, TRANSLATE_NOOP("Achievements", "Unofficial")},
2964
{ICON_EMOJI_UNLOCKED, TRANSLATE_NOOP("Achievements", "Recently Unlocked")},
2965
{ICON_FA_STOPWATCH, TRANSLATE_NOOP("Achievements", "Active Challenges")},
2966
{ICON_FA_RULER_HORIZONTAL, TRANSLATE_NOOP("Achievements", "Almost There")},
2967
{ICON_FA_TRIANGLE_EXCLAMATION, TRANSLATE_NOOP("Achievements", "Unsynchronized")},
2968
};
2969
2970
ImGuiFullscreen::ResetFocusHere();
2971
ImGuiFullscreen::BeginMenuButtons();
2972
2973
for (u32 bucket_type : {RC_CLIENT_ACHIEVEMENT_BUCKET_ACTIVE_CHALLENGE,
2974
RC_CLIENT_ACHIEVEMENT_BUCKET_RECENTLY_UNLOCKED, RC_CLIENT_ACHIEVEMENT_BUCKET_ALMOST_THERE,
2975
RC_CLIENT_ACHIEVEMENT_BUCKET_UNLOCKED, RC_CLIENT_ACHIEVEMENT_BUCKET_LOCKED,
2976
RC_CLIENT_ACHIEVEMENT_BUCKET_UNOFFICIAL, RC_CLIENT_ACHIEVEMENT_BUCKET_UNSUPPORTED})
2977
{
2978
for (u32 bucket_idx = 0; bucket_idx < s_state.achievement_list->num_buckets; bucket_idx++)
2979
{
2980
const rc_client_achievement_bucket_t& bucket = s_state.achievement_list->buckets[bucket_idx];
2981
if (bucket.bucket_type != bucket_type)
2982
continue;
2983
2984
DebugAssert(bucket.bucket_type < NUM_RC_CLIENT_ACHIEVEMENT_BUCKETS);
2985
2986
// TODO: Once subsets are supported, this will need to change.
2987
bool& bucket_collapsed = buckets_collapsed[bucket.bucket_type];
2988
bucket_collapsed ^= ImGuiFullscreen::MenuHeadingButton(
2989
TinyString::from_format("{} {}", bucket_names[bucket.bucket_type].first,
2990
Host::TranslateToStringView("Achievements", bucket_names[bucket.bucket_type].second)),
2991
bucket_collapsed ? ICON_FA_CHEVRON_DOWN : ICON_FA_CHEVRON_UP, UIStyle.MediumLargeFontSize);
2992
if (!bucket_collapsed)
2993
{
2994
for (u32 i = 0; i < bucket.num_achievements; i++)
2995
DrawAchievement(bucket.achievements[i]);
2996
}
2997
}
2998
}
2999
3000
ImGuiFullscreen::EndMenuButtons();
3001
}
3002
ImGuiFullscreen::EndFullscreenWindow();
3003
3004
ImGuiFullscreen::SetFullscreenStatusText(std::array{
3005
std::make_pair(ICON_PF_ACHIEVEMENTS_MISSABLE, TRANSLATE_SV("Achievements", "Missable")),
3006
std::make_pair(ICON_PF_ACHIEVEMENTS_PROGRESSION, TRANSLATE_SV("Achievements", "Progression")),
3007
std::make_pair(ICON_PF_ACHIEVEMENTS_WIN, TRANSLATE_SV("Achievements", "Win Condition")),
3008
std::make_pair(ICON_FA_LOCK, TRANSLATE_SV("Achievements", "Locked")),
3009
std::make_pair(ICON_EMOJI_UNLOCKED, TRANSLATE_SV("Achievements", "Unlocked")),
3010
});
3011
ImGuiFullscreen::SetFullscreenFooterText(
3012
std::array{std::make_pair(ImGuiFullscreen::IsGamepadInputSource() ? ICON_PF_XBOX_DPAD_UP_DOWN :
3013
ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN,
3014
TRANSLATE_SV("Achievements", "Change Selection")),
3015
std::make_pair(ImGuiFullscreen::IsGamepadInputSource() ? ICON_PF_BUTTON_A : ICON_PF_ENTER,
3016
TRANSLATE_SV("Achievements", "View Details")),
3017
std::make_pair(ImGuiFullscreen::IsGamepadInputSource() ? ICON_PF_BUTTON_B : ICON_PF_ESC,
3018
TRANSLATE_SV("Achievements", "Back"))},
3019
FullscreenUI::GetBackgroundAlpha());
3020
3021
if (close_window)
3022
FullscreenUI::ReturnToPreviousWindow();
3023
}
3024
3025
void Achievements::DrawAchievement(const rc_client_achievement_t* cheevo)
3026
{
3027
using ImGuiFullscreen::DarkerColor;
3028
using ImGuiFullscreen::LayoutScale;
3029
using ImGuiFullscreen::LayoutUnscale;
3030
using ImGuiFullscreen::RenderShadowedTextClipped;
3031
using ImGuiFullscreen::UIStyle;
3032
3033
static constexpr float progress_height_unscaled = 20.0f;
3034
static constexpr float progress_spacing_unscaled = 5.0f;
3035
static constexpr float progress_rounding_unscaled = 5.0f;
3036
3037
const float spacing = ImGuiFullscreen::LayoutScale(ImGuiFullscreen::LAYOUT_MENU_ITEM_TITLE_SUMMARY_SPACING);
3038
const u32 text_color = ImGui::GetColorU32(UIStyle.SecondaryTextColor);
3039
const u32 summary_color = ImGui::GetColorU32(DarkerColor(UIStyle.SecondaryTextColor));
3040
const u32 rarity_color = ImGui::GetColorU32(DarkerColor(DarkerColor(UIStyle.SecondaryTextColor)));
3041
3042
const ImVec2 image_size = LayoutScale(50.0f, 50.0f);
3043
const bool is_unlocked = (cheevo->state == RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED);
3044
const std::string_view measured_progress(cheevo->measured_progress);
3045
const bool is_measured = !is_unlocked && !measured_progress.empty();
3046
const float unlock_rarity_height = spacing + UIStyle.MediumFontSize;
3047
const ImVec2 points_template_size = UIStyle.Font->CalcTextSizeA(
3048
UIStyle.MediumFontSize, UIStyle.NormalFontWeight, FLT_MAX, 0.0f, TRANSLATE("Achievements", "XXX points"));
3049
const float avail_width = ImGuiFullscreen::GetMenuButtonAvailableWidth();
3050
const size_t summary_length = std::strlen(cheevo->description);
3051
const float summary_wrap_width = (avail_width - (image_size.x + spacing + spacing) - points_template_size.x);
3052
const ImVec2 summary_text_size =
3053
UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight, FLT_MAX, summary_wrap_width,
3054
cheevo->description, cheevo->description + summary_length);
3055
3056
const float content_height = UIStyle.LargeFontSize + spacing + summary_text_size.y + unlock_rarity_height +
3057
LayoutScale(is_measured ? progress_height_unscaled : 0.0f) +
3058
LayoutScale(ImGuiFullscreen::LAYOUT_MENU_ITEM_EXTRA_HEIGHT);
3059
ImRect bb;
3060
bool visible, hovered;
3061
const bool clicked = ImGuiFullscreen::MenuButtonFrame(TinyString::from_format("chv_{}", cheevo->id), content_height,
3062
true, &bb, &visible, &hovered);
3063
if (!visible)
3064
return;
3065
3066
const std::string& badge_path =
3067
GetCachedAchievementBadgePath(cheevo, cheevo->state != RC_CLIENT_ACHIEVEMENT_STATE_UNLOCKED);
3068
3069
if (!badge_path.empty())
3070
{
3071
GPUTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(badge_path);
3072
if (badge)
3073
{
3074
const ImRect image_bb = ImGuiFullscreen::CenterImage(ImRect(bb.Min, bb.Min + image_size), badge);
3075
ImGui::GetWindowDrawList()->AddImage(badge, image_bb.Min, image_bb.Max, ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f),
3076
IM_COL32(255, 255, 255, 255));
3077
}
3078
}
3079
3080
SmallString text;
3081
3082
const float midpoint = bb.Min.y + UIStyle.LargeFontSize + spacing;
3083
text = TRANSLATE_PLURAL_SSTR("Achievements", "%n points", "Achievement points", cheevo->points);
3084
const ImVec2 points_size =
3085
UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight, FLT_MAX, 0.0f, IMSTR_START_END(text));
3086
const float points_template_start = bb.Max.x - points_template_size.x;
3087
const float points_start = points_template_start + ((points_template_size.x - points_size.x) * 0.5f);
3088
3089
std::string_view right_icon_text;
3090
switch (cheevo->type)
3091
{
3092
case RC_CLIENT_ACHIEVEMENT_TYPE_MISSABLE:
3093
right_icon_text = ICON_PF_ACHIEVEMENTS_MISSABLE; // Missable
3094
break;
3095
3096
case RC_CLIENT_ACHIEVEMENT_TYPE_PROGRESSION:
3097
right_icon_text = ICON_PF_ACHIEVEMENTS_PROGRESSION; // Progression
3098
break;
3099
3100
case RC_CLIENT_ACHIEVEMENT_TYPE_WIN:
3101
right_icon_text = ICON_PF_ACHIEVEMENTS_WIN; // Win Condition
3102
break;
3103
3104
// Just use the lock for standard achievements.
3105
case RC_CLIENT_ACHIEVEMENT_TYPE_STANDARD:
3106
default:
3107
right_icon_text = is_unlocked ? ICON_EMOJI_UNLOCKED : ICON_FA_LOCK;
3108
break;
3109
}
3110
3111
const ImVec2 right_icon_size = UIStyle.Font->CalcTextSizeA(UIStyle.LargeFontSize, UIStyle.BoldFontWeight, FLT_MAX,
3112
0.0f, IMSTR_START_END(right_icon_text));
3113
3114
const float text_start_x = bb.Min.x + image_size.x + LayoutScale(15.0f);
3115
const ImRect title_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(points_start, midpoint));
3116
const ImRect summary_bb(ImVec2(text_start_x, midpoint), ImVec2(points_start, midpoint + summary_text_size.y));
3117
const ImRect unlock_rarity_bb(summary_bb.Min.x, summary_bb.Max.y + spacing, summary_bb.Max.x,
3118
summary_bb.Max.y + unlock_rarity_height);
3119
const ImRect points_bb(ImVec2(points_start, midpoint), bb.Max);
3120
const ImRect lock_bb(ImVec2(points_template_start + ((points_template_size.x - right_icon_size.x) * 0.5f), bb.Min.y),
3121
ImVec2(bb.Max.x, midpoint));
3122
3123
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, title_bb.Min, title_bb.Max,
3124
text_color, cheevo->title, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &title_bb);
3125
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, lock_bb.Min, lock_bb.Max,
3126
text_color, right_icon_text, &right_icon_size, ImVec2(0.0f, 0.0f), 0.0f, &lock_bb);
3127
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, points_bb.Min,
3128
points_bb.Max, summary_color, text, &points_size, ImVec2(0.0f, 0.0f), 0.0f, &points_bb);
3129
3130
if (cheevo->description && summary_length > 0)
3131
{
3132
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, summary_bb.Min,
3133
summary_bb.Max, summary_color, std::string_view(cheevo->description, summary_length),
3134
&summary_text_size, ImVec2(0.0f, 0.0f), summary_wrap_width, &summary_bb);
3135
}
3136
3137
// display hc if hc is active
3138
const float rarity_to_display = IsHardcoreModeActive() ? cheevo->rarity_hardcore : cheevo->rarity;
3139
3140
if (is_unlocked)
3141
{
3142
const std::string date =
3143
Host::FormatNumber(Host::NumberFormatType::LongDateTime, static_cast<s64>(cheevo->unlock_time));
3144
text.format(TRANSLATE_FS("Achievements", "Unlocked: {} | {:.1f}% of players have this achievement"), date,
3145
rarity_to_display);
3146
3147
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, unlock_rarity_bb.Min,
3148
unlock_rarity_bb.Max, rarity_color, text, nullptr, ImVec2(0.0f, 0.0f), 0.0f,
3149
&unlock_rarity_bb);
3150
}
3151
else
3152
{
3153
text.format(TRANSLATE_FS("Achievements", "{:.1f}% of players have this achievement"), rarity_to_display);
3154
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, unlock_rarity_bb.Min,
3155
unlock_rarity_bb.Max, rarity_color, text, nullptr, ImVec2(0.0f, 0.0f), 0.0f,
3156
&unlock_rarity_bb);
3157
}
3158
3159
if (!is_unlocked && is_measured)
3160
{
3161
ImDrawList* dl = ImGui::GetWindowDrawList();
3162
const float progress_height = LayoutScale(progress_height_unscaled);
3163
const float progress_spacing = LayoutScale(progress_spacing_unscaled);
3164
const float progress_rounding = LayoutScale(progress_rounding_unscaled);
3165
const ImRect progress_bb(summary_bb.Min.x, unlock_rarity_bb.Max.y + progress_spacing,
3166
summary_bb.Max.x - progress_spacing,
3167
unlock_rarity_bb.Max.y + progress_spacing + progress_height);
3168
const float fraction = cheevo->measured_percent * 0.01f;
3169
dl->AddRectFilled(progress_bb.Min, progress_bb.Max, ImGui::GetColorU32(ImGuiFullscreen::UIStyle.PrimaryDarkColor),
3170
progress_rounding);
3171
ImGui::RenderRectFilledRangeH(dl, progress_bb, ImGui::GetColorU32(ImGuiFullscreen::UIStyle.SecondaryColor), 0.0f,
3172
fraction, progress_rounding);
3173
3174
const ImVec2 text_size = UIStyle.Font->CalcTextSizeA(UIStyle.MediumFontSize, UIStyle.NormalFontWeight, FLT_MAX,
3175
0.0f, IMSTR_START_END(measured_progress));
3176
const ImVec2 text_pos(progress_bb.Min.x + ((progress_bb.Max.x - progress_bb.Min.x) / 2.0f) - (text_size.x / 2.0f),
3177
progress_bb.Min.y + ((progress_bb.Max.y - progress_bb.Min.y) / 2.0f) - (text_size.y / 2.0f));
3178
dl->AddText(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.NormalFontWeight, text_pos,
3179
ImGui::GetColorU32(ImGuiFullscreen::UIStyle.PrimaryTextColor), IMSTR_START_END(measured_progress));
3180
}
3181
3182
if (clicked)
3183
{
3184
const SmallString url = SmallString::from_format(fmt::runtime(ACHEIVEMENT_DETAILS_URL_TEMPLATE), cheevo->id);
3185
INFO_LOG("Opening achievement details: {}", url);
3186
Host::OpenURL(url);
3187
}
3188
}
3189
3190
bool Achievements::PrepareLeaderboardsWindow()
3191
{
3192
auto lock = Achievements::GetLock();
3193
rc_client_t* const client = s_state.client;
3194
3195
s_state.achievement_badge_paths = {};
3196
CloseLeaderboard();
3197
if (s_state.leaderboard_list)
3198
rc_client_destroy_leaderboard_list(s_state.leaderboard_list);
3199
s_state.leaderboard_list = rc_client_create_leaderboard_list(client, RC_CLIENT_LEADERBOARD_LIST_GROUPING_NONE);
3200
if (!s_state.leaderboard_list)
3201
{
3202
ERROR_LOG("rc_client_create_leaderboard_list() returned null");
3203
return false;
3204
}
3205
3206
return true;
3207
}
3208
3209
void Achievements::DrawLeaderboardsWindow()
3210
{
3211
using ImGuiFullscreen::DarkerColor;
3212
using ImGuiFullscreen::LayoutScale;
3213
using ImGuiFullscreen::RenderShadowedTextClipped;
3214
using ImGuiFullscreen::UIStyle;
3215
3216
static constexpr float alpha = 0.8f;
3217
static constexpr float heading_alpha = 0.95f;
3218
static constexpr float heading_height_unscaled = 110.0f;
3219
static constexpr float tab_height_unscaled = 50.0f;
3220
3221
const auto lock = Achievements::GetLock();
3222
if (!s_state.leaderboard_list)
3223
{
3224
FullscreenUI::ReturnToPreviousWindow();
3225
return;
3226
}
3227
3228
const bool is_leaderboard_open = (s_state.open_leaderboard != nullptr);
3229
bool close_leaderboard_on_exit = false;
3230
3231
SmallString text;
3232
3233
const ImVec4 background = ImGuiFullscreen::ModAlpha(ImGuiFullscreen::UIStyle.BackgroundColor, alpha);
3234
const ImVec4 heading_background = ImGuiFullscreen::ModAlpha(ImGuiFullscreen::UIStyle.BackgroundColor, heading_alpha);
3235
const ImVec2 display_size = ImGui::GetIO().DisplaySize;
3236
const u32 text_color = ImGui::GetColorU32(ImGuiCol_Text);
3237
const float spacing = LayoutScale(10.0f);
3238
const float spacing_small = ImFloor(spacing * 0.5f);
3239
float heading_height = LayoutScale(heading_height_unscaled);
3240
if (is_leaderboard_open)
3241
{
3242
// tabs
3243
heading_height += spacing * 2.0f + LayoutScale(tab_height_unscaled) + spacing * 2.0f;
3244
3245
// Add space for a legend - spacing + 1 line of text + spacing + line
3246
heading_height += UIStyle.LargeFontSize;
3247
}
3248
3249
const float rank_column_width =
3250
UIStyle.Font
3251
->CalcTextSizeA(UIStyle.LargeFontSize, UIStyle.BoldFontWeight, std::numeric_limits<float>::max(), -1.0f, "99999")
3252
.x;
3253
const float name_column_width = UIStyle.Font
3254
->CalcTextSizeA(UIStyle.LargeFontSize, UIStyle.BoldFontWeight,
3255
std::numeric_limits<float>::max(), -1.0f, "WWWWWWWWWWWWWWWWWWWWWW")
3256
.x;
3257
const float time_column_width = UIStyle.Font
3258
->CalcTextSizeA(UIStyle.LargeFontSize, UIStyle.BoldFontWeight,
3259
std::numeric_limits<float>::max(), -1.0f, "WWWWWWWWWWW")
3260
.x;
3261
const float column_spacing = spacing * 2.0f;
3262
3263
if (ImGuiFullscreen::BeginFullscreenWindow(ImVec2(), ImVec2(display_size.x, heading_height), "leaderboards_heading",
3264
heading_background, 0.0f, ImVec2(10.0f, 10.0f),
3265
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoDecoration |
3266
ImGuiWindowFlags_NoScrollWithMouse))
3267
{
3268
const ImVec2 heading_pos = ImGui::GetCursorScreenPos() + ImGui::GetStyle().FramePadding;
3269
const float image_size = LayoutScale(85.0f);
3270
3271
if (!s_state.game_icon.empty())
3272
{
3273
GPUTexture* badge = ImGuiFullscreen::GetCachedTextureAsync(s_state.game_icon);
3274
if (badge)
3275
{
3276
ImGui::GetWindowDrawList()->AddImage(badge, heading_pos, heading_pos + ImVec2(image_size, image_size),
3277
ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255));
3278
}
3279
}
3280
3281
float left = heading_pos.x + image_size + spacing;
3282
float right = heading_pos.x + ImGuiFullscreen::GetMenuButtonAvailableWidth();
3283
float top = heading_pos.y;
3284
3285
if (!is_leaderboard_open)
3286
{
3287
if (ImGuiFullscreen::FloatingButton(ICON_FA_SQUARE_XMARK, 10.0f, 10.0f, 1.0f, 0.0f, true) ||
3288
ImGuiFullscreen::WantsToCloseMenu())
3289
{
3290
FullscreenUI::ReturnToPreviousWindow();
3291
}
3292
}
3293
else
3294
{
3295
if (ImGuiFullscreen::FloatingButton(ICON_FA_SQUARE_CARET_LEFT, 10.0f, 10.0f, 1.0f, 0.0f, true) ||
3296
ImGuiFullscreen::WantsToCloseMenu())
3297
{
3298
close_leaderboard_on_exit = true;
3299
}
3300
}
3301
3302
const ImRect title_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.LargeFontSize));
3303
text.assign(Achievements::GetGameTitle());
3304
3305
top += UIStyle.LargeFontSize + spacing_small;
3306
3307
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, title_bb.Min, title_bb.Max,
3308
text_color, text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &title_bb);
3309
3310
u32 summary_color;
3311
if (is_leaderboard_open)
3312
{
3313
const ImRect subtitle_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.LargeFontSize));
3314
text.assign(s_state.open_leaderboard->title);
3315
3316
top += UIStyle.LargeFontSize + spacing_small;
3317
3318
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, subtitle_bb.Min,
3319
subtitle_bb.Max,
3320
ImGui::GetColorU32(DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text])), text, nullptr,
3321
ImVec2(0.0f, 0.0f), 0.0f, &subtitle_bb);
3322
3323
text.assign(s_state.open_leaderboard->description);
3324
summary_color = ImGui::GetColorU32(DarkerColor(DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text])));
3325
}
3326
else
3327
{
3328
u32 count = 0;
3329
for (u32 i = 0; i < s_state.leaderboard_list->num_buckets; i++)
3330
count += s_state.leaderboard_list->buckets[i].num_leaderboards;
3331
text = TRANSLATE_PLURAL_SSTR("Achievements", "This game has %n leaderboards.", "Leaderboard count", count);
3332
summary_color = ImGui::GetColorU32(DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text]));
3333
}
3334
3335
const ImRect summary_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.MediumFontSize));
3336
top += UIStyle.MediumFontSize + spacing_small;
3337
3338
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, summary_bb.Min,
3339
summary_bb.Max, summary_color, text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &summary_bb);
3340
3341
if (!is_leaderboard_open && !Achievements::IsHardcoreModeActive())
3342
{
3343
const ImRect hardcore_warning_bb(ImVec2(left, top), ImVec2(right, top + UIStyle.MediumFontSize));
3344
top += UIStyle.MediumFontSize + spacing_small;
3345
3346
text.format(
3347
ICON_EMOJI_WARNING " {}",
3348
TRANSLATE_SV("Achievements",
3349
"Submitting scores is disabled because hardcore mode is off. Leaderboards are read-only."));
3350
3351
RenderShadowedTextClipped(UIStyle.Font, UIStyle.MediumFontSize, UIStyle.BoldFontWeight, hardcore_warning_bb.Min,
3352
hardcore_warning_bb.Max,
3353
ImGui::GetColorU32(DarkerColor(DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text]))),
3354
text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &hardcore_warning_bb);
3355
}
3356
3357
if (is_leaderboard_open)
3358
{
3359
const float avail_width = ImGuiFullscreen::GetMenuButtonAvailableWidth();
3360
const float tab_width = avail_width * 0.2f;
3361
const float tab_spacing = LayoutScale(20.0f);
3362
const float tab_left_padding = (avail_width - ((tab_width * 2.0f) + tab_spacing)) * 0.5f;
3363
ImGui::SetCursorScreenPos(ImVec2(heading_pos.x + tab_left_padding, top + spacing * 2.0f));
3364
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, LayoutScale(ImGuiFullscreen::LAYOUT_MENU_WINDOW_X_PADDING,
3365
ImGuiFullscreen::LAYOUT_MENU_WINDOW_Y_PADDING));
3366
3367
if (ImGui::IsKeyPressed(ImGuiKey_GamepadDpadLeft, false) ||
3368
ImGui::IsKeyPressed(ImGuiKey_NavGamepadTweakSlow, false) || ImGui::IsKeyPressed(ImGuiKey_LeftArrow, false) ||
3369
ImGui::IsKeyPressed(ImGuiKey_GamepadDpadRight, false) ||
3370
ImGui::IsKeyPressed(ImGuiKey_NavGamepadTweakFast, false) || ImGui::IsKeyPressed(ImGuiKey_RightArrow, false))
3371
{
3372
s_state.is_showing_all_leaderboard_entries = !s_state.is_showing_all_leaderboard_entries;
3373
ImGuiFullscreen::QueueResetFocus(ImGuiFullscreen::FocusResetType::ViewChanged);
3374
}
3375
3376
for (const bool show_all : {false, true})
3377
{
3378
const std::string_view title =
3379
show_all ? TRANSLATE_SV("Achievements", "Show Best") : TRANSLATE_SV("Achievements", "Show Nearby");
3380
if (ImGuiFullscreen::NavTab(title, s_state.is_showing_all_leaderboard_entries == show_all, true, tab_width))
3381
{
3382
s_state.is_showing_all_leaderboard_entries = show_all;
3383
ImGuiFullscreen::QueueResetFocus(ImGuiFullscreen::FocusResetType::ViewChanged);
3384
}
3385
3386
if (!show_all)
3387
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + tab_spacing);
3388
}
3389
3390
ImGui::PopStyleVar();
3391
3392
ImGui::SetCursorPos(ImVec2(0.0f, ImGui::GetCursorPosY() + LayoutScale(tab_height_unscaled) + spacing * 2.0f));
3393
3394
ImVec2 column_heading_pos = ImGui::GetCursorScreenPos();
3395
float end_x = column_heading_pos.x + ImGui::GetContentRegionAvail().x;
3396
3397
// add padding from the window below, don't want the menu items butted up against the edge
3398
column_heading_pos.x += LayoutScale(ImGuiFullscreen::LAYOUT_MENU_WINDOW_X_PADDING);
3399
end_x -= LayoutScale(ImGuiFullscreen::LAYOUT_MENU_WINDOW_X_PADDING);
3400
3401
// and the padding for the frame itself
3402
column_heading_pos.x += LayoutScale(ImGuiFullscreen::LAYOUT_MENU_BUTTON_X_PADDING);
3403
end_x -= LayoutScale(ImGuiFullscreen::LAYOUT_MENU_BUTTON_X_PADDING);
3404
3405
const u32 heading_color = ImGui::GetColorU32(DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text]));
3406
3407
const float midpoint = column_heading_pos.y + UIStyle.LargeFontSize + LayoutScale(4.0f);
3408
float text_start_x = column_heading_pos.x;
3409
3410
const ImRect rank_bb(ImVec2(text_start_x, column_heading_pos.y), ImVec2(end_x, midpoint));
3411
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, rank_bb.Min, rank_bb.Max,
3412
heading_color, TRANSLATE_SV("Achievements", "Rank"), nullptr, ImVec2(0.0f, 0.0f), 0.0f,
3413
&rank_bb);
3414
text_start_x += rank_column_width + column_spacing;
3415
3416
const ImRect user_bb(ImVec2(text_start_x, column_heading_pos.y), ImVec2(end_x, midpoint));
3417
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, user_bb.Min, user_bb.Max,
3418
heading_color, TRANSLATE_SV("Achievements", "Name"), nullptr, ImVec2(0.0f, 0.0f), 0.0f,
3419
&user_bb);
3420
text_start_x += name_column_width + column_spacing;
3421
3422
static const char* value_headings[NUM_RC_CLIENT_LEADERBOARD_FORMATS] = {
3423
TRANSLATE_NOOP("Achievements", "Time"),
3424
TRANSLATE_NOOP("Achievements", "Score"),
3425
TRANSLATE_NOOP("Achievements", "Value"),
3426
};
3427
3428
const ImRect score_bb(ImVec2(text_start_x, column_heading_pos.y), ImVec2(end_x, midpoint));
3429
RenderShadowedTextClipped(
3430
UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, score_bb.Min, score_bb.Max, heading_color,
3431
Host::TranslateToStringView(
3432
"Achievements",
3433
value_headings[std::min<u8>(s_state.open_leaderboard->format, NUM_RC_CLIENT_LEADERBOARD_FORMATS - 1)]),
3434
nullptr, ImVec2(0.0f, 0.0f), 0.0f, &score_bb);
3435
text_start_x += time_column_width + column_spacing;
3436
3437
const ImRect date_bb(ImVec2(text_start_x, column_heading_pos.y), ImVec2(end_x, midpoint));
3438
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, date_bb.Min, date_bb.Max,
3439
heading_color, TRANSLATE_SV("Achievements", "Date Submitted"), nullptr,
3440
ImVec2(0.0f, 0.0f), 0.0f, &date_bb);
3441
3442
const float line_thickness = LayoutScale(1.0f);
3443
const float line_padding = LayoutScale(5.0f);
3444
const ImVec2 line_start(column_heading_pos.x, column_heading_pos.y + UIStyle.LargeFontSize + line_padding);
3445
const ImVec2 line_end(end_x, line_start.y);
3446
ImGui::GetWindowDrawList()->AddLine(line_start, line_end, ImGui::GetColorU32(ImGuiCol_TextDisabled),
3447
line_thickness);
3448
3449
// keep imgui happy
3450
ImGui::Dummy(ImVec2(end_x - column_heading_pos.x, column_heading_pos.y - line_end.y));
3451
}
3452
}
3453
ImGuiFullscreen::EndFullscreenWindow();
3454
3455
// See note in FullscreenUI::DrawSettingsWindow().
3456
if (ImGuiFullscreen::IsFocusResetFromWindowChange())
3457
ImGui::SetNextWindowScroll(ImVec2(0.0f, 0.0f));
3458
3459
if (!is_leaderboard_open)
3460
{
3461
if (ImGuiFullscreen::BeginFullscreenWindow(
3462
ImVec2(0.0f, heading_height),
3463
ImVec2(display_size.x, display_size.y - heading_height - LayoutScale(ImGuiFullscreen::LAYOUT_FOOTER_HEIGHT)),
3464
"leaderboards", background, 0.0f,
3465
ImVec2(ImGuiFullscreen::LAYOUT_MENU_WINDOW_X_PADDING, ImGuiFullscreen::LAYOUT_MENU_WINDOW_Y_PADDING), 0))
3466
{
3467
ImGuiFullscreen::ResetFocusHere();
3468
ImGuiFullscreen::BeginMenuButtons();
3469
3470
for (u32 bucket_index = 0; bucket_index < s_state.leaderboard_list->num_buckets; bucket_index++)
3471
{
3472
const rc_client_leaderboard_bucket_t& bucket = s_state.leaderboard_list->buckets[bucket_index];
3473
for (u32 i = 0; i < bucket.num_leaderboards; i++)
3474
DrawLeaderboardListEntry(bucket.leaderboards[i]);
3475
}
3476
3477
ImGuiFullscreen::EndMenuButtons();
3478
}
3479
ImGuiFullscreen::EndFullscreenWindow();
3480
3481
ImGuiFullscreen::SetFullscreenFooterText(
3482
std::array{std::make_pair(ImGuiFullscreen::IsGamepadInputSource() ? ICON_PF_XBOX_DPAD_UP_DOWN :
3483
ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN,
3484
TRANSLATE_SV("Achievements", "Change Selection")),
3485
std::make_pair(ImGuiFullscreen::IsGamepadInputSource() ? ICON_PF_BUTTON_A : ICON_PF_ENTER,
3486
TRANSLATE_SV("Achievements", "Open Leaderboard")),
3487
std::make_pair(ImGuiFullscreen::IsGamepadInputSource() ? ICON_PF_BUTTON_B : ICON_PF_ESC,
3488
TRANSLATE_SV("Achievements", "Back"))},
3489
FullscreenUI::GetBackgroundAlpha());
3490
}
3491
else
3492
{
3493
if (ImGuiFullscreen::BeginFullscreenWindow(
3494
ImVec2(0.0f, heading_height),
3495
ImVec2(display_size.x, display_size.y - heading_height - LayoutScale(ImGuiFullscreen::LAYOUT_FOOTER_HEIGHT)),
3496
"leaderboard", background, 0.0f,
3497
ImVec2(ImGuiFullscreen::LAYOUT_MENU_WINDOW_X_PADDING, ImGuiFullscreen::LAYOUT_MENU_WINDOW_Y_PADDING), 0))
3498
{
3499
ImGuiFullscreen::BeginMenuButtons();
3500
ImGuiFullscreen::ResetFocusHere();
3501
3502
if (!s_state.is_showing_all_leaderboard_entries)
3503
{
3504
if (s_state.leaderboard_nearby_entries)
3505
{
3506
for (u32 i = 0; i < s_state.leaderboard_nearby_entries->num_entries; i++)
3507
{
3508
DrawLeaderboardEntry(s_state.leaderboard_nearby_entries->entries[i], i,
3509
static_cast<s32>(i) == s_state.leaderboard_nearby_entries->user_index,
3510
rank_column_width, name_column_width, time_column_width, column_spacing);
3511
}
3512
}
3513
else
3514
{
3515
const ImVec2 pos_min(0.0f, heading_height);
3516
const ImVec2 pos_max(display_size.x, display_size.y);
3517
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, pos_min, pos_max,
3518
text_color,
3519
TRANSLATE_SV("Achievements", "Downloading leaderboard data, please wait..."),
3520
nullptr, ImVec2(0.5f, 0.5f), 0.0f);
3521
}
3522
}
3523
else
3524
{
3525
for (const rc_client_leaderboard_entry_list_t* list : s_state.leaderboard_entry_lists)
3526
{
3527
for (u32 i = 0; i < list->num_entries; i++)
3528
{
3529
DrawLeaderboardEntry(list->entries[i], i, static_cast<s32>(i) == list->user_index, rank_column_width,
3530
name_column_width, time_column_width, column_spacing);
3531
}
3532
}
3533
3534
bool visible;
3535
text.format(ICON_FA_HOURGLASS_HALF " {}", TRANSLATE_SV("Achievements", "Loading..."));
3536
ImGuiFullscreen::MenuButtonWithVisibilityQuery(text, text, {}, {}, &visible, false);
3537
if (visible && !s_state.leaderboard_fetch_handle)
3538
FetchNextLeaderboardEntries();
3539
}
3540
3541
ImGuiFullscreen::EndMenuButtons();
3542
}
3543
ImGuiFullscreen::EndFullscreenWindow();
3544
3545
ImGuiFullscreen::SetFullscreenFooterText(
3546
std::array{std::make_pair(ImGuiFullscreen::IsGamepadInputSource() ? ICON_PF_XBOX_DPAD_LEFT_RIGHT :
3547
ICON_PF_ARROW_LEFT ICON_PF_ARROW_RIGHT,
3548
TRANSLATE_SV("Achievements", "Change Page")),
3549
std::make_pair(ImGuiFullscreen::IsGamepadInputSource() ? ICON_PF_XBOX_DPAD_UP_DOWN :
3550
ICON_PF_ARROW_UP ICON_PF_ARROW_DOWN,
3551
TRANSLATE_SV("Achievements", "Change Selection")),
3552
std::make_pair(ImGuiFullscreen::IsGamepadInputSource() ? ICON_PF_BUTTON_A : ICON_PF_ENTER,
3553
TRANSLATE_SV("Achievements", "View Profile")),
3554
std::make_pair(ImGuiFullscreen::IsGamepadInputSource() ? ICON_PF_BUTTON_B : ICON_PF_ESC,
3555
TRANSLATE_SV("Achievements", "Back"))},
3556
FullscreenUI::GetBackgroundAlpha());
3557
}
3558
3559
if (close_leaderboard_on_exit)
3560
FullscreenUI::BeginTransition(&CloseLeaderboard);
3561
}
3562
3563
void Achievements::DrawLeaderboardEntry(const rc_client_leaderboard_entry_t& entry, u32 index, bool is_self,
3564
float rank_column_width, float name_column_width, float time_column_width,
3565
float column_spacing)
3566
{
3567
using ImGuiFullscreen::LayoutScale;
3568
using ImGuiFullscreen::RenderShadowedTextClipped;
3569
using ImGuiFullscreen::UIStyle;
3570
3571
ImRect bb;
3572
bool visible, hovered;
3573
bool pressed = ImGuiFullscreen::MenuButtonFrame(entry.user, UIStyle.LargeFontSize, true, &bb, &visible, &hovered);
3574
if (!visible)
3575
return;
3576
3577
const float midpoint = bb.Min.y + UIStyle.LargeFontSize + LayoutScale(4.0f);
3578
float text_start_x = bb.Min.x;
3579
SmallString text;
3580
3581
text.format("{}", entry.rank);
3582
3583
const u32 text_color =
3584
is_self ?
3585
IM_COL32(255, 242, 0, 255) :
3586
ImGui::GetColorU32(((index % 2) == 0) ? ImGuiFullscreen::DarkerColor(ImGui::GetStyle().Colors[ImGuiCol_Text]) :
3587
ImGui::GetStyle().Colors[ImGuiCol_Text]);
3588
3589
const ImRect rank_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
3590
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, rank_bb.Min, rank_bb.Max,
3591
text_color, text, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &rank_bb);
3592
text_start_x += rank_column_width + column_spacing;
3593
3594
const float icon_size = bb.Max.y - bb.Min.y;
3595
const ImRect icon_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
3596
GPUTexture* icon_tex = nullptr;
3597
if (auto it = std::find_if(s_state.leaderboard_user_icon_paths.begin(), s_state.leaderboard_user_icon_paths.end(),
3598
[&entry](const auto& it) { return it.first == &entry; });
3599
it != s_state.leaderboard_user_icon_paths.end())
3600
{
3601
if (!it->second.empty())
3602
icon_tex = ImGuiFullscreen::GetCachedTextureAsync(it->second);
3603
}
3604
else
3605
{
3606
std::string path = Achievements::GetLeaderboardUserBadgePath(&entry);
3607
if (!path.empty())
3608
{
3609
icon_tex = ImGuiFullscreen::GetCachedTextureAsync(path);
3610
s_state.leaderboard_user_icon_paths.emplace_back(&entry, std::move(path));
3611
}
3612
}
3613
if (icon_tex)
3614
{
3615
const ImRect fit_icon_bb =
3616
ImGuiFullscreen::CenterImage(ImRect(icon_bb.Min, icon_bb.Min + ImVec2(icon_size, icon_size)), icon_tex);
3617
ImGui::GetWindowDrawList()->AddImage(reinterpret_cast<ImTextureID>(icon_tex), fit_icon_bb.Min, fit_icon_bb.Max);
3618
}
3619
3620
const ImRect user_bb(ImVec2(text_start_x + column_spacing + icon_size, bb.Min.y), ImVec2(bb.Max.x, midpoint));
3621
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, user_bb.Min, user_bb.Max,
3622
text_color, entry.user, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &user_bb);
3623
text_start_x += name_column_width + column_spacing;
3624
3625
const ImRect score_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
3626
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, score_bb.Min, score_bb.Max,
3627
text_color, entry.display, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &score_bb);
3628
text_start_x += time_column_width + column_spacing;
3629
3630
const ImRect time_bb(ImVec2(text_start_x, bb.Min.y), ImVec2(bb.Max.x, midpoint));
3631
3632
const std::string submit_time =
3633
Host::FormatNumber(Host::NumberFormatType::LongDateTime, static_cast<s64>(entry.submitted));
3634
RenderShadowedTextClipped(UIStyle.Font, UIStyle.LargeFontSize, UIStyle.BoldFontWeight, time_bb.Min, time_bb.Max,
3635
text_color, submit_time, nullptr, ImVec2(0.0f, 0.0f), 0.0f, &time_bb);
3636
3637
if (pressed)
3638
{
3639
const SmallString url = SmallString::from_format(fmt::runtime(PROFILE_DETAILS_URL_TEMPLATE), entry.user);
3640
INFO_LOG("Opening profile details: {}", url);
3641
Host::OpenURL(url);
3642
}
3643
}
3644
void Achievements::DrawLeaderboardListEntry(const rc_client_leaderboard_t* lboard)
3645
{
3646
using ImGuiFullscreen::LayoutScale;
3647
using ImGuiFullscreen::MenuButton;
3648
using ImGuiFullscreen::UIStyle;
3649
3650
SmallString title;
3651
title.format("{}##{}", lboard->title, lboard->id);
3652
3653
std::string_view summary;
3654
if (lboard->description && lboard->description[0] != '\0')
3655
summary = lboard->description;
3656
3657
if (MenuButton(title, summary))
3658
FullscreenUI::BeginTransition([id = lboard->id]() { OpenLeaderboardById(id); });
3659
}
3660
3661
#endif // __ANDROID__
3662
3663
void Achievements::OpenLeaderboard(const rc_client_leaderboard_t* lboard)
3664
{
3665
DEV_LOG("Opening leaderboard '{}' ({})", lboard->title, lboard->id);
3666
3667
CloseLeaderboard();
3668
3669
s_state.open_leaderboard = lboard;
3670
s_state.is_showing_all_leaderboard_entries = false;
3671
s_state.leaderboard_fetch_handle = rc_client_begin_fetch_leaderboard_entries_around_user(
3672
s_state.client, lboard->id, LEADERBOARD_NEARBY_ENTRIES_TO_FETCH, LeaderboardFetchNearbyCallback, nullptr);
3673
ImGuiFullscreen::QueueResetFocus(ImGuiFullscreen::FocusResetType::Other);
3674
}
3675
3676
bool Achievements::OpenLeaderboardById(u32 leaderboard_id)
3677
{
3678
const rc_client_leaderboard_t* lb = rc_client_get_leaderboard_info(s_state.client, leaderboard_id);
3679
if (!lb)
3680
return false;
3681
3682
OpenLeaderboard(lb);
3683
return true;
3684
}
3685
3686
u32 Achievements::GetOpenLeaderboardId()
3687
{
3688
return s_state.open_leaderboard ? s_state.open_leaderboard->id : 0;
3689
}
3690
3691
bool Achievements::IsShowingAllLeaderboardEntries()
3692
{
3693
return s_state.is_showing_all_leaderboard_entries;
3694
}
3695
3696
const std::vector<rc_client_leaderboard_entry_list_t*>& Achievements::GetLeaderboardEntryLists()
3697
{
3698
return s_state.leaderboard_entry_lists;
3699
}
3700
3701
const rc_client_leaderboard_entry_list_t* Achievements::GetLeaderboardNearbyEntries()
3702
{
3703
return s_state.leaderboard_nearby_entries;
3704
}
3705
3706
void Achievements::LeaderboardFetchNearbyCallback(int result, const char* error_message,
3707
rc_client_leaderboard_entry_list_t* list, rc_client_t* client,
3708
void* callback_userdata)
3709
{
3710
const auto lock = GetLock();
3711
3712
s_state.leaderboard_fetch_handle = nullptr;
3713
3714
if (result != RC_OK)
3715
{
3716
ImGuiFullscreen::ShowToast(TRANSLATE("Achievements", "Leaderboard download failed"), error_message);
3717
CloseLeaderboard();
3718
return;
3719
}
3720
3721
if (s_state.leaderboard_nearby_entries)
3722
rc_client_destroy_leaderboard_entry_list(s_state.leaderboard_nearby_entries);
3723
s_state.leaderboard_nearby_entries = list;
3724
ImGuiFullscreen::QueueResetFocus(ImGuiFullscreen::FocusResetType::Other);
3725
}
3726
3727
void Achievements::LeaderboardFetchAllCallback(int result, const char* error_message,
3728
rc_client_leaderboard_entry_list_t* list, rc_client_t* client,
3729
void* callback_userdata)
3730
{
3731
const auto lock = GetLock();
3732
3733
s_state.leaderboard_fetch_handle = nullptr;
3734
3735
if (result != RC_OK)
3736
{
3737
ImGuiFullscreen::ShowToast(TRANSLATE("Achievements", "Leaderboard download failed"), error_message);
3738
CloseLeaderboard();
3739
return;
3740
}
3741
3742
if (s_state.leaderboard_entry_lists.empty())
3743
ImGuiFullscreen::QueueResetFocus(ImGuiFullscreen::FocusResetType::Other);
3744
3745
s_state.leaderboard_entry_lists.push_back(list);
3746
}
3747
3748
void Achievements::FetchNextLeaderboardEntries()
3749
{
3750
u32 start = 1;
3751
for (rc_client_leaderboard_entry_list_t* list : s_state.leaderboard_entry_lists)
3752
start += list->num_entries;
3753
3754
DEV_LOG("Fetching entries {} to {}", start, start + LEADERBOARD_ALL_FETCH_SIZE);
3755
3756
if (s_state.leaderboard_fetch_handle)
3757
rc_client_abort_async(s_state.client, s_state.leaderboard_fetch_handle);
3758
s_state.leaderboard_fetch_handle =
3759
rc_client_begin_fetch_leaderboard_entries(s_state.client, s_state.open_leaderboard->id, start,
3760
LEADERBOARD_ALL_FETCH_SIZE, LeaderboardFetchAllCallback, nullptr);
3761
}
3762
3763
void Achievements::CloseLeaderboard()
3764
{
3765
s_state.leaderboard_user_icon_paths.clear();
3766
3767
for (auto iter = s_state.leaderboard_entry_lists.rbegin(); iter != s_state.leaderboard_entry_lists.rend(); ++iter)
3768
rc_client_destroy_leaderboard_entry_list(*iter);
3769
s_state.leaderboard_entry_lists.clear();
3770
3771
if (s_state.leaderboard_nearby_entries)
3772
{
3773
rc_client_destroy_leaderboard_entry_list(s_state.leaderboard_nearby_entries);
3774
s_state.leaderboard_nearby_entries = nullptr;
3775
}
3776
3777
if (s_state.leaderboard_fetch_handle)
3778
{
3779
rc_client_abort_async(s_state.client, s_state.leaderboard_fetch_handle);
3780
s_state.leaderboard_fetch_handle = nullptr;
3781
}
3782
3783
s_state.open_leaderboard = nullptr;
3784
ImGuiFullscreen::QueueResetFocus(ImGuiFullscreen::FocusResetType::ViewChanged);
3785
}
3786
3787
#if defined(_WIN32)
3788
#include "common/windows_headers.h"
3789
#elif !defined(__ANDROID__)
3790
#include <unistd.h>
3791
#endif
3792
3793
#include "common/thirdparty/SmallVector.h"
3794
#include "common/thirdparty/aes.h"
3795
3796
#ifndef __ANDROID__
3797
3798
static TinyString GetLoginEncryptionMachineKey()
3799
{
3800
TinyString ret;
3801
3802
#ifdef _WIN32
3803
HKEY hKey;
3804
DWORD error;
3805
if ((error = RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Cryptography", 0, KEY_READ, &hKey)) !=
3806
ERROR_SUCCESS)
3807
{
3808
WARNING_LOG("Open SOFTWARE\\Microsoft\\Cryptography failed for machine key failed: {}", error);
3809
return ret;
3810
}
3811
3812
DWORD machine_guid_length;
3813
if ((error = RegGetValueA(hKey, NULL, "MachineGuid", RRF_RT_REG_SZ, NULL, NULL, &machine_guid_length)) !=
3814
ERROR_SUCCESS)
3815
{
3816
WARNING_LOG("Get MachineGuid failed: {}", error);
3817
RegCloseKey(hKey);
3818
return ret;
3819
}
3820
3821
ret.resize(machine_guid_length);
3822
if ((error = RegGetValueA(hKey, NULL, "MachineGuid", RRF_RT_REG_SZ, NULL, ret.data(), &machine_guid_length)) !=
3823
ERROR_SUCCESS ||
3824
machine_guid_length <= 1)
3825
{
3826
WARNING_LOG("Read MachineGuid failed: {}", error);
3827
ret = {};
3828
RegCloseKey(hKey);
3829
return ret;
3830
}
3831
3832
ret.resize(machine_guid_length);
3833
RegCloseKey(hKey);
3834
#else
3835
#if defined(__linux__)
3836
// use /etc/machine-id on Linux
3837
std::optional<std::string> machine_id = FileSystem::ReadFileToString("/etc/machine-id");
3838
if (machine_id.has_value())
3839
ret = std::string_view(machine_id.value());
3840
#elif defined(__APPLE__)
3841
// use gethostuuid(2) on macOS
3842
const struct timespec ts{};
3843
uuid_t uuid{};
3844
if (gethostuuid(uuid, &ts) == 0)
3845
ret.append_hex(uuid, sizeof(uuid), false);
3846
#endif
3847
3848
if (ret.empty())
3849
{
3850
WARNING_LOG("Falling back to gethostid()");
3851
3852
// fallback to POSIX gethostid()
3853
const long hostid = gethostid();
3854
ret.format("{:08X}", hostid);
3855
}
3856
#endif
3857
3858
return ret;
3859
}
3860
3861
#endif
3862
3863
static std::array<u8, 32> GetLoginEncryptionKey(std::string_view username)
3864
{
3865
// super basic key stretching
3866
static constexpr u32 EXTRA_ROUNDS = 100;
3867
3868
SHA256Digest digest;
3869
3870
#ifndef __ANDROID__
3871
// Only use machine key if we're not running in portable mode.
3872
if (!EmuFolders::IsRunningInPortableMode())
3873
{
3874
const TinyString machine_key = GetLoginEncryptionMachineKey();
3875
if (!machine_key.empty())
3876
digest.Update(machine_key.cbspan());
3877
else
3878
WARNING_LOG("Failed to get machine key, token will be decipherable.");
3879
}
3880
#endif
3881
3882
// salt with username
3883
digest.Update(username.data(), username.length());
3884
3885
std::array<u8, 32> key = digest.Final();
3886
3887
for (u32 i = 0; i < EXTRA_ROUNDS; i++)
3888
key = SHA256Digest::GetDigest(key);
3889
3890
return key;
3891
}
3892
3893
TinyString Achievements::EncryptLoginToken(std::string_view token, std::string_view username)
3894
{
3895
TinyString ret;
3896
if (token.empty() || username.empty())
3897
return ret;
3898
3899
const auto key = GetLoginEncryptionKey(username);
3900
std::array<u32, AES_KEY_SCHEDULE_SIZE> key_schedule;
3901
aes_key_setup(&key[0], key_schedule.data(), 128);
3902
3903
// has to be padded to the block size
3904
llvm::SmallVector<u8, 64> data(reinterpret_cast<const u8*>(token.data()),
3905
reinterpret_cast<const u8*>(token.data() + token.length()));
3906
data.resize(Common::AlignUpPow2(token.length(), AES_BLOCK_SIZE), 0);
3907
aes_encrypt_cbc(data.data(), data.size(), data.data(), key_schedule.data(), 128, &key[16]);
3908
3909
// base64 encode it
3910
const std::span<const u8> data_span(data.data(), data.size());
3911
ret.resize(static_cast<u32>(StringUtil::EncodedBase64Length(data_span)));
3912
StringUtil::EncodeBase64(ret.span(), data_span);
3913
return ret;
3914
}
3915
3916
TinyString Achievements::DecryptLoginToken(std::string_view encrypted_token, std::string_view username)
3917
{
3918
TinyString ret;
3919
if (encrypted_token.empty() || username.empty())
3920
return ret;
3921
3922
const size_t encrypted_data_length = StringUtil::DecodedBase64Length(encrypted_token);
3923
if (encrypted_data_length == 0 || (encrypted_data_length % AES_BLOCK_SIZE) != 0)
3924
return ret;
3925
3926
const auto key = GetLoginEncryptionKey(username);
3927
std::array<u32, AES_KEY_SCHEDULE_SIZE> key_schedule;
3928
aes_key_setup(&key[0], key_schedule.data(), 128);
3929
3930
// has to be padded to the block size
3931
llvm::SmallVector<u8, 64> encrypted_data;
3932
encrypted_data.resize(encrypted_data_length);
3933
if (StringUtil::DecodeBase64(std::span<u8>(encrypted_data.data(), encrypted_data.size()), encrypted_token) !=
3934
encrypted_data_length)
3935
{
3936
WARNING_LOG("Failed to base64 decode encrypted login token.");
3937
return ret;
3938
}
3939
3940
aes_decrypt_cbc(encrypted_data.data(), encrypted_data.size(), encrypted_data.data(), key_schedule.data(), 128,
3941
&key[16]);
3942
3943
// remove any trailing null bytes
3944
const size_t real_length =
3945
StringUtil::Strnlen(reinterpret_cast<const char*>(encrypted_data.data()), encrypted_data_length);
3946
ret.append(reinterpret_cast<const char*>(encrypted_data.data()), static_cast<u32>(real_length));
3947
return ret;
3948
}
3949
3950
std::string Achievements::GetHashDatabasePath()
3951
{
3952
return Path::Combine(EmuFolders::Cache, "achievement_gamedb.cache");
3953
}
3954
3955
std::string Achievements::GetProgressDatabasePath()
3956
{
3957
return Path::Combine(EmuFolders::Cache, "achievement_progress.cache");
3958
}
3959
3960
void Achievements::BeginRefreshHashDatabase()
3961
{
3962
INFO_LOG("Starting hash database refresh...");
3963
3964
// kick off both requests
3965
CancelHashDatabaseRequests();
3966
s_state.fetch_hash_library_request =
3967
rc_client_begin_fetch_hash_library(s_state.client, RC_CONSOLE_PLAYSTATION, FetchHashLibraryCallback, nullptr);
3968
s_state.fetch_all_progress_request =
3969
rc_client_begin_fetch_all_user_progress(s_state.client, RC_CONSOLE_PLAYSTATION, FetchAllProgressCallback, nullptr);
3970
if (!s_state.fetch_hash_library_request || !s_state.fetch_hash_library_request)
3971
{
3972
ERROR_LOG("Failed to create hash database refresh requests.");
3973
CancelHashDatabaseRequests();
3974
}
3975
}
3976
3977
void Achievements::FetchHashLibraryCallback(int result, const char* error_message, rc_client_hash_library_t* list,
3978
rc_client_t* client, void* callback_userdata)
3979
{
3980
s_state.fetch_hash_library_request = nullptr;
3981
3982
if (result != RC_OK)
3983
{
3984
ERROR_LOG("Fetch hash library failed: {}: {}", rc_error_str(result), error_message);
3985
CancelHashDatabaseRequests();
3986
return;
3987
}
3988
3989
s_state.fetch_hash_library_result = list;
3990
FinishRefreshHashDatabase();
3991
}
3992
3993
void Achievements::FetchAllProgressCallback(int result, const char* error_message, rc_client_all_user_progress_t* list,
3994
rc_client_t* client, void* callback_userdata)
3995
{
3996
s_state.fetch_all_progress_request = nullptr;
3997
3998
if (result != RC_OK)
3999
{
4000
ERROR_LOG("Fetch all progress failed: {}: {}", rc_error_str(result), error_message);
4001
CancelHashDatabaseRequests();
4002
return;
4003
}
4004
4005
s_state.fetch_all_progress_result = list;
4006
FinishRefreshHashDatabase();
4007
}
4008
4009
void Achievements::CancelHashDatabaseRequests()
4010
{
4011
if (s_state.fetch_all_progress_result)
4012
{
4013
rc_client_destroy_all_user_progress(s_state.fetch_all_progress_result);
4014
s_state.fetch_all_progress_result = nullptr;
4015
}
4016
if (s_state.fetch_all_progress_request)
4017
{
4018
rc_client_abort_async(s_state.client, s_state.fetch_all_progress_request);
4019
s_state.fetch_all_progress_request = nullptr;
4020
}
4021
4022
if (s_state.fetch_hash_library_result)
4023
{
4024
rc_client_destroy_hash_library(s_state.fetch_hash_library_result);
4025
s_state.fetch_hash_library_result = nullptr;
4026
}
4027
if (s_state.fetch_hash_library_request)
4028
{
4029
rc_client_abort_async(s_state.client, s_state.fetch_hash_library_request);
4030
s_state.fetch_hash_library_request = nullptr;
4031
}
4032
}
4033
4034
void Achievements::FinishRefreshHashDatabase()
4035
{
4036
if (!s_state.fetch_hash_library_result || !s_state.fetch_all_progress_result)
4037
{
4038
// not done yet
4039
return;
4040
}
4041
4042
// build mapping of hashes to game ids and achievement counts
4043
BuildHashDatabase(s_state.fetch_hash_library_result, s_state.fetch_all_progress_result);
4044
4045
// update the progress tracking while we're at it
4046
BuildProgressDatabase(s_state.fetch_all_progress_result);
4047
4048
// tidy up
4049
rc_client_destroy_all_user_progress(s_state.fetch_all_progress_result);
4050
s_state.fetch_all_progress_result = nullptr;
4051
rc_client_destroy_hash_library(s_state.fetch_hash_library_result);
4052
s_state.fetch_hash_library_result = nullptr;
4053
4054
// update game list, we might have some new games that weren't in the seed database
4055
GameList::UpdateAllAchievementData();
4056
4057
Host::OnAchievementsAllProgressRefreshed();
4058
}
4059
4060
bool Achievements::RefreshAllProgressDatabase(Error* error)
4061
{
4062
if (!IsLoggedIn())
4063
{
4064
Error::SetStringView(error, TRANSLATE_SV("Achievements", "User is not logged in."));
4065
return false;
4066
}
4067
4068
if (s_state.fetch_hash_library_request || s_state.fetch_all_progress_request || s_state.refresh_all_progress_request)
4069
{
4070
Error::SetStringView(error, TRANSLATE_SV("Achievements", "Progress is already being updated."));
4071
return false;
4072
}
4073
4074
// refresh in progress
4075
s_state.refresh_all_progress_request = rc_client_begin_fetch_all_user_progress(s_state.client, RC_CONSOLE_PLAYSTATION,
4076
RefreshAllProgressCallback, nullptr);
4077
4078
return true;
4079
}
4080
4081
void Achievements::RefreshAllProgressCallback(int result, const char* error_message,
4082
rc_client_all_user_progress_t* list, rc_client_t* client,
4083
void* callback_userdata)
4084
{
4085
s_state.refresh_all_progress_request = nullptr;
4086
4087
if (result != RC_OK)
4088
{
4089
Host::ReportErrorAsync(TRANSLATE_SV("Achievements", "Error"),
4090
fmt::format("{}: {}\n{}", TRANSLATE_SV("Achievements", "Refresh all progress failed"),
4091
rc_error_str(result), error_message));
4092
return;
4093
}
4094
4095
BuildProgressDatabase(list);
4096
rc_client_destroy_all_user_progress(list);
4097
4098
GameList::UpdateAllAchievementData();
4099
4100
Host::OnAchievementsAllProgressRefreshed();
4101
4102
if (FullscreenUI::IsInitialized())
4103
{
4104
GPUThread::RunOnThread([]() {
4105
if (!FullscreenUI::IsInitialized())
4106
return;
4107
4108
ImGuiFullscreen::ShowToast({}, TRANSLATE_STR("Achievements", "Updated achievement progress database."),
4109
Host::OSD_INFO_DURATION);
4110
});
4111
}
4112
}
4113
4114
void Achievements::BuildHashDatabase(const rc_client_hash_library_t* hashlib,
4115
const rc_client_all_user_progress_t* allprog)
4116
{
4117
std::vector<HashDatabaseEntry> dbentries;
4118
dbentries.reserve(hashlib->num_entries);
4119
4120
for (const rc_client_hash_library_entry_t& entry :
4121
std::span<const rc_client_hash_library_entry_t>(hashlib->entries, hashlib->num_entries))
4122
{
4123
HashDatabaseEntry dbentry;
4124
dbentry.game_id = entry.game_id;
4125
dbentry.num_achievements = 0;
4126
if (StringUtil::DecodeHex(dbentry.hash, entry.hash) != GAME_HASH_LENGTH)
4127
{
4128
WARNING_LOG("Invalid hash '{}' in game ID {}", entry.hash, entry.game_id);
4129
continue;
4130
}
4131
4132
// Just in case...
4133
if (std::any_of(dbentries.begin(), dbentries.end(),
4134
[&dbentry](const HashDatabaseEntry& e) { return (e.hash == dbentry.hash); }))
4135
{
4136
WARNING_LOG("Duplicate hash {}", entry.hash);
4137
continue;
4138
}
4139
4140
dbentries.push_back(dbentry);
4141
}
4142
4143
// fill in achievement counts
4144
for (const rc_client_all_user_progress_entry_t& entry :
4145
std::span<const rc_client_all_user_progress_entry_t>(allprog->entries, allprog->num_entries))
4146
{
4147
// can have multiple hashes with the same game id, update count on all of them
4148
bool found_one = false;
4149
for (HashDatabaseEntry& dbentry : dbentries)
4150
{
4151
if (dbentry.game_id == entry.game_id)
4152
{
4153
dbentry.num_achievements = entry.num_achievements;
4154
found_one = true;
4155
}
4156
}
4157
4158
if (!found_one)
4159
WARNING_LOG("All progress contained game ID {} without hash", entry.game_id);
4160
}
4161
4162
s_state.hashdb_entries = std::move(dbentries);
4163
s_state.hashdb_loaded = true;
4164
4165
Error error;
4166
if (!SortAndSaveHashDatabase(&error))
4167
ERROR_LOG("Failed to sort/save hash database from server: {}", error.GetDescription());
4168
}
4169
4170
bool Achievements::CreateHashDatabaseFromSeedDatabase(const std::string& path, Error* error)
4171
{
4172
std::optional<std::string> yaml_data = Host::ReadResourceFileToString("achievement_hashlib.yaml", false, error);
4173
if (!yaml_data.has_value())
4174
{
4175
Error::SetStringView(error, "Seed database is missing.");
4176
return false;
4177
}
4178
4179
const ryml::Tree yaml =
4180
ryml::parse_in_place(to_csubstr(path), c4::substr(reinterpret_cast<char*>(yaml_data->data()), yaml_data->size()));
4181
const ryml::ConstNodeRef root = yaml.rootref();
4182
if (root.empty())
4183
{
4184
Error::SetStringView(error, "Seed database is empty.");
4185
return false;
4186
}
4187
4188
std::vector<HashDatabaseEntry> dbentries;
4189
4190
if (const ryml::ConstNodeRef hashes = root.find_child(to_csubstr("hashes")); hashes.valid())
4191
{
4192
dbentries.reserve(hashes.num_children());
4193
for (const ryml::ConstNodeRef& current : hashes.cchildren())
4194
{
4195
const std::string_view hash = to_stringview(current.key());
4196
const std::optional<u32> game_id = StringUtil::FromChars<u32>(to_stringview(current.val()));
4197
if (!game_id.has_value())
4198
{
4199
WARNING_LOG("Invalid game ID {} in hash {}", to_stringview(current.val()), hash);
4200
continue;
4201
}
4202
4203
HashDatabaseEntry dbentry;
4204
dbentry.game_id = game_id.value();
4205
dbentry.num_achievements = 0;
4206
if (StringUtil::DecodeHex(dbentry.hash, hash) != GAME_HASH_LENGTH)
4207
{
4208
WARNING_LOG("Invalid hash '{}' in game ID {}", hash, game_id.value());
4209
continue;
4210
}
4211
4212
dbentries.push_back(dbentry);
4213
}
4214
}
4215
4216
if (const ryml::ConstNodeRef achievements = root.find_child(to_csubstr("achievements")); achievements.valid())
4217
{
4218
for (const ryml::ConstNodeRef& current : achievements.cchildren())
4219
{
4220
const std::optional<u32> game_id = StringUtil::FromChars<u32>(to_stringview(current.key()));
4221
const std::optional<u32> num_achievements = StringUtil::FromChars<u32>(to_stringview(current.val()));
4222
if (!game_id.has_value() || !num_achievements.has_value())
4223
{
4224
WARNING_LOG("Invalid achievements entry in game ID {}", to_stringview(current.key()));
4225
continue;
4226
}
4227
4228
// can have multiple hashes with the same game id, update count on all of them
4229
bool found_one = false;
4230
for (HashDatabaseEntry& dbentry : dbentries)
4231
{
4232
if (dbentry.game_id == game_id.value())
4233
{
4234
dbentry.num_achievements = num_achievements.value();
4235
found_one = true;
4236
}
4237
}
4238
4239
if (!found_one)
4240
WARNING_LOG("Seed database contained game ID {} without hash", game_id.value());
4241
}
4242
}
4243
4244
if (dbentries.empty())
4245
{
4246
Error::SetStringView(error, "Parsed seed database was empty");
4247
return false;
4248
}
4249
4250
s_state.hashdb_entries = std::move(dbentries);
4251
s_state.hashdb_loaded = true;
4252
4253
Error save_error;
4254
if (!SortAndSaveHashDatabase(&save_error))
4255
ERROR_LOG("Failed to sort/save hash database from server: {}", save_error.GetDescription());
4256
4257
return true;
4258
}
4259
4260
bool Achievements::SortAndSaveHashDatabase(Error* error)
4261
{
4262
// sort hashes for quick lookup
4263
s_state.hashdb_entries.shrink_to_fit();
4264
std::sort(s_state.hashdb_entries.begin(), s_state.hashdb_entries.end(),
4265
[](const HashDatabaseEntry& lhs, const HashDatabaseEntry& rhs) {
4266
return std::memcmp(lhs.hash.data(), rhs.hash.data(), GAME_HASH_LENGTH) < 0;
4267
});
4268
4269
FileSystem::AtomicRenamedFile fp = FileSystem::CreateAtomicRenamedFile(GetHashDatabasePath().c_str(), error);
4270
if (!fp)
4271
{
4272
Error::AddPrefix(error, "Failed to open cache for writing: ");
4273
return false;
4274
}
4275
4276
BinaryFileWriter writer(fp.get());
4277
writer.WriteU32(static_cast<u32>(s_state.hashdb_entries.size()));
4278
for (const HashDatabaseEntry& entry : s_state.hashdb_entries)
4279
{
4280
writer.Write(entry.hash.data(), GAME_HASH_LENGTH);
4281
writer.WriteU32(entry.game_id);
4282
writer.WriteU32(entry.num_achievements);
4283
}
4284
4285
if (!writer.Flush(error) || !FileSystem::CommitAtomicRenamedFile(fp, error))
4286
{
4287
Error::AddPrefix(error, "Failed to write cache: ");
4288
return false;
4289
}
4290
4291
INFO_LOG("Wrote {} games to hash database", s_state.hashdb_entries.size());
4292
return true;
4293
}
4294
4295
bool Achievements::LoadHashDatabase(const std::string& path, Error* error)
4296
{
4297
FileSystem::ManagedCFilePtr fp = FileSystem::OpenManagedCFile(path.c_str(), "rb", error);
4298
if (!fp)
4299
{
4300
Error::AddPrefix(error, "Failed to open cache for reading: ");
4301
return false;
4302
}
4303
4304
BinaryFileReader reader(fp.get());
4305
const u32 count = reader.ReadU32();
4306
4307
// simple sanity check on file size
4308
constexpr size_t entry_size = (GAME_HASH_LENGTH + sizeof(u32) + sizeof(u32));
4309
if (static_cast<s64>((count * entry_size) + sizeof(u32)) > FileSystem::FSize64(fp.get()))
4310
{
4311
Error::SetStringFmt(error, "Invalid entry count: {}", count);
4312
return false;
4313
}
4314
4315
s_state.hashdb_entries.resize(count);
4316
for (HashDatabaseEntry& entry : s_state.hashdb_entries)
4317
{
4318
reader.Read(entry.hash.data(), entry.hash.size());
4319
reader.ReadU32(&entry.game_id);
4320
reader.ReadU32(&entry.num_achievements);
4321
}
4322
if (reader.HasError())
4323
{
4324
Error::SetStringView(error, "Error while reading cache");
4325
s_state.hashdb_entries = {};
4326
return false;
4327
}
4328
4329
VERBOSE_LOG("Loaded {} entries from cached hash database", s_state.hashdb_entries.size());
4330
return true;
4331
}
4332
4333
const Achievements::HashDatabaseEntry* Achievements::LookupGameHash(const GameHash& hash)
4334
{
4335
if (!s_state.hashdb_loaded) [[unlikely]]
4336
{
4337
// loaded by another thread?
4338
std::unique_lock lock(s_state.mutex);
4339
if (!s_state.hashdb_loaded)
4340
{
4341
Error error;
4342
std::string path = GetHashDatabasePath();
4343
const bool hashdb_exists = FileSystem::FileExists(path.c_str());
4344
if (!hashdb_exists || !LoadHashDatabase(path, &error))
4345
{
4346
if (hashdb_exists)
4347
WARNING_LOG("Failed to load hash database: {}", error.GetDescription());
4348
4349
if (!CreateHashDatabaseFromSeedDatabase(path, &error))
4350
ERROR_LOG("Failed to create hash database from seed database: {}", error.GetDescription());
4351
}
4352
}
4353
4354
s_state.hashdb_loaded = true;
4355
}
4356
4357
const auto iter = std::lower_bound(s_state.hashdb_entries.begin(), s_state.hashdb_entries.end(), hash,
4358
[](const HashDatabaseEntry& entry, const GameHash& search) {
4359
return (std::memcmp(entry.hash.data(), search.data(), GAME_HASH_LENGTH) < 0);
4360
});
4361
return (iter != s_state.hashdb_entries.end() && std::memcmp(iter->hash.data(), hash.data(), GAME_HASH_LENGTH) == 0) ?
4362
&(*iter) :
4363
nullptr;
4364
}
4365
4366
void Achievements::PreloadHashDatabase()
4367
{
4368
const std::string hash_database_path = GetHashDatabasePath();
4369
const std::string progress_database_path = GetProgressDatabasePath();
4370
4371
bool has_hash_database = (s_state.hashdb_loaded && !s_state.hashdb_entries.empty());
4372
const bool has_progress_database = FileSystem::FileExists(progress_database_path.c_str());
4373
4374
// if we don't have a progress database, just redownload everything, it's probably our first login
4375
if (!has_hash_database && has_progress_database && FileSystem::FileExists(hash_database_path.c_str()))
4376
{
4377
// try loading binary cache
4378
VERBOSE_LOG("Trying to load hash database from {}", hash_database_path);
4379
4380
Error error;
4381
has_hash_database = LoadHashDatabase(hash_database_path, &error);
4382
if (!has_hash_database)
4383
ERROR_LOG("Failed to load hash database: {}", error.GetDescription());
4384
}
4385
4386
// don't try to load the hash database from the game list now
4387
s_state.hashdb_loaded = true;
4388
4389
// got everything?
4390
if (has_hash_database && has_progress_database)
4391
return;
4392
4393
// kick off a new download, game list will be notified when it's done
4394
BeginRefreshHashDatabase();
4395
}
4396
4397
FileSystem::ManagedCFilePtr Achievements::OpenProgressDatabase(bool for_write, bool truncate, Error* error)
4398
{
4399
const std::string path = GetProgressDatabasePath();
4400
const FileSystem::FileShareMode share_mode =
4401
for_write ? FileSystem::FileShareMode::DenyReadWrite : FileSystem::FileShareMode::DenyWrite;
4402
#ifdef _WIN32
4403
const char* mode = for_write ? (truncate ? "w+b" : "r+b") : "rb";
4404
#else
4405
// Always open read/write on Linux, since we need it for flock().
4406
const char* mode = truncate ? "w+b" : "r+b";
4407
#endif
4408
4409
FileSystem::ManagedCFilePtr fp = FileSystem::OpenManagedSharedCFile(path.c_str(), mode, share_mode, error);
4410
if (fp)
4411
return fp;
4412
4413
// Doesn't exist? Create it.
4414
if (errno == ENOENT)
4415
{
4416
if (!for_write)
4417
return nullptr;
4418
4419
mode = "w+b";
4420
fp = FileSystem::OpenManagedSharedCFile(path.c_str(), mode, share_mode, error);
4421
if (fp)
4422
return fp;
4423
}
4424
4425
// If there's a sharing violation, try again for 100ms.
4426
if (errno != EACCES)
4427
return nullptr;
4428
4429
Timer timer;
4430
while (timer.GetTimeMilliseconds() <= 100.0f)
4431
{
4432
fp = FileSystem::OpenManagedSharedCFile(path.c_str(), mode, share_mode, error);
4433
if (fp)
4434
return fp;
4435
4436
if (errno != EACCES)
4437
return nullptr;
4438
}
4439
4440
Error::SetStringView(error, "Timed out while trying to open progress database.");
4441
return nullptr;
4442
}
4443
4444
void Achievements::BuildProgressDatabase(const rc_client_all_user_progress_t* allprog)
4445
{
4446
// no point storing it in memory, just write directly to the file
4447
Error error;
4448
FileSystem::ManagedCFilePtr fp = OpenProgressDatabase(true, true, &error);
4449
if (!fp)
4450
{
4451
ERROR_LOG("Failed to build progress database: {}", error.GetDescription());
4452
return;
4453
}
4454
4455
#ifdef HAS_POSIX_FILE_LOCK
4456
FileSystem::POSIXLock lock(fp.get());
4457
#endif
4458
4459
// save a rewrite at the beginning
4460
u32 games_with_unlocks = 0;
4461
for (u32 i = 0; i < allprog->num_entries; i++)
4462
{
4463
games_with_unlocks += BoolToUInt32(
4464
(allprog->entries[i].num_unlocked_achievements + allprog->entries[i].num_unlocked_achievements_hardcore) > 0);
4465
}
4466
4467
BinaryFileWriter writer(fp.get());
4468
writer.WriteU32(games_with_unlocks);
4469
if (games_with_unlocks > 0)
4470
{
4471
for (const rc_client_all_user_progress_entry_t& entry :
4472
std::span<const rc_client_all_user_progress_entry_t>(allprog->entries, allprog->num_entries))
4473
{
4474
if ((entry.num_unlocked_achievements + entry.num_unlocked_achievements_hardcore) == 0)
4475
continue;
4476
4477
writer.WriteU32(entry.game_id);
4478
writer.WriteU16(Truncate16(entry.num_unlocked_achievements));
4479
writer.WriteU16(Truncate16(entry.num_unlocked_achievements_hardcore));
4480
}
4481
}
4482
4483
if (!writer.Flush(&error))
4484
ERROR_LOG("Failed to write progress database: {}", error.GetDescription());
4485
}
4486
4487
void Achievements::UpdateProgressDatabase()
4488
{
4489
// don't write updates in spectator mode
4490
if (rc_client_get_spectator_mode_enabled(s_state.client))
4491
return;
4492
4493
// query list to get both hardcore and softcore counts
4494
rc_client_achievement_list_t* const achievements =
4495
rc_client_create_achievement_list(s_state.client, RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE, 0);
4496
u32 num_achievements = 0;
4497
u32 achievements_unlocked = 0;
4498
u32 achievements_unlocked_hardcore = 0;
4499
if (achievements)
4500
{
4501
for (const rc_client_achievement_bucket_t& bucket :
4502
std::span<const rc_client_achievement_bucket_t>(achievements->buckets, achievements->num_buckets))
4503
{
4504
for (const rc_client_achievement_t* achievement :
4505
std::span<const rc_client_achievement_t*>(bucket.achievements, bucket.num_achievements))
4506
{
4507
achievements_unlocked += BoolToUInt32((achievement->unlocked & RC_CLIENT_ACHIEVEMENT_UNLOCKED_SOFTCORE) != 0);
4508
achievements_unlocked_hardcore +=
4509
BoolToUInt32((achievement->unlocked & RC_CLIENT_ACHIEVEMENT_UNLOCKED_HARDCORE) != 0);
4510
}
4511
4512
num_achievements += bucket.num_achievements;
4513
}
4514
rc_client_destroy_achievement_list(achievements);
4515
}
4516
4517
// update the game list, this should be fairly quick
4518
if (s_state.game_hash.has_value())
4519
{
4520
GameList::UpdateAchievementData(s_state.game_hash.value(), s_state.game_id, num_achievements, achievements_unlocked,
4521
achievements_unlocked_hardcore);
4522
}
4523
4524
// done asynchronously so we don't hitch on disk I/O
4525
System::QueueAsyncTask([game_id = s_state.game_id, achievements_unlocked, achievements_unlocked_hardcore]() {
4526
// no point storing it in memory, just write directly to the file
4527
Error error;
4528
FileSystem::ManagedCFilePtr fp = OpenProgressDatabase(true, false, &error);
4529
const s64 size = fp ? FileSystem::FSize64(fp.get(), &error) : -1;
4530
if (!fp || size < 0)
4531
{
4532
ERROR_LOG("Failed to update progress database: {}", error.GetDescription());
4533
return;
4534
}
4535
4536
#ifdef HAS_POSIX_FILE_LOCK
4537
FileSystem::POSIXLock lock(fp.get());
4538
#endif
4539
4540
BinaryFileReader reader(fp.get());
4541
const u32 game_count = (size > 0) ? reader.ReadU32() : 0;
4542
4543
// entry exists?
4544
s64 found_offset = -1;
4545
for (u32 i = 0; i < game_count; i++)
4546
{
4547
const u32 check_game_id = reader.ReadU32();
4548
if (check_game_id == game_id)
4549
{
4550
// do we even need to change it?
4551
const u16 current_achievements_unlocked = reader.ReadU16();
4552
const u16 current_achievements_unlocked_hardcore = reader.ReadU16();
4553
if (current_achievements_unlocked == achievements_unlocked &&
4554
current_achievements_unlocked_hardcore == achievements_unlocked_hardcore)
4555
{
4556
VERBOSE_LOG("No update to progress database needed for game {}", game_id);
4557
return;
4558
}
4559
4560
found_offset = FileSystem::FTell64(fp.get()) - sizeof(u16) - sizeof(u16);
4561
break;
4562
}
4563
4564
if (!FileSystem::FSeek64(fp.get(), sizeof(u16) + sizeof(u16), SEEK_CUR, &error)) [[unlikely]]
4565
{
4566
ERROR_LOG("Failed to seek in progress database: {}", error.GetDescription());
4567
return;
4568
}
4569
}
4570
4571
// make sure we had no read errors, don't want to make corrupted files
4572
if (reader.HasError())
4573
{
4574
ERROR_LOG("Failed to read in progress database: {}", error.GetDescription());
4575
return;
4576
}
4577
4578
BinaryFileWriter writer(fp.get());
4579
4580
// append/update the entry
4581
if (found_offset > 0)
4582
{
4583
INFO_LOG("Updating game {} with {}/{} unlocked", game_id, achievements_unlocked, achievements_unlocked_hardcore);
4584
4585
// need to seek when switching read->write
4586
if (!FileSystem::FSeek64(fp.get(), found_offset, SEEK_SET, &error))
4587
{
4588
ERROR_LOG("Failed to write seek in progress database: {}", error.GetDescription());
4589
return;
4590
}
4591
4592
writer.WriteU16(Truncate16(achievements_unlocked));
4593
writer.WriteU16(Truncate16(achievements_unlocked_hardcore));
4594
}
4595
else
4596
{
4597
// don't write zeros to the file. we could still end up with zeros here after reset, but that's rare
4598
if (achievements_unlocked == 0 && achievements_unlocked_hardcore == 0)
4599
return;
4600
4601
INFO_LOG("Appending game {} with {}/{} unlocked", game_id, achievements_unlocked, achievements_unlocked_hardcore);
4602
4603
if (size == 0)
4604
{
4605
// if the file is empty, need to write the header
4606
writer.WriteU32(1);
4607
}
4608
else
4609
{
4610
// update the count
4611
if (!FileSystem::FSeek64(fp.get(), 0, SEEK_SET, &error) || !writer.WriteU32(game_count + 1) ||
4612
!FileSystem::FSeek64(fp.get(), 0, SEEK_END, &error))
4613
{
4614
ERROR_LOG("Failed to write seek/update header in progress database: {}", error.GetDescription());
4615
return;
4616
}
4617
}
4618
4619
writer.WriteU32(game_id);
4620
writer.WriteU16(Truncate16(achievements_unlocked));
4621
writer.WriteU16(Truncate16(achievements_unlocked_hardcore));
4622
}
4623
4624
if (!writer.Flush(&error))
4625
{
4626
ERROR_LOG("Failed to write count in progress database: {}", error.GetDescription());
4627
return;
4628
}
4629
});
4630
}
4631
4632
void Achievements::ClearProgressDatabase()
4633
{
4634
std::string path = GetProgressDatabasePath();
4635
if (FileSystem::FileExists(path.c_str()))
4636
{
4637
INFO_LOG("Deleting progress database {}", path);
4638
4639
Error error;
4640
if (!FileSystem::DeleteFile(path.c_str(), &error))
4641
ERROR_LOG("Failed to delete progress database: {}", error.GetDescription());
4642
}
4643
4644
GameList::UpdateAllAchievementData();
4645
}
4646
4647
Achievements::ProgressDatabase::ProgressDatabase() = default;
4648
4649
Achievements::ProgressDatabase::~ProgressDatabase() = default;
4650
4651
bool Achievements::ProgressDatabase::Load(Error* error)
4652
{
4653
FileSystem::ManagedCFilePtr fp = OpenProgressDatabase(false, false, error);
4654
if (!fp)
4655
return false;
4656
4657
#ifdef HAS_POSIX_FILE_LOCK
4658
FileSystem::POSIXLock lock(fp.get());
4659
#endif
4660
4661
BinaryFileReader reader(fp.get());
4662
const u32 count = reader.ReadU32();
4663
4664
// simple sanity check on file size
4665
constexpr size_t entry_size = (sizeof(u32) + sizeof(u16) + sizeof(u16));
4666
if (static_cast<s64>((count * entry_size) + sizeof(u32)) > FileSystem::FSize64(fp.get()))
4667
{
4668
Error::SetStringFmt(error, "Invalid entry count: {}", count);
4669
return false;
4670
}
4671
4672
m_entries.reserve(count);
4673
for (u32 i = 0; i < count; i++)
4674
{
4675
const Entry entry = {.game_id = reader.ReadU32(),
4676
.num_achievements_unlocked = reader.ReadU16(),
4677
.num_hc_achievements_unlocked = reader.ReadU16()};
4678
4679
// Just in case...
4680
if (std::any_of(m_entries.begin(), m_entries.end(),
4681
[id = entry.game_id](const Entry& e) { return (e.game_id == id); }))
4682
{
4683
WARNING_LOG("Duplicate game ID {}", entry.game_id);
4684
continue;
4685
}
4686
4687
m_entries.push_back(entry);
4688
}
4689
4690
// sort for quick lookup
4691
m_entries.shrink_to_fit();
4692
std::sort(m_entries.begin(), m_entries.end(),
4693
[](const Entry& lhs, const Entry& rhs) { return (lhs.game_id < rhs.game_id); });
4694
4695
return true;
4696
}
4697
4698
const Achievements::ProgressDatabase::Entry* Achievements::ProgressDatabase::LookupGame(u32 game_id) const
4699
{
4700
const auto iter = std::lower_bound(m_entries.begin(), m_entries.end(), game_id,
4701
[](const Entry& entry, u32 search) { return (entry.game_id < search); });
4702
return (iter != m_entries.end() && iter->game_id == game_id) ? &(*iter) : nullptr;
4703
}
4704
4705
#ifdef RC_CLIENT_SUPPORTS_RAINTEGRATION
4706
4707
#include "common/windows_headers.h"
4708
4709
#include "rc_client_raintegration.h"
4710
4711
namespace Achievements {
4712
4713
static void FinishLoadRAIntegration();
4714
static void FinishLoadRAIntegrationOnCPUThread();
4715
4716
static void RAIntegrationBeginLoadCallback(int result, const char* error_message, rc_client_t* client, void* userdata);
4717
static void RAIntegrationEventHandler(const rc_client_raintegration_event_t* event, rc_client_t* client);
4718
static void RAIntegrationWriteMemoryCallback(uint32_t address, uint8_t* buffer, uint32_t num_bytes,
4719
rc_client_t* client);
4720
static void RAIntegrationGetGameNameCallback(char* buffer, uint32_t buffer_size, rc_client_t* client);
4721
4722
} // namespace Achievements
4723
4724
bool Achievements::IsUsingRAIntegration()
4725
{
4726
return s_state.using_raintegration;
4727
}
4728
4729
bool Achievements::IsRAIntegrationAvailable()
4730
{
4731
return (FileSystem::FileExists(Path::Combine(EmuFolders::AppRoot, "RA_Integration-x64.dll").c_str()) ||
4732
FileSystem::FileExists(Path::Combine(EmuFolders::AppRoot, "RA_Integration.dll").c_str()));
4733
}
4734
4735
bool Achievements::IsRAIntegrationInitializing()
4736
{
4737
return (s_state.using_raintegration && (s_state.load_raintegration_request || s_state.raintegration_loading));
4738
}
4739
4740
void Achievements::BeginLoadRAIntegration()
4741
{
4742
// set the flag so we don't try to log in immediately, need to wait for RAIntegration to load first
4743
s_state.using_raintegration = true;
4744
s_state.raintegration_loading = true;
4745
4746
const std::wstring wapproot = StringUtil::UTF8StringToWideString(EmuFolders::AppRoot);
4747
s_state.load_raintegration_request = rc_client_begin_load_raintegration_deferred(
4748
s_state.client, wapproot.c_str(), RAIntegrationBeginLoadCallback, nullptr);
4749
}
4750
4751
void Achievements::RAIntegrationBeginLoadCallback(int result, const char* error_message, rc_client_t* client,
4752
void* userdata)
4753
{
4754
s_state.load_raintegration_request = nullptr;
4755
4756
if (result != RC_OK)
4757
{
4758
s_state.raintegration_loading = false;
4759
4760
std::string message = fmt::format("Failed to load RAIntegration:\n{}", error_message ? error_message : "");
4761
Host::ReportErrorAsync("RAIntegration Error", message);
4762
return;
4763
}
4764
4765
INFO_COLOR_LOG(StrongGreen, "RAIntegration DLL loaded, initializing.");
4766
Host::RunOnUIThread(&Achievements::FinishLoadRAIntegration);
4767
}
4768
4769
void Achievements::FinishLoadRAIntegration()
4770
{
4771
const std::optional<WindowInfo> wi = Host::GetTopLevelWindowInfo();
4772
const auto lock = GetLock();
4773
4774
// disabled externally?
4775
if (!s_state.using_raintegration)
4776
return;
4777
4778
const char* error_message = nullptr;
4779
const int res = rc_client_finish_load_raintegration(
4780
s_state.client,
4781
(wi.has_value() && wi->type == WindowInfo::Type::Win32) ? static_cast<HWND>(wi->window_handle) : NULL,
4782
"DuckStation", g_scm_tag_str, &error_message);
4783
if (res != RC_OK)
4784
{
4785
std::string message = fmt::format("Failed to initialize RAIntegration:\n{}", error_message ? error_message : "");
4786
Host::ReportErrorAsync("RAIntegration Error", message);
4787
s_state.using_raintegration = false;
4788
Host::RunOnCPUThread(&Achievements::FinishLoadRAIntegrationOnCPUThread);
4789
return;
4790
}
4791
4792
rc_client_raintegration_set_write_memory_function(s_state.client, RAIntegrationWriteMemoryCallback);
4793
rc_client_raintegration_set_console_id(s_state.client, RC_CONSOLE_PLAYSTATION);
4794
rc_client_raintegration_set_get_game_name_function(s_state.client, RAIntegrationGetGameNameCallback);
4795
rc_client_raintegration_set_event_handler(s_state.client, RAIntegrationEventHandler);
4796
4797
Host::OnRAIntegrationMenuChanged();
4798
4799
Host::RunOnCPUThread(&Achievements::FinishLoadRAIntegrationOnCPUThread);
4800
}
4801
4802
void Achievements::FinishLoadRAIntegrationOnCPUThread()
4803
{
4804
// note: this is executed even for the failure case.
4805
// we want to finish initializing with internal client if RAIntegration didn't load.
4806
const auto lock = GetLock();
4807
s_state.raintegration_loading = false;
4808
FinishInitialize();
4809
}
4810
4811
void Achievements::UnloadRAIntegration()
4812
{
4813
DebugAssert(s_state.using_raintegration && s_state.client);
4814
4815
if (s_state.load_raintegration_request)
4816
{
4817
rc_client_abort_async(s_state.client, s_state.load_raintegration_request);
4818
s_state.load_raintegration_request = nullptr;
4819
}
4820
4821
// Have to unload it on the UI thread, otherwise the DLL unload races the UI thread message processing.
4822
s_state.http_downloader->WaitForAllRequests();
4823
s_state.http_downloader.reset();
4824
s_state.raintegration_loading = false;
4825
s_state.using_raintegration = false;
4826
Host::RunOnUIThread([client = std::exchange(s_state.client, nullptr)]() {
4827
rc_client_unload_raintegration(client);
4828
rc_client_destroy(client);
4829
});
4830
4831
Host::OnRAIntegrationMenuChanged();
4832
}
4833
4834
void Achievements::RAIntegrationEventHandler(const rc_client_raintegration_event_t* event, rc_client_t* client)
4835
{
4836
switch (event->type)
4837
{
4838
case RC_CLIENT_RAINTEGRATION_EVENT_MENUITEM_CHECKED_CHANGED:
4839
case RC_CLIENT_RAINTEGRATION_EVENT_MENU_CHANGED:
4840
{
4841
Host::OnRAIntegrationMenuChanged();
4842
}
4843
break;
4844
4845
case RC_CLIENT_RAINTEGRATION_EVENT_HARDCORE_CHANGED:
4846
{
4847
// Could get called from a different thread...
4848
Host::RunOnCPUThread([]() {
4849
const auto lock = GetLock();
4850
OnHardcoreModeChanged(rc_client_get_hardcore_enabled(s_state.client) != 0, false, false);
4851
});
4852
}
4853
break;
4854
4855
case RC_CLIENT_RAINTEGRATION_EVENT_PAUSE:
4856
{
4857
Host::RunOnCPUThread([]() { System::PauseSystem(true); });
4858
}
4859
break;
4860
4861
default:
4862
ERROR_LOG("Unhandled RAIntegration event {}", static_cast<u32>(event->type));
4863
break;
4864
}
4865
}
4866
4867
void Achievements::RAIntegrationWriteMemoryCallback(uint32_t address, uint8_t* buffer, uint32_t num_bytes,
4868
rc_client_t* client)
4869
{
4870
if ((address + num_bytes) > 0x200400U) [[unlikely]]
4871
return;
4872
4873
// This can be called on the UI thread, so always queue it.
4874
llvm::SmallVector<u8, 16> data(buffer, buffer + num_bytes);
4875
Host::RunOnCPUThread([address, data = std::move(data)]() {
4876
u8* src = (address >= 0x200000U) ? CPU::g_state.scratchpad.data() : Bus::g_ram;
4877
const u32 offset = (address & Bus::RAM_2MB_MASK); // size guarded by check above
4878
4879
switch (data.size())
4880
{
4881
case 1:
4882
std::memcpy(&src[offset], data.data(), 1);
4883
break;
4884
case 2:
4885
std::memcpy(&src[offset], data.data(), 2);
4886
break;
4887
case 4:
4888
std::memcpy(&src[offset], data.data(), 4);
4889
break;
4890
default:
4891
[[unlikely]] std::memcpy(&src[offset], data.data(), data.size());
4892
break;
4893
}
4894
});
4895
}
4896
4897
void Achievements::RAIntegrationGetGameNameCallback(char* buffer, uint32_t buffer_size, rc_client_t* client)
4898
{
4899
StringUtil::Strlcpy(buffer, System::GetGameTitle(), buffer_size);
4900
}
4901
4902
#else
4903
4904
bool Achievements::IsUsingRAIntegration()
4905
{
4906
return false;
4907
}
4908
4909
bool Achievements::IsRAIntegrationAvailable()
4910
{
4911
return false;
4912
}
4913
4914
bool Achievements::IsRAIntegrationInitializing()
4915
{
4916
return false;
4917
}
4918
4919
#endif
4920
4921