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