Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
stenzek
GitHub Repository: stenzek/duckstation
Path: blob/master/src/core/achievements.cpp
7362 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 "core.h"
12
#include "cpu_core.h"
13
#include "fullscreenui.h"
14
#include "fullscreenui_private.h"
15
#include "game_list.h"
16
#include "host.h"
17
#include "imgui_overlays.h"
18
#include "sound_effect_manager.h"
19
#include "system.h"
20
#include "video_thread.h"
21
22
#include "scmversion/scmversion.h"
23
24
#include "common/assert.h"
25
#include "common/binary_reader_writer.h"
26
#include "common/easing.h"
27
#include "common/error.h"
28
#include "common/file_system.h"
29
#include "common/heap_array.h"
30
#include "common/log.h"
31
#include "common/md5_digest.h"
32
#include "common/path.h"
33
#include "common/progress_callback.h"
34
#include "common/ryml_helpers.h"
35
#include "common/scoped_guard.h"
36
#include "common/sha256_digest.h"
37
#include "common/small_string.h"
38
#include "common/string_util.h"
39
#include "common/timer.h"
40
41
#include "util/cd_image.h"
42
#include "util/http_downloader.h"
43
#include "util/imgui_manager.h"
44
#include "util/state_wrapper.h"
45
46
#include "IconsEmoji.h"
47
#include "IconsFontAwesome.h"
48
#include "IconsPromptFont.h"
49
#include "fmt/format.h"
50
#include "imgui.h"
51
#include "imgui_internal.h"
52
#include "rc_api_info.h"
53
#include "rc_api_runtime.h"
54
#include "rc_client.h"
55
#include "rc_consoles.h"
56
57
#include <algorithm>
58
#include <atomic>
59
#include <cstdarg>
60
#include <cstdlib>
61
#include <ctime>
62
#include <functional>
63
#include <string>
64
#include <unordered_set>
65
#include <vector>
66
67
LOG_CHANNEL(Achievements);
68
69
namespace Achievements {
70
71
static constexpr const char* INFO_SOUND_NAME = "sounds/achievements/message.wav";
72
static constexpr const char* UNLOCK_SOUND_NAME = "sounds/achievements/unlock.wav";
73
static constexpr const char* LBSUBMIT_SOUND_NAME = "sounds/achievements/lbsubmit.wav";
74
static constexpr const char* CACHE_SUBDIRECTORY_NAME = "achievement_images";
75
constexpr const char* const RA_LOGO_ICON_NAME = "images/ra-icon.webp";
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 GAME_COMPLETE_NOTIFICATION_TIME = 20.0f;
81
static constexpr float CHALLENGE_STARTED_NOTIFICATION_TIME = 5.0f;
82
static constexpr float CHALLENGE_FAILED_NOTIFICATION_TIME = 5.0f;
83
static constexpr float LEADERBOARD_STARTED_NOTIFICATION_TIME = 3.0f;
84
static constexpr float LEADERBOARD_FAILED_NOTIFICATION_TIME = 3.0f;
85
static constexpr u16 LEADERBOARD_NOTIFICATION_MIN_WIDTH = 380;
86
87
// Some API calls are really slow. Set a longer timeout.
88
static constexpr float SERVER_CALL_TIMEOUT = 60.0f;
89
90
// Chrome uses 10 server calls per domain, seems reasonable.
91
static constexpr u32 MAX_CONCURRENT_SERVER_CALLS = 10;
92
93
namespace {
94
95
struct LoginWithPasswordParameters
96
{
97
const char* username;
98
Error* error;
99
rc_client_async_handle_t* request;
100
bool result;
101
};
102
103
struct FetchGameTitlesParameters
104
{
105
Error* error;
106
rc_client_async_handle_t* request;
107
rc_client_game_title_list_t* list;
108
bool success;
109
};
110
111
} // namespace
112
113
static void ReportError(std::string_view sv);
114
template<typename... T>
115
static void ReportFmtError(fmt::format_string<T...> fmt, T&&... args);
116
template<typename... T>
117
static void ReportRCError(int err, fmt::format_string<T...> fmt, T&&... args);
118
static void ClearGameInfo();
119
static void ClearGameHash();
120
static bool HasSavedCredentials();
121
static bool TryLoggingInWithToken();
122
static void EnableHardcoreMode(bool display_message, bool display_game_summary);
123
static void OnHardcoreModeChanged(bool enabled, bool display_message, bool display_game_summary);
124
static bool IsRAIntegrationInitializing();
125
static void FinishInitialize();
126
static void FinishLogin();
127
static bool IdentifyGame(CDImage* image);
128
static bool IdentifyCurrentGame();
129
static void BeginLoadGame();
130
static void UpdateGameSummary(bool update_progress_database);
131
static DynamicHeapArray<u8> SaveStateToBuffer();
132
static void LoadStateFromBuffer(std::span<const u8> data, std::unique_lock<std::recursive_mutex>& lock);
133
static bool SaveStateToBuffer(std::span<u8> data);
134
static std::string GetImageURL(const char* image_name, u32 type);
135
static std::string GetLocalImagePath(const std::string_view image_name, u32 type);
136
static void DownloadImage(std::string url, std::string cache_path);
137
138
static TinyString DecryptLoginToken(std::string_view encrypted_token, std::string_view username);
139
static TinyString EncryptLoginToken(std::string_view token, std::string_view username);
140
141
static bool CreateClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http);
142
static void DestroyClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http);
143
static void ClientMessageCallback(const char* message, const rc_client_t* client);
144
static uint32_t ClientReadMemory(uint32_t address, uint8_t* buffer, uint32_t num_bytes, rc_client_t* client);
145
static void ClientServerCall(const rc_api_request_t* request, rc_client_server_callback_t callback, void* callback_data,
146
rc_client_t* client);
147
static rc_api_server_response_t MakeRCAPIServerResponse(s32 status_code, const std::vector<u8>& data);
148
static void WaitForHTTPRequestsWithYield(std::unique_lock<std::recursive_mutex>& lock);
149
150
static void ClientEventHandler(const rc_client_event_t* event, rc_client_t* client);
151
static void HandleResetEvent(const rc_client_event_t* event);
152
static void HandleUnlockEvent(const rc_client_event_t* event);
153
static void HandleGameCompleteEvent(const rc_client_event_t* event);
154
static void HandleSubsetCompleteEvent(const rc_client_event_t* event);
155
static void HandleLeaderboardStartedEvent(const rc_client_event_t* event);
156
static void HandleLeaderboardFailedEvent(const rc_client_event_t* event);
157
static void HandleLeaderboardSubmittedEvent(const rc_client_event_t* event);
158
static void HandleLeaderboardScoreboardEvent(const rc_client_event_t* event);
159
static void HandleLeaderboardTrackerShowEvent(const rc_client_event_t* event);
160
static void HandleLeaderboardTrackerHideEvent(const rc_client_event_t* event);
161
static void HandleLeaderboardTrackerUpdateEvent(const rc_client_event_t* event);
162
static void HandleAchievementChallengeIndicatorShowEvent(const rc_client_event_t* event);
163
static void HandleAchievementChallengeIndicatorHideEvent(const rc_client_event_t* event);
164
static void HandleAchievementProgressIndicatorShowEvent(const rc_client_event_t* event);
165
static void HandleAchievementProgressIndicatorHideEvent(const rc_client_event_t* event);
166
static void HandleAchievementProgressIndicatorUpdateEvent(const rc_client_event_t* event);
167
static void HandleServerErrorEvent(const rc_client_event_t* event);
168
static void HandleServerDisconnectedEvent(const rc_client_event_t* event);
169
static void HandleServerReconnectedEvent(const rc_client_event_t* event);
170
171
static void ClientLoginWithTokenCallback(int result, const char* error_message, rc_client_t* client, void* userdata);
172
static void ClientLoginWithPasswordCallback(int result, const char* error_message, rc_client_t* client, void* userdata);
173
static void FetchGameTitlesCallback(int result, const char* error_message, rc_client_game_title_list_t* list,
174
rc_client_t* client, void* userdata);
175
static void ClientLoadGameCallback(int result, const char* error_message, rc_client_t* client, void* userdata);
176
177
static void DisplayHardcoreDeferredMessage();
178
static void DisplayAchievementSummary();
179
static void UpdateRichPresence(std::unique_lock<std::recursive_mutex>& lock);
180
181
static std::string GetHashDatabasePath();
182
static std::string GetProgressDatabasePath();
183
static void PreloadHashDatabase();
184
static bool LoadHashDatabase(const std::string& path, Error* error);
185
static bool CreateHashDatabaseFromSeedDatabase(const std::string& path, Error* error);
186
static void BeginRefreshHashDatabase();
187
static void FinishRefreshHashDatabase();
188
static void CancelHashDatabaseRequests();
189
190
static void FetchHashLibraryCallback(int result, const char* error_message, rc_client_hash_library_t* list,
191
rc_client_t* client, void* callback_userdata);
192
static void FetchAllProgressCallback(int result, const char* error_message, rc_client_all_user_progress_t* list,
193
rc_client_t* client, void* callback_userdata);
194
static void RefreshAllProgressCallback(int result, const char* error_message, rc_client_all_user_progress_t* list,
195
rc_client_t* client, void* callback_userdata);
196
197
static void BuildHashDatabase(const rc_client_hash_library_t* hashlib, const rc_client_all_user_progress_t* allprog);
198
static bool SortAndSaveHashDatabase(Error* error);
199
200
static FileSystem::ManagedCFilePtr OpenProgressDatabase(bool for_write, bool truncate, Error* error);
201
static void BuildProgressDatabase(const rc_client_all_user_progress_t* allprog);
202
static void UpdateProgressDatabase();
203
static void ClearProgressDatabase();
204
205
#ifdef RC_CLIENT_SUPPORTS_RAINTEGRATION
206
207
static void BeginLoadRAIntegration();
208
static void UnloadRAIntegration();
209
210
#endif
211
212
namespace {
213
214
struct State
215
{
216
rc_client_t* client = nullptr;
217
bool has_achievements = false;
218
bool has_leaderboards = false;
219
bool has_rich_presence = false;
220
bool reload_game_on_reset = false;
221
bool hashdb_loaded = false;
222
223
std::recursive_mutex mutex; // large
224
225
std::string user_badge_path;
226
227
std::string rich_presence_string;
228
Timer::Value rich_presence_poll_time = 0;
229
230
std::vector<LeaderboardTrackerIndicator> active_leaderboard_trackers;
231
std::vector<ActiveChallengeIndicator> active_challenge_indicators;
232
std::optional<AchievementProgressIndicator> active_progress_indicator;
233
234
rc_client_user_game_summary_t game_summary = {};
235
u32 game_id = 0;
236
237
std::unique_ptr<HTTPDownloader> http_downloader;
238
239
std::string game_path;
240
std::string game_title;
241
std::string game_icon;
242
std::string game_icon_url;
243
std::optional<GameHash> game_hash;
244
245
rc_client_async_handle_t* login_request = nullptr;
246
rc_client_async_handle_t* load_game_request = nullptr;
247
248
std::vector<HashDatabaseEntry> hashdb_entries;
249
250
rc_client_async_handle_t* fetch_hash_library_request = nullptr;
251
rc_client_hash_library_t* fetch_hash_library_result = nullptr;
252
rc_client_async_handle_t* fetch_all_progress_request = nullptr;
253
rc_client_all_user_progress_t* fetch_all_progress_result = nullptr;
254
rc_client_async_handle_t* refresh_all_progress_request = nullptr;
255
256
#ifdef RC_CLIENT_SUPPORTS_RAINTEGRATION
257
rc_client_async_handle_t* load_raintegration_request = nullptr;
258
bool using_raintegration = false;
259
bool raintegration_loading = false;
260
#endif
261
};
262
263
} // namespace
264
265
ALIGN_TO_CACHE_LINE static State s_state;
266
267
} // namespace Achievements
268
269
TinyString Achievements::GameHashToString(const std::optional<GameHash>& hash)
270
{
271
TinyString ret;
272
273
// Use a hash that will never match if we removed the disc. See rc_client_begin_change_media().
274
if (!hash.has_value())
275
{
276
ret = "[NO HASH]";
277
}
278
else
279
{
280
ret.format("{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
281
hash.value()[0], hash.value()[1], hash.value()[2], hash.value()[3], hash.value()[4], hash.value()[5],
282
hash.value()[6], hash.value()[7], hash.value()[8], hash.value()[9], hash.value()[10], hash.value()[11],
283
hash.value()[12], hash.value()[13], hash.value()[14], hash.value()[15]);
284
}
285
286
return ret;
287
}
288
289
std::unique_lock<std::recursive_mutex> Achievements::GetLock()
290
{
291
return std::unique_lock(s_state.mutex);
292
}
293
294
rc_client_t* Achievements::GetClient()
295
{
296
return s_state.client;
297
}
298
299
const rc_client_user_game_summary_t& Achievements::GetGameSummary()
300
{
301
return s_state.game_summary;
302
}
303
304
std::vector<Achievements::LeaderboardTrackerIndicator>& Achievements::GetLeaderboardTrackerIndicators()
305
{
306
return s_state.active_leaderboard_trackers;
307
}
308
309
std::vector<Achievements::ActiveChallengeIndicator>& Achievements::GetActiveChallengeIndicators()
310
{
311
return s_state.active_challenge_indicators;
312
}
313
314
std::optional<Achievements::AchievementProgressIndicator>& Achievements::GetActiveProgressIndicator()
315
{
316
return s_state.active_progress_indicator;
317
}
318
319
void Achievements::ReportError(std::string_view sv)
320
{
321
ERROR_LOG(sv);
322
Host::AddIconOSDMessage(OSDMessageType::Error, std::string(), ICON_EMOJI_WARNING, std::string(sv));
323
}
324
325
template<typename... T>
326
void Achievements::ReportFmtError(fmt::format_string<T...> fmt, T&&... args)
327
{
328
TinyString str;
329
fmt::vformat_to(std::back_inserter(str), fmt, fmt::make_format_args(args...));
330
ReportError(str);
331
}
332
333
template<typename... T>
334
void Achievements::ReportRCError(int err, fmt::format_string<T...> fmt, T&&... args)
335
{
336
TinyString str;
337
fmt::vformat_to(std::back_inserter(str), fmt, fmt::make_format_args(args...));
338
str.append_format("{} ({})", rc_error_str(err), err);
339
ReportError(str);
340
}
341
342
std::optional<Achievements::GameHash> Achievements::GetGameHash(CDImage* image)
343
{
344
std::optional<GameHash> ret;
345
346
std::string executable_name;
347
std::vector<u8> executable_data;
348
if (!System::ReadExecutableFromImage(image, &executable_name, &executable_data))
349
return ret;
350
351
return GetGameHash(executable_name, executable_data);
352
}
353
354
std::optional<Achievements::GameHash> Achievements::GetGameHash(const std::string_view executable_name,
355
std::span<const u8> executable_data)
356
{
357
std::optional<GameHash> ret;
358
359
// NOTE: Assumes executable_data is aligned to 4 bytes at least.. it should be.
360
const BIOS::PSEXEHeader* header = reinterpret_cast<const BIOS::PSEXEHeader*>(executable_data.data());
361
if (executable_data.size() < sizeof(BIOS::PSEXEHeader) || !BIOS::IsValidPSExeHeader(*header, executable_data.size()))
362
{
363
ERROR_LOG("PS-EXE header is invalid in '{}' ({} bytes)", executable_name, executable_data.size());
364
return ret;
365
}
366
367
const u32 hash_size = std::min(header->file_size + 2048, static_cast<u32>(executable_data.size()));
368
369
MD5Digest digest;
370
digest.Update(executable_name.data(), static_cast<u32>(executable_name.size()));
371
if (hash_size > 0)
372
digest.Update(executable_data.data(), hash_size);
373
374
ret.emplace();
375
digest.Final(ret.value());
376
377
INFO_COLOR_LOG(StrongOrange, "RA Hash for '{}': {} ({} bytes hashed)", executable_name, GameHashToString(ret),
378
hash_size);
379
380
return ret;
381
}
382
383
std::string Achievements::GetImageURL(const char* image_name, u32 type)
384
{
385
std::string ret;
386
387
const rc_api_fetch_image_request_t image_request = {.image_name = image_name, .image_type = type};
388
rc_api_request_t request;
389
int result = rc_api_init_fetch_image_request(&request, &image_request);
390
if (result == RC_OK)
391
ret = request.url;
392
393
rc_api_destroy_request(&request);
394
return ret;
395
}
396
397
std::string Achievements::GetLocalImagePath(const std::string_view image_name, u32 type)
398
{
399
std::string_view prefix;
400
std::string_view suffix;
401
switch (type)
402
{
403
case RC_IMAGE_TYPE_GAME:
404
prefix = "image"; // https://media.retroachievements.org/Images/{}.png
405
break;
406
407
case RC_IMAGE_TYPE_USER:
408
prefix = "user"; // https://media.retroachievements.org/UserPic/{}.png
409
break;
410
411
case RC_IMAGE_TYPE_ACHIEVEMENT: // https://media.retroachievements.org/Badge/{}.png
412
prefix = "badge";
413
break;
414
415
case RC_IMAGE_TYPE_ACHIEVEMENT_LOCKED:
416
prefix = "badge";
417
suffix = "_lock";
418
break;
419
420
default:
421
prefix = "badge";
422
break;
423
}
424
425
std::string ret;
426
if (!image_name.empty())
427
{
428
ret = fmt::format("{}" FS_OSPATH_SEPARATOR_STR "{}" FS_OSPATH_SEPARATOR_STR "{}_{}{}.png", EmuFolders::Cache,
429
CACHE_SUBDIRECTORY_NAME, prefix, Path::SanitizeFileName(image_name), suffix);
430
}
431
432
return ret;
433
}
434
435
void Achievements::DownloadImage(std::string url, std::string cache_path)
436
{
437
auto callback = [cache_path = std::move(cache_path)](s32 status_code, const Error& error,
438
const std::string& content_type,
439
HTTPDownloader::Request::Data data) mutable {
440
if (status_code != HTTPDownloader::HTTP_STATUS_OK)
441
{
442
ERROR_LOG("Failed to download badge '{}': {}", Path::GetFileName(cache_path), error.GetDescription());
443
return;
444
}
445
446
Error write_error;
447
if (!FileSystem::WriteBinaryFile(cache_path.c_str(), data, &write_error))
448
{
449
ERROR_LOG("Failed to write badge image to '{}': {}", cache_path, write_error.GetDescription());
450
return;
451
}
452
453
VideoThread::RunOnThread(
454
[cache_path = std::move(cache_path)]() { FullscreenUI::InvalidateCachedTexture(cache_path); });
455
};
456
457
s_state.http_downloader->CreateRequest(std::move(url), std::move(callback));
458
}
459
460
bool Achievements::IsActive()
461
{
462
return (s_state.client != nullptr);
463
}
464
465
bool Achievements::IsHardcoreModeActive()
466
{
467
if (!s_state.client)
468
return false;
469
470
const auto lock = GetLock();
471
return rc_client_get_hardcore_enabled(s_state.client);
472
}
473
474
bool Achievements::HasActiveGame()
475
{
476
return s_state.game_id != 0;
477
}
478
479
u32 Achievements::GetGameID()
480
{
481
return s_state.game_id;
482
}
483
484
bool Achievements::HasAchievementsOrLeaderboards()
485
{
486
return s_state.has_achievements || s_state.has_leaderboards;
487
}
488
489
bool Achievements::HasAchievements()
490
{
491
return s_state.has_achievements;
492
}
493
494
bool Achievements::HasLeaderboards()
495
{
496
return s_state.has_leaderboards;
497
}
498
499
bool Achievements::HasRichPresence()
500
{
501
return s_state.has_rich_presence;
502
}
503
504
const std::string& Achievements::GetGameTitle()
505
{
506
return s_state.game_title;
507
}
508
509
const std::string& Achievements::GetGamePath()
510
{
511
return s_state.game_path;
512
}
513
514
const std::string& Achievements::GetGameIconPath()
515
{
516
return s_state.game_icon;
517
}
518
519
const std::string& Achievements::GetGameIconURL()
520
{
521
return s_state.game_icon_url;
522
}
523
524
const std::string& Achievements::GetRichPresenceString()
525
{
526
return s_state.rich_presence_string;
527
}
528
529
bool Achievements::Initialize()
530
{
531
auto lock = GetLock();
532
AssertMsg(g_settings.achievements_enabled, "Achievements are enabled");
533
Assert(!s_state.client && !s_state.http_downloader);
534
535
if (!CreateClient(&s_state.client, &s_state.http_downloader))
536
return false;
537
538
rc_client_set_event_handler(s_state.client, ClientEventHandler);
539
rc_client_set_allow_background_memory_reads(s_state.client, true);
540
541
#ifdef RC_CLIENT_SUPPORTS_RAINTEGRATION
542
if (g_settings.achievements_use_raintegration)
543
BeginLoadRAIntegration();
544
#endif
545
546
// Hardcore starts off. We enable it on first boot.
547
rc_client_set_hardcore_enabled(s_state.client, false);
548
rc_client_set_encore_mode_enabled(s_state.client, g_settings.achievements_encore_mode);
549
rc_client_set_unofficial_enabled(s_state.client, g_settings.achievements_unofficial_test_mode);
550
rc_client_set_spectator_mode_enabled(s_state.client, g_settings.achievements_spectator_mode);
551
552
// We can't do an internal client login while using RAIntegration, since the two will conflict.
553
if (!IsRAIntegrationInitializing())
554
FinishInitialize();
555
556
return true;
557
}
558
559
void Achievements::FinishInitialize()
560
{
561
// Start logging in. This can take a while.
562
TryLoggingInWithToken();
563
564
// Are we running a game?
565
if (System::IsValid())
566
{
567
IdentifyCurrentGame();
568
BeginLoadGame();
569
570
// Hardcore mode isn't enabled when achievements first starts, if a game is already running.
571
if (IsLoggedInOrLoggingIn() && g_settings.achievements_hardcore_mode)
572
DisplayHardcoreDeferredMessage();
573
}
574
575
Host::OnAchievementsActiveChanged(true);
576
}
577
578
bool Achievements::CreateClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http)
579
{
580
rc_client_t* new_client = rc_client_create(ClientReadMemory, ClientServerCall);
581
if (!new_client)
582
{
583
Host::ReportErrorAsync("Achievements Error", "rc_client_create() failed, cannot use achievements");
584
return false;
585
}
586
587
rc_client_enable_logging(
588
new_client, (Log::GetLogLevel() >= Log::Level::Verbose) ? RC_CLIENT_LOG_LEVEL_VERBOSE : RC_CLIENT_LOG_LEVEL_INFO,
589
ClientMessageCallback);
590
591
char rc_client_user_agent[128];
592
rc_client_get_user_agent_clause(new_client, rc_client_user_agent, std::size(rc_client_user_agent));
593
*http = HTTPDownloader::Create(fmt::format("{} {}", Core::GetHTTPUserAgent(), rc_client_user_agent));
594
if (!*http)
595
{
596
Host::ReportErrorAsync("Achievements Error", "Failed to create HTTPDownloader, cannot use achievements");
597
rc_client_destroy(new_client);
598
return false;
599
}
600
601
(*http)->SetTimeout(SERVER_CALL_TIMEOUT);
602
(*http)->SetMaxActiveRequests(MAX_CONCURRENT_SERVER_CALLS);
603
604
rc_client_set_userdata(new_client, http->get());
605
606
// Allow custom host to be overridden through config.
607
if (std::string host = Core::GetBaseStringSettingValue("Cheevos", "Host"); !host.empty())
608
{
609
// drop trailing slash, rc_client appends its own
610
while (!host.empty() && host.back() == '/')
611
host.pop_back();
612
if (!host.empty())
613
{
614
INFO_COLOR_LOG(StrongOrange, "Using alternative host for achievements: {}", host);
615
rc_client_set_host(new_client, host.c_str());
616
}
617
}
618
619
*client = new_client;
620
return true;
621
}
622
623
void Achievements::DestroyClient(rc_client_t** client, std::unique_ptr<HTTPDownloader>* http)
624
{
625
(*http)->WaitForAllRequests();
626
627
rc_client_destroy(*client);
628
*client = nullptr;
629
630
http->reset();
631
}
632
633
bool Achievements::HasSavedCredentials()
634
{
635
const TinyString username = Core::GetTinyStringSettingValue("Cheevos", "Username");
636
const TinyString api_token = Core::GetTinyStringSettingValue("Cheevos", "Token");
637
return (!username.empty() && !api_token.empty());
638
}
639
640
bool Achievements::TryLoggingInWithToken()
641
{
642
const TinyString username = Core::GetTinyStringSettingValue("Cheevos", "Username");
643
const TinyString api_token = Core::GetTinyStringSettingValue("Cheevos", "Token");
644
if (username.empty() || api_token.empty())
645
return false;
646
647
INFO_LOG("Attempting token login with user '{}'...", username);
648
649
// If we can't decrypt the token, it was an old config and we need to re-login.
650
if (const TinyString decrypted_api_token = DecryptLoginToken(api_token, username); !decrypted_api_token.empty())
651
{
652
s_state.login_request = rc_client_begin_login_with_token(
653
s_state.client, username.c_str(), decrypted_api_token.c_str(), ClientLoginWithTokenCallback, nullptr);
654
if (!s_state.login_request)
655
{
656
WARNING_LOG("Creating login request failed.");
657
return false;
658
}
659
660
return true;
661
}
662
else
663
{
664
WARNING_LOG("Invalid encrypted login token, requesting a new one.");
665
Host::OnAchievementsLoginRequested(LoginRequestReason::TokenInvalid);
666
return false;
667
}
668
}
669
670
void Achievements::UpdateSettings(const Settings& old_config)
671
{
672
if (!g_settings.achievements_enabled)
673
{
674
// we're done here
675
Shutdown();
676
return;
677
}
678
679
if (!IsActive())
680
{
681
// we just got enabled
682
Initialize();
683
return;
684
}
685
686
#ifdef RC_CLIENT_SUPPORTS_RAINTEGRATION
687
if (g_settings.achievements_use_raintegration != old_config.achievements_use_raintegration)
688
{
689
// RAIntegration requires a full client reload?
690
Shutdown();
691
Initialize();
692
return;
693
}
694
#endif
695
696
if (g_settings.achievements_hardcore_mode != old_config.achievements_hardcore_mode)
697
{
698
// Enables have to wait for reset, disables can go through immediately.
699
if (!g_settings.achievements_hardcore_mode)
700
DisableHardcoreMode(true, true);
701
}
702
703
auto lock = GetLock();
704
const bool encore_mode_changed = (g_settings.achievements_encore_mode != old_config.achievements_encore_mode);
705
const bool spectator_mode_changed =
706
(g_settings.achievements_spectator_mode != old_config.achievements_spectator_mode);
707
const bool unofficial_test_mode_changed =
708
(g_settings.achievements_unofficial_test_mode != old_config.achievements_unofficial_test_mode);
709
710
if (encore_mode_changed)
711
rc_client_set_encore_mode_enabled(s_state.client, g_settings.achievements_encore_mode);
712
if (spectator_mode_changed)
713
rc_client_set_spectator_mode_enabled(s_state.client, g_settings.achievements_spectator_mode);
714
if (unofficial_test_mode_changed)
715
rc_client_set_unofficial_enabled(s_state.client, g_settings.achievements_unofficial_test_mode);
716
717
// If a game is active and these settings changed, reload the game to apply them.
718
// Just unload and reload without destroying the client to preserve hardcore mode.
719
if (HasActiveGame() && (encore_mode_changed || spectator_mode_changed || unofficial_test_mode_changed))
720
{
721
// Save and restore state to preserve progress.
722
const DynamicHeapArray<u8> state_data = SaveStateToBuffer();
723
ClearGameInfo();
724
BeginLoadGame();
725
LoadStateFromBuffer(state_data.cspan(), lock);
726
return;
727
}
728
729
if (!g_settings.achievements_leaderboard_trackers)
730
s_state.active_leaderboard_trackers.clear();
731
732
if (!g_settings.achievements_progress_indicators)
733
s_state.active_progress_indicator.reset();
734
}
735
736
void Achievements::Shutdown()
737
{
738
if (!IsActive())
739
return;
740
741
auto lock = GetLock();
742
Assert(s_state.client && s_state.http_downloader);
743
744
ClearGameInfo();
745
ClearGameHash();
746
DisableHardcoreMode(false, false);
747
CancelHashDatabaseRequests();
748
749
if (s_state.login_request)
750
{
751
rc_client_abort_async(s_state.client, s_state.login_request);
752
s_state.login_request = nullptr;
753
}
754
755
#ifdef RC_CLIENT_SUPPORTS_RAINTEGRATION
756
if (s_state.using_raintegration)
757
{
758
UnloadRAIntegration();
759
return;
760
}
761
#endif
762
763
DestroyClient(&s_state.client, &s_state.http_downloader);
764
Host::OnAchievementsActiveChanged(false);
765
}
766
767
void Achievements::ClientMessageCallback(const char* message, const rc_client_t* client)
768
{
769
DEV_LOG(message);
770
}
771
772
uint32_t Achievements::ClientReadMemory(uint32_t address, uint8_t* buffer, uint32_t num_bytes, rc_client_t* client)
773
{
774
if ((address + num_bytes) > 0x200400U) [[unlikely]]
775
return 0;
776
777
const u8* src = (address >= 0x200000U) ? CPU::g_state.scratchpad.data() : Bus::g_ram;
778
const u32 offset = (address & Bus::RAM_2MB_MASK); // size guarded by check above
779
780
switch (num_bytes)
781
{
782
case 1:
783
std::memcpy(buffer, &src[offset], 1);
784
break;
785
case 2:
786
std::memcpy(buffer, &src[offset], 2);
787
break;
788
case 4:
789
std::memcpy(buffer, &src[offset], 4);
790
break;
791
default:
792
[[unlikely]] std::memcpy(buffer, &src[offset], num_bytes);
793
break;
794
}
795
796
return num_bytes;
797
}
798
799
void Achievements::ClientServerCall(const rc_api_request_t* request, rc_client_server_callback_t callback,
800
void* callback_data, rc_client_t* client)
801
{
802
HTTPDownloader::Request::Callback hd_callback = [callback, callback_data](s32 status_code, const Error& error,
803
const std::string& content_type,
804
HTTPDownloader::Request::Data data) {
805
if (status_code != HTTPDownloader::HTTP_STATUS_OK)
806
ERROR_LOG("Server call failed: {}", error.GetDescription());
807
808
const rc_api_server_response_t rr = MakeRCAPIServerResponse(status_code, data);
809
callback(&rr, callback_data);
810
};
811
812
HTTPDownloader* http = static_cast<HTTPDownloader*>(rc_client_get_userdata(client));
813
814
// TODO: Content-type for post
815
if (request->post_data)
816
{
817
// const auto pd = std::string_view(request->post_data);
818
// Log_DevFmt("Server POST: {}", pd.substr(0, std::min<size_t>(pd.length(), 10)));
819
http->CreatePostRequest(request->url, request->post_data, std::move(hd_callback));
820
}
821
else
822
{
823
http->CreateRequest(request->url, std::move(hd_callback));
824
}
825
}
826
827
rc_api_server_response_t Achievements::MakeRCAPIServerResponse(s32 status_code, const std::vector<u8>& data)
828
{
829
return rc_api_server_response_t{
830
.body = data.empty() ? nullptr : reinterpret_cast<const char*>(data.data()),
831
.body_length = data.size(),
832
.http_status_code = (status_code <= 0) ? (status_code == HTTPDownloader::HTTP_STATUS_CANCELLED ?
833
RC_API_SERVER_RESPONSE_CLIENT_ERROR :
834
RC_API_SERVER_RESPONSE_RETRYABLE_CLIENT_ERROR) :
835
status_code,
836
};
837
}
838
839
void Achievements::WaitForHTTPRequestsWithYield(std::unique_lock<std::recursive_mutex>& lock)
840
{
841
DebugAssert(s_state.http_downloader);
842
s_state.http_downloader->WaitForAllRequestsWithYield([&lock]() { lock.unlock(); }, [&lock]() { lock.lock(); });
843
}
844
845
void Achievements::IdleUpdate()
846
{
847
if (!IsActive())
848
return;
849
850
const auto lock = GetLock();
851
852
s_state.http_downloader->PollRequests();
853
rc_client_idle(s_state.client);
854
}
855
856
bool Achievements::NeedsIdleUpdate()
857
{
858
if (!IsActive())
859
return false;
860
861
const auto lock = GetLock();
862
return (s_state.http_downloader && s_state.http_downloader->HasAnyRequests());
863
}
864
865
void Achievements::FrameUpdate()
866
{
867
if (!IsActive())
868
return;
869
870
auto lock = GetLock();
871
872
s_state.http_downloader->PollRequests();
873
rc_client_do_frame(s_state.client);
874
875
UpdateRichPresence(lock);
876
}
877
878
void Achievements::ClientEventHandler(const rc_client_event_t* event, rc_client_t* client)
879
{
880
switch (event->type)
881
{
882
case RC_CLIENT_EVENT_RESET:
883
HandleResetEvent(event);
884
break;
885
886
case RC_CLIENT_EVENT_ACHIEVEMENT_TRIGGERED:
887
HandleUnlockEvent(event);
888
break;
889
890
case RC_CLIENT_EVENT_GAME_COMPLETED:
891
HandleGameCompleteEvent(event);
892
break;
893
894
case RC_CLIENT_EVENT_SUBSET_COMPLETED:
895
HandleSubsetCompleteEvent(event);
896
break;
897
898
case RC_CLIENT_EVENT_LEADERBOARD_STARTED:
899
HandleLeaderboardStartedEvent(event);
900
break;
901
902
case RC_CLIENT_EVENT_LEADERBOARD_FAILED:
903
HandleLeaderboardFailedEvent(event);
904
break;
905
906
case RC_CLIENT_EVENT_LEADERBOARD_SUBMITTED:
907
HandleLeaderboardSubmittedEvent(event);
908
break;
909
910
case RC_CLIENT_EVENT_LEADERBOARD_SCOREBOARD:
911
HandleLeaderboardScoreboardEvent(event);
912
break;
913
914
case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_SHOW:
915
HandleLeaderboardTrackerShowEvent(event);
916
break;
917
918
case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_HIDE:
919
HandleLeaderboardTrackerHideEvent(event);
920
break;
921
922
case RC_CLIENT_EVENT_LEADERBOARD_TRACKER_UPDATE:
923
HandleLeaderboardTrackerUpdateEvent(event);
924
break;
925
926
case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_SHOW:
927
HandleAchievementChallengeIndicatorShowEvent(event);
928
break;
929
930
case RC_CLIENT_EVENT_ACHIEVEMENT_CHALLENGE_INDICATOR_HIDE:
931
HandleAchievementChallengeIndicatorHideEvent(event);
932
break;
933
934
case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_SHOW:
935
HandleAchievementProgressIndicatorShowEvent(event);
936
break;
937
938
case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_HIDE:
939
HandleAchievementProgressIndicatorHideEvent(event);
940
break;
941
942
case RC_CLIENT_EVENT_ACHIEVEMENT_PROGRESS_INDICATOR_UPDATE:
943
HandleAchievementProgressIndicatorUpdateEvent(event);
944
break;
945
946
case RC_CLIENT_EVENT_SERVER_ERROR:
947
HandleServerErrorEvent(event);
948
break;
949
950
case RC_CLIENT_EVENT_DISCONNECTED:
951
HandleServerDisconnectedEvent(event);
952
break;
953
954
case RC_CLIENT_EVENT_RECONNECTED:
955
HandleServerReconnectedEvent(event);
956
break;
957
958
default:
959
[[unlikely]] ERROR_LOG("Unhandled event: {}", event->type);
960
break;
961
}
962
}
963
964
void Achievements::UpdateGameSummary(bool update_progress_database)
965
{
966
rc_client_get_user_game_summary(s_state.client, &s_state.game_summary);
967
968
if (update_progress_database)
969
UpdateProgressDatabase();
970
}
971
972
void Achievements::UpdateRichPresence(std::unique_lock<std::recursive_mutex>& lock)
973
{
974
if (!s_state.has_rich_presence)
975
return;
976
977
// Limit rich presence updates to once per second, since it could change per frame.
978
const Timer::Value now = Timer::GetCurrentValue();
979
if (Timer::ConvertValueToSeconds(now - s_state.rich_presence_poll_time) < 1)
980
return;
981
982
s_state.rich_presence_poll_time = now;
983
984
char buffer[512];
985
const size_t res = rc_client_get_rich_presence_message(s_state.client, buffer, std::size(buffer));
986
const std::string_view sv(buffer, res);
987
if (s_state.rich_presence_string == sv)
988
return;
989
990
s_state.rich_presence_string.assign(sv);
991
992
INFO_LOG("Rich presence updated: {}", s_state.rich_presence_string);
993
994
lock.unlock();
995
System::UpdateRichPresence(false);
996
lock.lock();
997
}
998
999
void Achievements::OnSystemStarting(CDImage* image, bool disable_hardcore_mode)
1000
{
1001
std::unique_lock lock(s_state.mutex);
1002
1003
if (!IsActive() || IsRAIntegrationInitializing())
1004
return;
1005
1006
// if we're not logged in, and there's no login request, retry logging in
1007
// this'll happen if we had no network connection on startup, but gained it before starting a game.
1008
if (!IsLoggedInOrLoggingIn())
1009
{
1010
WARNING_LOG("Not logged in on game booting, trying again.");
1011
TryLoggingInWithToken();
1012
}
1013
1014
// HC should have been disabled, we're now enabling it
1015
// RAIntegration can enable hardcode mode outside of us, so we need to double-check
1016
if (rc_client_get_hardcore_enabled(s_state.client))
1017
{
1018
WARNING_LOG("Hardcore mode was enabled on system starting.");
1019
OnHardcoreModeChanged(true, false, false);
1020
}
1021
else
1022
{
1023
// only enable hardcore mode if we're logged in, or waiting for a login response
1024
if (image && !disable_hardcore_mode && g_settings.achievements_hardcore_mode && IsLoggedInOrLoggingIn())
1025
EnableHardcoreMode(false, false);
1026
}
1027
1028
// now we can finally identify the game
1029
IdentifyGame(image);
1030
BeginLoadGame();
1031
}
1032
1033
void Achievements::OnSystemDestroyed()
1034
{
1035
const auto lock = GetLock();
1036
ClearGameInfo();
1037
ClearGameHash();
1038
DisableHardcoreMode(false, false);
1039
}
1040
1041
void Achievements::OnSystemReset()
1042
{
1043
const auto lock = GetLock();
1044
if (!IsActive() || IsRAIntegrationInitializing())
1045
return;
1046
1047
// Do we need to enable hardcore mode?
1048
if (System::IsValid() && g_settings.achievements_hardcore_mode && !rc_client_get_hardcore_enabled(s_state.client) &&
1049
(s_state.load_game_request || s_state.has_achievements || s_state.has_leaderboards))
1050
{
1051
// This will raise the silly reset event, but we can safely ignore that since we're immediately resetting the client
1052
DEV_LOG("Enabling hardcore mode after reset");
1053
EnableHardcoreMode(true, true);
1054
}
1055
1056
DEV_LOG("Reset client");
1057
rc_client_reset(s_state.client);
1058
1059
// Was there a pending disc change?
1060
// Ensure the new game is fully loaded after the reset, and not just treated as a disc swap.
1061
if (s_state.reload_game_on_reset)
1062
{
1063
DEV_LOG("Reloading game after reset due to disc change");
1064
ClearGameInfo();
1065
BeginLoadGame();
1066
}
1067
}
1068
1069
void Achievements::GameChanged(CDImage* image)
1070
{
1071
std::unique_lock lock(s_state.mutex);
1072
1073
if (!IsActive() || IsRAIntegrationInitializing())
1074
return;
1075
1076
// disc changed?
1077
if (!IdentifyGame(image))
1078
return;
1079
1080
// cancel previous requests
1081
if (s_state.load_game_request)
1082
{
1083
rc_client_abort_async(s_state.client, s_state.load_game_request);
1084
s_state.load_game_request = nullptr;
1085
}
1086
1087
s_state.load_game_request =
1088
rc_client_begin_change_media_from_hash(s_state.client, GameHashToString(s_state.game_hash).c_str(),
1089
ClientLoadGameCallback, reinterpret_cast<void*>(static_cast<uintptr_t>(1)));
1090
1091
// Flag the disc change. That way we reload the game on reset instead of treating it as a swap.
1092
s_state.reload_game_on_reset = true;
1093
}
1094
1095
bool Achievements::IdentifyGame(CDImage* image)
1096
{
1097
std::optional<GameHash> game_hash;
1098
if (image)
1099
{
1100
game_hash = GetGameHash(image);
1101
1102
if (!game_hash.has_value() && !rc_client_is_game_loaded(s_state.client))
1103
{
1104
// If we are starting with this game and it's bad, notify the user that this is why.
1105
Host::AddIconOSDMessage(OSDMessageType::Error, "AchievementsHashFailed", ICON_EMOJI_WARNING,
1106
TRANSLATE_STR("Achievements", "Failed to read executable from disc."),
1107
TRANSLATE_STR("Achievements", "Achievements have been disabled."));
1108
}
1109
}
1110
1111
s_state.game_path = image ? image->GetPath() : std::string();
1112
1113
if (s_state.game_hash == game_hash)
1114
{
1115
// only the path has changed - different format/save state/etc.
1116
INFO_LOG("Detected path change to '{}'", s_state.game_path);
1117
return false;
1118
}
1119
1120
s_state.game_hash = game_hash;
1121
return true;
1122
}
1123
1124
bool Achievements::IdentifyCurrentGame()
1125
{
1126
DebugAssert(System::IsValid());
1127
1128
// this crap is only needed because we can't grab the image from the reader...
1129
std::unique_ptr<CDImage> temp_image;
1130
if (const std::string& disc_path = System::GetGamePath(); !disc_path.empty())
1131
{
1132
Error error;
1133
temp_image = CDImage::Open(disc_path.c_str(), g_settings.cdrom_load_image_patches, &error);
1134
if (!temp_image)
1135
ERROR_LOG("Failed to open disc for late game identification: {}", error.GetDescription());
1136
}
1137
1138
return IdentifyGame(temp_image.get());
1139
}
1140
1141
void Achievements::BeginLoadGame()
1142
{
1143
if (!s_state.game_hash.has_value())
1144
{
1145
// no need to go through ClientLoadGameCallback, just bail out straight away
1146
DisableHardcoreMode(false, false);
1147
return;
1148
}
1149
1150
s_state.load_game_request = rc_client_begin_load_game(s_state.client, GameHashToString(s_state.game_hash).c_str(),
1151
ClientLoadGameCallback, nullptr);
1152
}
1153
1154
void Achievements::ClientLoadGameCallback(int result, const char* error_message, rc_client_t* client, void* userdata)
1155
{
1156
const bool was_disc_change = (userdata != nullptr);
1157
1158
s_state.load_game_request = nullptr;
1159
1160
if (result == RC_NO_GAME_LOADED)
1161
{
1162
// Unknown game.
1163
INFO_LOG("Unknown game '{}', disabling achievements.", GameHashToString(s_state.game_hash));
1164
if (was_disc_change)
1165
ClearGameInfo();
1166
1167
DisableHardcoreMode(false, false);
1168
return;
1169
}
1170
else if (result == RC_LOGIN_REQUIRED)
1171
{
1172
// We would've asked to re-authenticate, so leave HC on for now.
1173
// Once we've done so, we'll reload the game.
1174
if (!HasSavedCredentials())
1175
{
1176
DisableHardcoreMode(false, false);
1177
return;
1178
}
1179
1180
return;
1181
}
1182
else if (result == RC_HARDCORE_DISABLED)
1183
{
1184
if (error_message)
1185
ReportError(error_message);
1186
1187
OnHardcoreModeChanged(false, true, false);
1188
return;
1189
}
1190
else if (result != RC_OK)
1191
{
1192
ReportFmtError("Loading game failed: {}", error_message);
1193
if (was_disc_change)
1194
ClearGameInfo();
1195
1196
DisableHardcoreMode(false, false);
1197
return;
1198
}
1199
1200
const rc_client_game_t* info = rc_client_get_game_info(s_state.client);
1201
if (!info)
1202
{
1203
ReportError("rc_client_get_game_info() returned NULL");
1204
if (was_disc_change)
1205
ClearGameInfo();
1206
1207
DisableHardcoreMode(false, false);
1208
return;
1209
}
1210
1211
const bool has_achievements = rc_client_has_achievements(client);
1212
bool has_leaderboards = rc_client_has_leaderboards(client);
1213
INFO_LOG("Game loaded: '{}' (ID: {}, Achievements: {}, Leaderboards: {})", info->title, info->id,
1214
has_achievements ? "Yes" : "No", has_leaderboards ? "Yes" : "No");
1215
1216
// Only display summary if the game title has changed across discs.
1217
const bool display_summary = (s_state.game_id != info->id || s_state.game_title != info->title);
1218
1219
// If the game has an RA entry but no achievements or leaderboards, we should not enforce hardcore mode.
1220
if (!has_achievements && !has_leaderboards)
1221
{
1222
WARNING_LOG("Game '{}' has no achievements or leaderboards, disabling hardcore mode.", info->title);
1223
DisableHardcoreMode(false, false);
1224
}
1225
1226
s_state.game_id = info->id;
1227
s_state.game_title = info->title;
1228
s_state.has_achievements = has_achievements;
1229
s_state.has_leaderboards = has_leaderboards;
1230
s_state.has_rich_presence = rc_client_has_rich_presence(client);
1231
s_state.game_icon_url =
1232
info->badge_url ? std::string(info->badge_url) : GetImageURL(info->badge_name, RC_IMAGE_TYPE_GAME);
1233
s_state.game_icon = GetLocalImagePath(info->badge_name, RC_IMAGE_TYPE_GAME);
1234
if (!s_state.game_icon.empty() && !s_state.game_icon_url.empty())
1235
{
1236
if (!FileSystem::FileExists(s_state.game_icon.c_str()))
1237
DownloadImage(s_state.game_icon_url, s_state.game_icon);
1238
1239
GameList::UpdateAchievementBadgeName(info->id, info->badge_name);
1240
}
1241
1242
// update progress database on first load, in case it was played on another PC
1243
UpdateGameSummary(true);
1244
1245
// needed for notifications
1246
SoundEffectManager::EnsureInitialized();
1247
1248
if (display_summary)
1249
DisplayAchievementSummary();
1250
}
1251
1252
void Achievements::ClearGameInfo()
1253
{
1254
FullscreenUI::ClearAchievementsState();
1255
1256
if (s_state.load_game_request)
1257
{
1258
rc_client_abort_async(s_state.client, s_state.load_game_request);
1259
s_state.load_game_request = nullptr;
1260
}
1261
rc_client_unload_game(s_state.client);
1262
1263
s_state.active_leaderboard_trackers = {};
1264
s_state.active_challenge_indicators = {};
1265
s_state.active_progress_indicator.reset();
1266
s_state.game_id = 0;
1267
s_state.game_title = {};
1268
s_state.game_icon = {};
1269
s_state.game_icon_url = {};
1270
s_state.reload_game_on_reset = false;
1271
s_state.has_achievements = false;
1272
s_state.has_leaderboards = false;
1273
s_state.has_rich_presence = false;
1274
s_state.rich_presence_string = {};
1275
s_state.game_summary = {};
1276
}
1277
1278
void Achievements::ClearGameHash()
1279
{
1280
s_state.game_path = {};
1281
s_state.game_hash.reset();
1282
}
1283
1284
void Achievements::DisplayAchievementSummary()
1285
{
1286
if (g_settings.achievements_notifications)
1287
{
1288
SmallString summary;
1289
if (s_state.game_summary.num_core_achievements > 0)
1290
{
1291
summary.format(
1292
TRANSLATE_FS("Achievements", "{0}, {1}."),
1293
SmallString::from_format(TRANSLATE_PLURAL_FS("Achievements", "You have unlocked {} of %n achievements",
1294
"Achievement popup", s_state.game_summary.num_core_achievements),
1295
s_state.game_summary.num_unlocked_achievements),
1296
SmallString::from_format(TRANSLATE_PLURAL_FS("Achievements", "and earned {} of %n points", "Achievement popup",
1297
s_state.game_summary.points_core),
1298
s_state.game_summary.points_unlocked));
1299
1300
summary.append('\n');
1301
if (IsHardcoreModeActive())
1302
{
1303
summary.append(
1304
TRANSLATE_SV("Achievements", "Hardcore mode is enabled. Cheats and save states are unavailable."));
1305
}
1306
else
1307
{
1308
summary.append(TRANSLATE_SV("Achievements", "Hardcore mode is disabled. Leaderboards will not be tracked."));
1309
}
1310
}
1311
else
1312
{
1313
summary.assign(TRANSLATE_SV("Achievements", "This game has no achievements."));
1314
}
1315
1316
FullscreenUI::AddAchievementNotification("AchievementsSummary",
1317
IsHardcoreModeActive() ? ACHIEVEMENT_SUMMARY_NOTIFICATION_TIME_HC :
1318
ACHIEVEMENT_SUMMARY_NOTIFICATION_TIME,
1319
s_state.game_icon, s_state.game_title, std::string(summary),
1320
RA_LOGO_ICON_NAME, FullscreenUI::AchievementNotificationNoteType::Image);
1321
1322
if (s_state.game_summary.num_unsupported_achievements > 0)
1323
{
1324
Host::AddIconOSDMessage(OSDMessageType::Error, "UnsupportedAchievements", ICON_EMOJI_WARNING,
1325
TRANSLATE_STR("Achievements", "Unsupported Achievements"),
1326
TRANSLATE_PLURAL_STR("Achievements", "%n achievements are not supported by DuckStation.",
1327
"Achievement popup",
1328
s_state.game_summary.num_unsupported_achievements));
1329
}
1330
}
1331
1332
// Technically not going through the resource API, but since we're passing this to something else, we can't.
1333
if (g_settings.achievements_sound_effects)
1334
SoundEffectManager::EnqueueSoundEffect(INFO_SOUND_NAME);
1335
}
1336
1337
void Achievements::DisplayHardcoreDeferredMessage()
1338
{
1339
if (g_settings.achievements_hardcore_mode && System::IsValid())
1340
{
1341
VideoThread::RunOnThread([]() {
1342
if (FullscreenUI::HasActiveWindow())
1343
{
1344
FullscreenUI::ShowToast(OSDMessageType::Info, {},
1345
TRANSLATE_STR("Achievements", "Hardcore mode will be enabled on game restart."));
1346
}
1347
else
1348
{
1349
Host::AddIconOSDMessage(OSDMessageType::Info, "AchievementsHardcoreDeferred", ICON_EMOJI_TROPHY,
1350
TRANSLATE_STR("Achievements", "Hardcore mode will be enabled on game restart."));
1351
}
1352
});
1353
}
1354
}
1355
1356
void Achievements::HandleResetEvent(const rc_client_event_t* event)
1357
{
1358
WARNING_LOG("Ignoring RC_CLIENT_EVENT_RESET.");
1359
}
1360
1361
void Achievements::HandleUnlockEvent(const rc_client_event_t* event)
1362
{
1363
const rc_client_achievement_t* cheevo = event->achievement;
1364
DebugAssert(cheevo);
1365
1366
INFO_LOG("Achievement {} ({}) for game {} unlocked", cheevo->id, cheevo->title, s_state.game_id);
1367
UpdateGameSummary(true);
1368
1369
if (g_settings.achievements_notifications)
1370
{
1371
std::string title;
1372
if (cheevo->category == RC_CLIENT_ACHIEVEMENT_CATEGORY_UNOFFICIAL)
1373
title = fmt::format(TRANSLATE_FS("Achievements", "{} (Unofficial)"), cheevo->title);
1374
else
1375
title = cheevo->title;
1376
1377
std::string note;
1378
if (cheevo->points > 0)
1379
note = fmt::format(ICON_EMOJI_TROPHY " {}", cheevo->points);
1380
1381
FullscreenUI::AddAchievementNotification(
1382
fmt::format("achievement_unlock_{}", cheevo->id),
1383
static_cast<float>(g_settings.achievements_notification_duration), GetAchievementBadgePath(cheevo, false),
1384
std::move(title), std::string(cheevo->description), std::move(note),
1385
(cheevo->points > 0) ? FullscreenUI::AchievementNotificationNoteType::Text :
1386
FullscreenUI::AchievementNotificationNoteType::None);
1387
}
1388
1389
if (g_settings.achievements_sound_effects)
1390
SoundEffectManager::EnqueueSoundEffect(UNLOCK_SOUND_NAME);
1391
}
1392
1393
void Achievements::HandleGameCompleteEvent(const rc_client_event_t* event)
1394
{
1395
INFO_LOG("Game {} ({}) complete", s_state.game_id, s_state.game_title);
1396
UpdateGameSummary(false);
1397
1398
if (g_settings.achievements_notifications)
1399
{
1400
std::string message = fmt::format(
1401
TRANSLATE_FS("Achievements", "Game complete.\n{0} and {1}."),
1402
TRANSLATE_PLURAL_STR("Achievements", "%n achievements", "Mastery popup",
1403
s_state.game_summary.num_unlocked_achievements),
1404
TRANSLATE_PLURAL_STR("Achievements", "%n points", "Achievement points", s_state.game_summary.points_unlocked));
1405
1406
FullscreenUI::AddAchievementNotification("achievement_mastery", GAME_COMPLETE_NOTIFICATION_TIME, s_state.game_icon,
1407
s_state.game_title, std::move(message), ICON_EMOJI_TROPHY,
1408
FullscreenUI::AchievementNotificationNoteType::IconText);
1409
}
1410
}
1411
1412
void Achievements::HandleSubsetCompleteEvent(const rc_client_event_t* event)
1413
{
1414
INFO_LOG("Subset {} ({}) complete", event->subset->id, event->subset->title);
1415
UpdateGameSummary(false);
1416
1417
if (g_settings.achievements_notifications && event->subset->badge_name[0] != '\0')
1418
{
1419
// Need to grab the icon for the subset.
1420
std::string badge_path = GetSubsetBadgePath(event->subset);
1421
1422
std::string message = fmt::format(
1423
TRANSLATE_FS("Achievements", "Subset complete.\n{0} and {1}."),
1424
TRANSLATE_PLURAL_STR("Achievements", "%n achievements", "Mastery popup",
1425
s_state.game_summary.num_unlocked_achievements),
1426
TRANSLATE_PLURAL_STR("Achievements", "%n points", "Achievement points", s_state.game_summary.points_unlocked));
1427
1428
FullscreenUI::AddAchievementNotification(
1429
"achievement_mastery", GAME_COMPLETE_NOTIFICATION_TIME, std::move(badge_path), std::string(event->subset->title),
1430
std::move(message), ICON_EMOJI_CHECKMARK_BUTTON, FullscreenUI::AchievementNotificationNoteType::IconText);
1431
}
1432
}
1433
1434
void Achievements::HandleLeaderboardStartedEvent(const rc_client_event_t* event)
1435
{
1436
DEV_LOG("Leaderboard {} ({}) started", event->leaderboard->id, event->leaderboard->title);
1437
1438
if (g_settings.achievements_leaderboard_notifications)
1439
{
1440
FullscreenUI::AddAchievementNotification(
1441
fmt::format("leaderboard_{}", event->leaderboard->id), LEADERBOARD_STARTED_NOTIFICATION_TIME, s_state.game_icon,
1442
std::string(event->leaderboard->title), TRANSLATE_STR("Achievements", "Leaderboard attempt started."),
1443
ICON_EMOJI_RED_FLAG, FullscreenUI::AchievementNotificationNoteType::IconText);
1444
}
1445
}
1446
1447
void Achievements::HandleLeaderboardFailedEvent(const rc_client_event_t* event)
1448
{
1449
DEV_LOG("Leaderboard {} ({}) failed", event->leaderboard->id, event->leaderboard->title);
1450
1451
if (g_settings.achievements_leaderboard_notifications)
1452
{
1453
FullscreenUI::AddAchievementNotification(
1454
fmt::format("leaderboard_{}", event->leaderboard->id), LEADERBOARD_FAILED_NOTIFICATION_TIME, s_state.game_icon,
1455
std::string(event->leaderboard->title), TRANSLATE_STR("Achievements", "Leaderboard attempt failed."),
1456
ICON_EMOJI_CROSS_MARK_BUTTON, FullscreenUI::AchievementNotificationNoteType::IconText);
1457
}
1458
}
1459
1460
std::string_view Achievements::GetLeaderboardFormatIcon(u32 format)
1461
{
1462
static const char* value_strings[NUM_RC_CLIENT_LEADERBOARD_FORMATS] = {
1463
ICON_EMOJI_CLOCK_FIVE_OCLOCK,
1464
ICON_EMOJI_DIRECT_HIT,
1465
ICON_EMOJI_CLIPBOARD,
1466
};
1467
1468
return value_strings[std::min<u32>(format, std::size(value_strings) - 1)];
1469
}
1470
1471
void Achievements::HandleLeaderboardSubmittedEvent(const rc_client_event_t* event)
1472
{
1473
DEV_LOG("Leaderboard {} ({}) submitted", event->leaderboard->id, event->leaderboard->title);
1474
1475
if (g_settings.achievements_leaderboard_notifications)
1476
{
1477
static const char* value_strings[NUM_RC_CLIENT_LEADERBOARD_FORMATS] = {
1478
TRANSLATE_NOOP("Achievements", "Your Time: {}"),
1479
TRANSLATE_NOOP("Achievements", "Your Score: {}"),
1480
TRANSLATE_NOOP("Achievements", "Your Value: {}"),
1481
};
1482
1483
std::string message =
1484
fmt::format("{} {}", GetLeaderboardFormatIcon(event->leaderboard->format),
1485
TinyString::from_format(
1486
fmt::runtime(Host::TranslateToStringView(
1487
"Achievements",
1488
value_strings[std::min<u8>(event->leaderboard->format, NUM_RC_CLIENT_LEADERBOARD_FORMATS - 1)])),
1489
event->leaderboard->tracker_value ? event->leaderboard->tracker_value : "Unknown"));
1490
1491
FullscreenUI::AddAchievementNotification(
1492
fmt::format("leaderboard_{}", event->leaderboard->id),
1493
static_cast<float>(g_settings.achievements_leaderboard_duration), s_state.game_icon,
1494
std::string(event->leaderboard->title), std::move(message),
1495
g_settings.achievements_spectator_mode ? std::string(ICON_EMOJI_CHART_UPWARDS_TREND) : std::string(),
1496
g_settings.achievements_spectator_mode ? FullscreenUI::AchievementNotificationNoteType::IconText :
1497
FullscreenUI::AchievementNotificationNoteType::Spinner,
1498
LEADERBOARD_NOTIFICATION_MIN_WIDTH);
1499
}
1500
1501
if (g_settings.achievements_sound_effects)
1502
SoundEffectManager::EnqueueSoundEffect(LBSUBMIT_SOUND_NAME);
1503
}
1504
1505
void Achievements::HandleLeaderboardScoreboardEvent(const rc_client_event_t* event)
1506
{
1507
DEV_LOG("Leaderboard {} scoreboard rank {} of {}", event->leaderboard_scoreboard->leaderboard_id,
1508
event->leaderboard_scoreboard->new_rank, event->leaderboard_scoreboard->num_entries);
1509
1510
if (g_settings.achievements_leaderboard_notifications)
1511
{
1512
static const char* value_strings[NUM_RC_CLIENT_LEADERBOARD_FORMATS] = {
1513
TRANSLATE_NOOP("Achievements", "Your Time: {0} (Best: {1})"),
1514
TRANSLATE_NOOP("Achievements", "Your Score: {0} (Best: {1})"),
1515
TRANSLATE_NOOP("Achievements", "Your Value: {0} (Best: {1})"),
1516
};
1517
1518
std::string message = fmt::format(
1519
"{} {}\n" ICON_EMOJI_CHART_UPWARDS_TREND " {}", GetLeaderboardFormatIcon(event->leaderboard->format),
1520
TinyString::from_format(
1521
fmt::runtime(Host::TranslateToStringView(
1522
"Achievements",
1523
value_strings[std::min<u8>(event->leaderboard->format, NUM_RC_CLIENT_LEADERBOARD_FORMATS - 1)])),
1524
event->leaderboard_scoreboard->submitted_score, event->leaderboard_scoreboard->best_score),
1525
TinyString::from_format(TRANSLATE_FS("Achievements", "Leaderboard Position: {0} of {1}"),
1526
event->leaderboard_scoreboard->new_rank, event->leaderboard_scoreboard->num_entries));
1527
1528
FullscreenUI::AddAchievementNotification(
1529
fmt::format("leaderboard_{}", event->leaderboard->id),
1530
static_cast<float>(g_settings.achievements_leaderboard_duration), s_state.game_icon,
1531
std::string(event->leaderboard->title), std::move(message), ICON_EMOJI_CHECKMARK_BUTTON,
1532
FullscreenUI::AchievementNotificationNoteType::IconText, LEADERBOARD_NOTIFICATION_MIN_WIDTH);
1533
}
1534
}
1535
1536
void Achievements::HandleLeaderboardTrackerShowEvent(const rc_client_event_t* event)
1537
{
1538
DEV_LOG("Showing leaderboard tracker: {}: {}", event->leaderboard_tracker->id, event->leaderboard_tracker->display);
1539
1540
if (!g_settings.achievements_leaderboard_trackers)
1541
return;
1542
1543
const u32 id = event->leaderboard_tracker->id;
1544
auto it = std::find_if(s_state.active_leaderboard_trackers.begin(), s_state.active_leaderboard_trackers.end(),
1545
[id](const auto& it) { return it.tracker_id == id; });
1546
if (it != s_state.active_leaderboard_trackers.end())
1547
{
1548
WARNING_LOG("Leaderboard tracker {} already active", id);
1549
it->text = event->leaderboard_tracker->display;
1550
it->active = true;
1551
return;
1552
}
1553
1554
s_state.active_leaderboard_trackers.push_back(LeaderboardTrackerIndicator{
1555
.tracker_id = id,
1556
.text = event->leaderboard_tracker->display,
1557
.time = 0.0f,
1558
.active = true,
1559
});
1560
}
1561
1562
void Achievements::HandleLeaderboardTrackerHideEvent(const rc_client_event_t* event)
1563
{
1564
const u32 id = event->leaderboard_tracker->id;
1565
DEV_LOG("Hiding leaderboard tracker: {}", id);
1566
1567
auto it = std::find_if(s_state.active_leaderboard_trackers.begin(), s_state.active_leaderboard_trackers.end(),
1568
[id](const auto& it) { return it.tracker_id == id; });
1569
if (it == s_state.active_leaderboard_trackers.end())
1570
return;
1571
1572
it->active = false;
1573
it->time = std::min(it->time, INDICATOR_FADE_OUT_TIME);
1574
}
1575
1576
void Achievements::HandleLeaderboardTrackerUpdateEvent(const rc_client_event_t* event)
1577
{
1578
const u32 id = event->leaderboard_tracker->id;
1579
DEV_LOG("Updating leaderboard tracker: {}: {}", id, event->leaderboard_tracker->display);
1580
1581
auto it = std::find_if(s_state.active_leaderboard_trackers.begin(), s_state.active_leaderboard_trackers.end(),
1582
[id](const auto& it) { return it.tracker_id == id; });
1583
if (it == s_state.active_leaderboard_trackers.end())
1584
return;
1585
1586
it->text = event->leaderboard_tracker->display;
1587
it->active = true;
1588
}
1589
1590
void Achievements::HandleAchievementChallengeIndicatorShowEvent(const rc_client_event_t* event)
1591
{
1592
if (const auto it =
1593
std::find_if(s_state.active_challenge_indicators.begin(), s_state.active_challenge_indicators.end(),
1594
[event](const ActiveChallengeIndicator& it) { return it.achievement == event->achievement; });
1595
it != s_state.active_challenge_indicators.end())
1596
{
1597
it->active = true;
1598
return;
1599
}
1600
1601
std::string badge_path = GetAchievementBadgePath(event->achievement, false);
1602
1603
// we still track these even if the option is disabled, so that they can be displayed in the pause menu
1604
if (g_settings.achievements_challenge_indicator_mode == AchievementChallengeIndicatorMode::Notification)
1605
{
1606
FullscreenUI::AddAchievementNotification(
1607
fmt::format("AchievementChallenge{}", event->achievement->id), CHALLENGE_STARTED_NOTIFICATION_TIME, badge_path,
1608
fmt::format(TRANSLATE_FS("Achievements", "Challenge Started: {}"),
1609
event->achievement->title ? event->achievement->title : ""),
1610
fmt::format(ICON_EMOJI_DIRECT_HIT " {}", event->achievement->description ? event->achievement->description : ""),
1611
{}, FullscreenUI::AchievementNotificationNoteType::None, 0, true);
1612
}
1613
1614
s_state.active_challenge_indicators.push_back(
1615
ActiveChallengeIndicator{.achievement = event->achievement,
1616
.badge_path = std::move(badge_path),
1617
.time_remaining = LEADERBOARD_STARTED_NOTIFICATION_TIME,
1618
.opacity = 0.0f,
1619
.active = true});
1620
1621
DEV_LOG("Show challenge indicator for {} ({})", event->achievement->id, event->achievement->title);
1622
}
1623
1624
void Achievements::HandleAchievementChallengeIndicatorHideEvent(const rc_client_event_t* event)
1625
{
1626
auto it = std::find_if(s_state.active_challenge_indicators.begin(), s_state.active_challenge_indicators.end(),
1627
[event](const ActiveChallengeIndicator& it) { return it.achievement == event->achievement; });
1628
if (it == s_state.active_challenge_indicators.end())
1629
return;
1630
1631
DEV_LOG("Hide challenge indicator for {} ({})", event->achievement->id, event->achievement->title);
1632
1633
if (g_settings.achievements_challenge_indicator_mode == AchievementChallengeIndicatorMode::Notification &&
1634
event->achievement->state == RC_CLIENT_ACHIEVEMENT_STATE_ACTIVE)
1635
{
1636
FullscreenUI::AddAchievementNotification(
1637
fmt::format("AchievementChallenge{}", event->achievement->id), CHALLENGE_FAILED_NOTIFICATION_TIME, it->badge_path,
1638
fmt::format(TRANSLATE_FS("Achievements", "Challenge Failed: {}"),
1639
event->achievement->title ? event->achievement->title : ""),
1640
fmt::format(ICON_EMOJI_CROSS_MARK_BUTTON " {}",
1641
event->achievement->description ? event->achievement->description : ""),
1642
{}, FullscreenUI::AchievementNotificationNoteType::None, 0, true);
1643
}
1644
if (g_settings.achievements_challenge_indicator_mode == AchievementChallengeIndicatorMode::Notification ||
1645
g_settings.achievements_challenge_indicator_mode == AchievementChallengeIndicatorMode::Disabled)
1646
{
1647
// remove it here, because it won't naturally decay
1648
s_state.active_challenge_indicators.erase(it);
1649
return;
1650
}
1651
1652
it->active = false;
1653
}
1654
1655
void Achievements::HandleAchievementProgressIndicatorShowEvent(const rc_client_event_t* event)
1656
{
1657
DEV_LOG("Showing progress indicator: {} ({}): {}", event->achievement->id, event->achievement->title,
1658
event->achievement->measured_progress);
1659
1660
if (!g_settings.achievements_progress_indicators)
1661
return;
1662
1663
if (!s_state.active_progress_indicator.has_value())
1664
s_state.active_progress_indicator.emplace();
1665
1666
s_state.active_progress_indicator->achievement = event->achievement;
1667
s_state.active_progress_indicator->badge_path = GetAchievementBadgePath(event->achievement, false);
1668
s_state.active_progress_indicator->time = 0.0f;
1669
s_state.active_progress_indicator->active = true;
1670
FullscreenUI::UpdateAchievementsLastProgressUpdate(event->achievement);
1671
}
1672
1673
void Achievements::HandleAchievementProgressIndicatorHideEvent(const rc_client_event_t* event)
1674
{
1675
if (!s_state.active_progress_indicator.has_value())
1676
return;
1677
1678
DEV_LOG("Hiding progress indicator");
1679
1680
if (!g_settings.achievements_progress_indicators)
1681
{
1682
s_state.active_progress_indicator.reset();
1683
return;
1684
}
1685
1686
s_state.active_progress_indicator->active = false;
1687
s_state.active_progress_indicator->time = std::min(s_state.active_progress_indicator->time, INDICATOR_FADE_OUT_TIME);
1688
}
1689
1690
void Achievements::HandleAchievementProgressIndicatorUpdateEvent(const rc_client_event_t* event)
1691
{
1692
DEV_LOG("Updating progress indicator: {} ({}): {}", event->achievement->id, event->achievement->title,
1693
event->achievement->measured_progress);
1694
if (!s_state.active_progress_indicator.has_value())
1695
return;
1696
1697
s_state.active_progress_indicator->achievement = event->achievement;
1698
s_state.active_progress_indicator->active = true;
1699
FullscreenUI::UpdateAchievementsLastProgressUpdate(event->achievement);
1700
}
1701
1702
void Achievements::HandleServerErrorEvent(const rc_client_event_t* event)
1703
{
1704
ERROR_LOG("Server error in {}:\n{}", event->server_error->api ? event->server_error->api : "UNKNOWN",
1705
event->server_error->error_message ? event->server_error->error_message : "UNKNOWN");
1706
Host::AddIconOSDMessage(OSDMessageType::Error, {}, ICON_EMOJI_WARNING,
1707
fmt::format(TRANSLATE_FS("Achievements", "Server error in {}"),
1708
event->server_error->api ? event->server_error->api : "UNKNOWN"),
1709
event->server_error->error_message ? event->server_error->error_message : "UNKNOWN");
1710
}
1711
1712
void Achievements::HandleServerDisconnectedEvent(const rc_client_event_t* event)
1713
{
1714
WARNING_LOG("Server disconnected.");
1715
1716
Host::AddIconOSDMessage(
1717
OSDMessageType::Error, "AchievementsDisconnected", ICON_EMOJI_WARNING,
1718
TRANSLATE_STR("Achievements", "Achievements Disconnected"),
1719
TRANSLATE_STR("Achievements",
1720
"An unlock request could not be completed.\nWe will keep trying to submit this request."));
1721
}
1722
1723
void Achievements::HandleServerReconnectedEvent(const rc_client_event_t* event)
1724
{
1725
WARNING_LOG("Server reconnected.");
1726
1727
Host::AddIconOSDMessage(OSDMessageType::Warning, "AchievementsDisconnected", RA_LOGO_ICON_NAME,
1728
TRANSLATE_STR("Achievements", "Achievements Reconnected"),
1729
TRANSLATE_STR("Achievements", "All pending unlock requests have completed."));
1730
}
1731
1732
void Achievements::EnableHardcoreMode(bool display_message, bool display_game_summary)
1733
{
1734
DebugAssert(IsActive());
1735
if (rc_client_get_hardcore_enabled(s_state.client))
1736
return;
1737
1738
rc_client_set_hardcore_enabled(s_state.client, true);
1739
OnHardcoreModeChanged(true, display_message, display_game_summary);
1740
}
1741
1742
void Achievements::DisableHardcoreMode(bool show_message, bool display_game_summary)
1743
{
1744
if (!IsActive())
1745
return;
1746
1747
const auto lock = GetLock();
1748
if (!rc_client_get_hardcore_enabled(s_state.client))
1749
return;
1750
1751
rc_client_set_hardcore_enabled(s_state.client, false);
1752
OnHardcoreModeChanged(false, show_message, display_game_summary);
1753
}
1754
1755
void Achievements::OnHardcoreModeChanged(bool enabled, bool display_message, bool display_game_summary)
1756
{
1757
INFO_COLOR_LOG(StrongYellow, "Hardcore mode/restrictions are now {}.", enabled ? "ACTIVE" : "inactive");
1758
1759
if (System::IsValid() && display_message)
1760
{
1761
Host::AddIconOSDMessage(OSDMessageType::Info, "AchievementsHardcoreModeChanged", RA_LOGO_ICON_NAME,
1762
enabled ? TRANSLATE_STR("Achievements", "Hardcore mode enabled.") :
1763
TRANSLATE_STR("Achievements", "Hardcore mode disabled."),
1764
enabled ? TRANSLATE_STR("Achievements", "Restrictions are now active.") :
1765
TRANSLATE_STR("Achievements", "Restrictions are no longer active."));
1766
}
1767
1768
if (HasActiveGame() && display_game_summary)
1769
{
1770
UpdateGameSummary(true);
1771
DisplayAchievementSummary();
1772
}
1773
1774
DebugAssert((rc_client_get_hardcore_enabled(s_state.client) != 0) == enabled);
1775
1776
// Reload setting to permit cheating-like things if we were just disabled.
1777
if (System::IsValid())
1778
{
1779
// Make sure a pre-existing cheat file hasn't been loaded when resetting after enabling HC mode.
1780
Cheats::ReloadCheats(true, true, false, true, true);
1781
1782
// Defer settings update in case something is using it.
1783
Host::RunOnCoreThread([]() { System::ApplySettings(false); });
1784
}
1785
else if (System::GetState() == System::State::Starting)
1786
{
1787
// Initial HC enable, activate restrictions.
1788
System::ApplySettings(false);
1789
}
1790
1791
// Toss away UI state, because it's invalid now
1792
FullscreenUI::ClearAchievementsState();
1793
1794
Host::OnAchievementsHardcoreModeChanged(enabled);
1795
}
1796
1797
void Achievements::LoadStateFromBuffer(std::span<const u8> data, std::unique_lock<std::recursive_mutex>& lock)
1798
{
1799
// if we're active, make sure we've downloaded and activated all the achievements
1800
// before deserializing, otherwise that state's going to get lost.
1801
if (s_state.load_game_request)
1802
{
1803
FullscreenUI::OpenOrUpdateLoadingScreen(System::GetImageForLoadingScreen(System::GetGamePath()),
1804
TRANSLATE_SV("Achievements", "Downloading achievements data..."));
1805
1806
WaitForHTTPRequestsWithYield(lock);
1807
1808
FullscreenUI::CloseLoadingScreen();
1809
}
1810
1811
if (data.empty())
1812
{
1813
// reset runtime, no data (state might've been created without cheevos)
1814
WARNING_LOG("State is missing cheevos data, resetting runtime");
1815
rc_client_reset(s_state.client);
1816
return;
1817
}
1818
1819
const int result = rc_client_deserialize_progress_sized(s_state.client, data.data(), data.size());
1820
if (result != RC_OK)
1821
ERROR_LOG("Failed to deserialize cheevos state ({}/{}), runtime was reset", rc_error_str(result), result);
1822
}
1823
1824
bool Achievements::SaveStateToBuffer(std::span<u8> data)
1825
{
1826
const int result = rc_client_serialize_progress_sized(s_state.client, data.data(), data.size());
1827
if (result != RC_OK)
1828
{
1829
// set data to zero, effectively serializing nothing
1830
ERROR_LOG("Failed to serialize cheevos state ({}/{})", rc_error_str(result), result);
1831
return false;
1832
}
1833
1834
return true;
1835
}
1836
1837
DynamicHeapArray<u8> Achievements::SaveStateToBuffer()
1838
{
1839
DynamicHeapArray<u8> ret;
1840
if (const size_t data_size = rc_client_progress_size(s_state.client); data_size > 0)
1841
{
1842
ret.resize(data_size);
1843
if (!SaveStateToBuffer(ret))
1844
ret.deallocate();
1845
}
1846
1847
return ret;
1848
}
1849
1850
bool Achievements::DoState(StateWrapper& sw)
1851
{
1852
static constexpr u32 REQUIRED_VERSION = 56;
1853
1854
// if we're inactive, we still need to skip the data (if any)
1855
if (!IsActive())
1856
{
1857
u32 data_size = 0;
1858
sw.DoEx(&data_size, REQUIRED_VERSION, 0u);
1859
if (data_size > 0)
1860
sw.SkipBytes(data_size);
1861
1862
return !sw.HasError();
1863
}
1864
1865
std::unique_lock lock(s_state.mutex);
1866
1867
if (sw.IsReading())
1868
{
1869
u32 data_size = 0;
1870
sw.DoEx(&data_size, REQUIRED_VERSION, 0u);
1871
1872
const std::span<u8> data = sw.GetDeferredBytes(data_size);
1873
if (sw.HasError())
1874
return false;
1875
1876
LoadStateFromBuffer(data, lock);
1877
return true;
1878
}
1879
else
1880
{
1881
const size_t size_pos = sw.GetPosition();
1882
1883
u32 data_size = static_cast<u32>(rc_client_progress_size(s_state.client));
1884
sw.Do(&data_size);
1885
1886
if (data_size > 0)
1887
{
1888
const std::span<u8> data = sw.GetDeferredBytes(data_size);
1889
if (!sw.HasError()) [[likely]]
1890
{
1891
if (!SaveStateToBuffer(data))
1892
{
1893
// set data to zero, effectively serializing nothing
1894
data_size = 0;
1895
sw.SetPosition(size_pos);
1896
sw.Do(&data_size);
1897
}
1898
}
1899
}
1900
1901
return !sw.HasError();
1902
}
1903
}
1904
1905
std::string Achievements::GetAchievementBadgePath(const rc_client_achievement_t* achievement, bool locked,
1906
bool download_if_missing)
1907
{
1908
const u32 image_type = locked ? RC_IMAGE_TYPE_ACHIEVEMENT_LOCKED : RC_IMAGE_TYPE_ACHIEVEMENT;
1909
const std::string path = GetLocalImagePath(achievement->badge_name, image_type);
1910
if (download_if_missing && !path.empty() && !FileSystem::FileExists(path.c_str()))
1911
{
1912
std::string url;
1913
const char* url_ptr;
1914
1915
// RAIntegration doesn't set the URL fields.
1916
if (IsUsingRAIntegration() || !(url_ptr = locked ? achievement->badge_locked_url : achievement->badge_url))
1917
url = GetImageURL(achievement->badge_name, image_type);
1918
else
1919
url = std::string(url_ptr);
1920
1921
if (url.empty()) [[unlikely]]
1922
ReportFmtError("Acheivement {} with badge name {} has no badge URL", achievement->id, achievement->badge_name);
1923
else
1924
DownloadImage(std::string(url), path);
1925
}
1926
1927
return path;
1928
}
1929
1930
std::string Achievements::GetLeaderboardUserBadgePath(const rc_client_leaderboard_entry_t* entry)
1931
{
1932
const std::string path = GetLocalImagePath(entry->user, RC_IMAGE_TYPE_USER);
1933
if (!FileSystem::FileExists(path.c_str()))
1934
{
1935
std::string url = GetImageURL(entry->user, RC_IMAGE_TYPE_USER);
1936
if (!url.empty())
1937
DownloadImage(std::move(url), path);
1938
}
1939
1940
return path;
1941
}
1942
1943
std::string Achievements::GetSubsetBadgePath(const rc_client_subset_t* subset)
1944
{
1945
std::string badge_path = GetLocalImagePath(subset->badge_name, RC_IMAGE_TYPE_GAME);
1946
if (!FileSystem::FileExists(badge_path.c_str()))
1947
{
1948
std::string url;
1949
if (IsUsingRAIntegration() || !subset->badge_url)
1950
url = GetImageURL(subset->badge_name, RC_IMAGE_TYPE_GAME);
1951
else
1952
url = subset->badge_url;
1953
DownloadImage(std::move(url), badge_path);
1954
}
1955
1956
return badge_path;
1957
}
1958
1959
bool Achievements::IsLoggedIn()
1960
{
1961
return (rc_client_get_user_info(s_state.client) != nullptr);
1962
}
1963
1964
bool Achievements::IsLoggedInOrLoggingIn()
1965
{
1966
return (IsLoggedIn() || s_state.login_request);
1967
}
1968
1969
bool Achievements::Login(const char* username, const char* password, Error* error)
1970
{
1971
auto lock = GetLock();
1972
1973
// We need to use a temporary client if achievements aren't currently active.
1974
rc_client_t* client = s_state.client;
1975
HTTPDownloader* http = s_state.http_downloader.get();
1976
const bool is_temporary_client = (client == nullptr);
1977
std::unique_ptr<HTTPDownloader> temporary_downloader;
1978
ScopedGuard temporary_client_guard = [&client, is_temporary_client, &temporary_downloader]() {
1979
if (is_temporary_client)
1980
DestroyClient(&client, &temporary_downloader);
1981
};
1982
if (is_temporary_client)
1983
{
1984
if (!CreateClient(&client, &temporary_downloader))
1985
{
1986
Error::SetString(error, "Failed to create client.");
1987
return false;
1988
}
1989
http = temporary_downloader.get();
1990
}
1991
1992
LoginWithPasswordParameters params = {username, error, nullptr, false};
1993
1994
params.request =
1995
rc_client_begin_login_with_password(client, username, password, ClientLoginWithPasswordCallback, &params);
1996
if (!params.request)
1997
{
1998
Error::SetString(error, "Failed to create login request.");
1999
return false;
2000
}
2001
2002
// Wait until the login request completes.
2003
http->WaitForAllRequestsWithYield([&lock]() { lock.unlock(); }, [&lock]() { lock.lock(); });
2004
Assert(!params.request);
2005
2006
// Success? Assume the callback set the error message.
2007
if (!params.result)
2008
return false;
2009
2010
// If we were't a temporary client, get the game loaded.
2011
if (System::IsValid() && !is_temporary_client)
2012
{
2013
IdentifyCurrentGame();
2014
BeginLoadGame();
2015
}
2016
2017
return true;
2018
}
2019
2020
void Achievements::ClientLoginWithPasswordCallback(int result, const char* error_message, rc_client_t* client,
2021
void* userdata)
2022
{
2023
Assert(userdata);
2024
2025
LoginWithPasswordParameters* params = static_cast<LoginWithPasswordParameters*>(userdata);
2026
params->request = nullptr;
2027
2028
if (result != RC_OK)
2029
{
2030
ERROR_LOG("Login failed: {}: {}", rc_error_str(result), error_message ? error_message : "Unknown");
2031
Error::SetString(params->error,
2032
fmt::format("{}: {}", rc_error_str(result), error_message ? error_message : "Unknown"));
2033
params->result = false;
2034
return;
2035
}
2036
2037
// Grab the token from the client, and save it to the config.
2038
const rc_client_user_t* user = rc_client_get_user_info(client);
2039
if (!user || !user->token)
2040
{
2041
ERROR_LOG("rc_client_get_user_info() returned NULL");
2042
Error::SetString(params->error, "rc_client_get_user_info() returned NULL");
2043
params->result = false;
2044
return;
2045
}
2046
2047
params->result = true;
2048
2049
// Store configuration.
2050
Core::SetBaseStringSettingValue("Cheevos", "Username", params->username);
2051
Core::SetBaseStringSettingValue("Cheevos", "Token", EncryptLoginToken(user->token, params->username));
2052
Core::SetBaseStringSettingValue("Cheevos", "LoginTimestamp", fmt::format("{}", std::time(nullptr)).c_str());
2053
Host::CommitBaseSettingChanges();
2054
2055
// Will be using temporary client if achievements are not enabled.
2056
if (client == s_state.client)
2057
FinishLogin();
2058
}
2059
2060
void Achievements::FetchGameTitlesCallback(int result, const char* error_message, rc_client_game_title_list_t* list,
2061
rc_client_t* client, void* userdata)
2062
{
2063
FetchGameTitlesParameters* params = static_cast<FetchGameTitlesParameters*>(userdata);
2064
params->request = nullptr;
2065
2066
if (result != RC_OK || !list)
2067
{
2068
if (error_message)
2069
Error::SetString(params->error, error_message);
2070
else
2071
Error::SetStringFmt(params->error, TRANSLATE_FS("Achievements", "Failed to fetch game titles (code {})."),
2072
result);
2073
params->success = false;
2074
return;
2075
}
2076
2077
params->list = list;
2078
params->success = true;
2079
}
2080
2081
void Achievements::ClientLoginWithTokenCallback(int result, const char* error_message, rc_client_t* client,
2082
void* userdata)
2083
{
2084
s_state.login_request = nullptr;
2085
2086
if (result == RC_INVALID_CREDENTIALS || result == RC_EXPIRED_TOKEN)
2087
{
2088
ERROR_LOG("Login failed due to invalid token: {}: {}", rc_error_str(result), error_message);
2089
Host::OnAchievementsLoginRequested(LoginRequestReason::TokenInvalid);
2090
return;
2091
}
2092
else if (result != RC_OK)
2093
{
2094
ERROR_LOG("Login failed: {}: {}", rc_error_str(result), error_message);
2095
2096
// only display user error if they've started a game
2097
if (System::IsValid())
2098
{
2099
Host::AddIconOSDMessage(
2100
OSDMessageType::Error, "AchievementsLoginFailed", ICON_EMOJI_NO_ENTRY_SIGN,
2101
TRANSLATE_STR("Achievements", "RetroAchievements Login Failed"),
2102
fmt::format(
2103
TRANSLATE_FS("Achievements", "Achievement unlocks will not be submitted for this session.\nError: {}"),
2104
error_message));
2105
}
2106
2107
return;
2108
}
2109
2110
// Should be active here.
2111
DebugAssert(client == s_state.client);
2112
FinishLogin();
2113
}
2114
2115
void Achievements::FinishLogin()
2116
{
2117
const rc_client_user_t* const user = rc_client_get_user_info(s_state.client);
2118
if (!user)
2119
return;
2120
2121
s_state.user_badge_path = GetLocalImagePath(user->username, RC_IMAGE_TYPE_USER);
2122
if (!s_state.user_badge_path.empty() && !FileSystem::FileExists(s_state.user_badge_path.c_str()))
2123
{
2124
std::string url;
2125
if (IsUsingRAIntegration() || !user->avatar_url)
2126
url = GetImageURL(user->username, RC_IMAGE_TYPE_USER);
2127
else
2128
url = user->avatar_url;
2129
2130
DownloadImage(std::move(url), s_state.user_badge_path);
2131
}
2132
2133
PreloadHashDatabase();
2134
2135
Host::OnAchievementsLoginSuccess(user->username, user->score, user->score_softcore, user->num_unread_messages);
2136
2137
if (g_settings.achievements_notifications)
2138
{
2139
//: Summary for login notification.
2140
std::string summary = fmt::format(TRANSLATE_FS("Achievements", "Score: {} ({} softcore)\nUnread messages: {}"),
2141
user->score, user->score_softcore, user->num_unread_messages);
2142
2143
FullscreenUI::AddAchievementNotification("achievements_login", LOGIN_NOTIFICATION_TIME, s_state.user_badge_path,
2144
user->display_name, std::move(summary), RA_LOGO_ICON_NAME,
2145
FullscreenUI::AchievementNotificationNoteType::Image);
2146
}
2147
}
2148
2149
const char* Achievements::GetLoggedInUserName()
2150
{
2151
const rc_client_user_t* user = rc_client_get_user_info(s_state.client);
2152
if (!user) [[unlikely]]
2153
return nullptr;
2154
2155
return user->username;
2156
}
2157
2158
const std::string& Achievements::GetLoggedInUserBadgePath()
2159
{
2160
return s_state.user_badge_path;
2161
}
2162
2163
SmallString Achievements::GetLoggedInUserPointsSummary()
2164
{
2165
SmallString ret;
2166
2167
const rc_client_user_t* user = rc_client_get_user_info(s_state.client);
2168
if (!user) [[unlikely]]
2169
return ret;
2170
2171
//: Score summary, shown in Big Picture mode.
2172
ret.format(TRANSLATE_FS("Achievements", "Score: {} ({} softcore)"), user->score, user->score_softcore);
2173
return ret;
2174
}
2175
2176
std::string Achievements::GetGameBadgePath(std::string_view badge_name)
2177
{
2178
return GetLocalImagePath(badge_name, RC_IMAGE_TYPE_GAME);
2179
}
2180
2181
bool Achievements::DownloadGameIcons(ProgressCallback* progress, Error* error)
2182
{
2183
// Collect all unique game IDs that don't have icons yet
2184
std::vector<u32> game_ids;
2185
{
2186
const auto lock = GameList::GetLock();
2187
for (const GameList::Entry& entry : GameList::GetEntries())
2188
{
2189
if (entry.achievements_game_id != 0)
2190
{
2191
// Check if we already have this badge
2192
const std::string existing_badge = GameList::GetAchievementGameBadgePath(entry.achievements_game_id);
2193
if (existing_badge.empty() &&
2194
std::find(game_ids.begin(), game_ids.end(), entry.achievements_game_id) == game_ids.end())
2195
{
2196
game_ids.push_back(entry.achievements_game_id);
2197
}
2198
}
2199
}
2200
}
2201
2202
if (game_ids.empty())
2203
{
2204
Error::SetStringView(error, TRANSLATE_SV("Achievements", "All available icons have already been downloaded."));
2205
return false;
2206
}
2207
2208
progress->FormatStatusText(TRANSLATE_FS("Achievements", "Fetching icon info for {} games..."), game_ids.size());
2209
2210
auto lock = GetLock();
2211
if (!IsActive())
2212
{
2213
Error::SetStringView(error, TRANSLATE_SV("Achievements", "Achievements are not enabled."));
2214
return false;
2215
}
2216
2217
// Fetch game titles (includes badge names) from RetroAchievements
2218
FetchGameTitlesParameters params = {error, nullptr, nullptr, false};
2219
params.request = rc_client_begin_fetch_game_titles(s_state.client, game_ids.data(), static_cast<u32>(game_ids.size()),
2220
FetchGameTitlesCallback, &params);
2221
if (!params.request)
2222
{
2223
Error::SetStringView(error, TRANSLATE_SV("Achievements", "Failed to create game titles request."));
2224
return false;
2225
}
2226
2227
WaitForHTTPRequestsWithYield(lock);
2228
2229
if (!params.success || !params.list)
2230
return false;
2231
2232
const ScopedGuard list_guard([&params]() { rc_client_destroy_game_title_list(params.list); });
2233
if (params.list->num_entries == 0)
2234
{
2235
Error::SetStringView(error, TRANSLATE_SV("Achievements", "No image names returned."));
2236
return false;
2237
}
2238
2239
// Create all download requests in parallel
2240
u32 badges_to_download = 0;
2241
for (u32 i = 0; i < params.list->num_entries; i++)
2242
{
2243
const rc_client_game_title_entry_t& entry = params.list->entries[i];
2244
2245
if (entry.badge_name[0] == '\0')
2246
continue;
2247
2248
std::string path = GetLocalImagePath(entry.badge_name, RC_IMAGE_TYPE_GAME);
2249
if (FileSystem::FileExists(path.c_str()))
2250
{
2251
// Already have this icon, just update the cache
2252
GameList::UpdateAchievementBadgeName(entry.game_id, entry.badge_name);
2253
continue;
2254
}
2255
2256
std::string url =
2257
entry.badge_url ? std::string(entry.badge_url) : GetImageURL(entry.badge_name, RC_IMAGE_TYPE_GAME);
2258
if (url.empty())
2259
continue;
2260
2261
badges_to_download++;
2262
s_state.http_downloader->CreateRequest(
2263
std::move(url), [path = std::move(path), progress](s32 status_code, const Error& http_error, const std::string&,
2264
HTTPDownloader::Request::Data data) {
2265
if (status_code == HTTPDownloader::HTTP_STATUS_OK)
2266
{
2267
INFO_LOG("Writing badge to {}...", Path::GetFileName(path));
2268
2269
Error write_error;
2270
if (!FileSystem::FileExists(path.c_str()) && !FileSystem::WriteBinaryFile(path.c_str(), data, &write_error))
2271
{
2272
ERROR_LOG("Failed to write badge to {}: {}", Path::GetFileName(path), write_error.GetDescription());
2273
FileSystem::DeleteFile(path.c_str());
2274
}
2275
}
2276
else
2277
{
2278
ERROR_LOG("Failed to download badge: HTTP {}: {}", status_code, http_error.GetDescription());
2279
}
2280
2281
progress->IncrementProgressValue();
2282
});
2283
}
2284
2285
if (badges_to_download == 0)
2286
{
2287
Error::SetStringView(error, TRANSLATE_SV("Achievements", "All icons have already been downloaded."));
2288
return false;
2289
}
2290
2291
progress->SetProgressRange(badges_to_download);
2292
progress->FormatStatusText(TRANSLATE_FS("Achievements", "Downloading {} game icons..."), badges_to_download);
2293
WaitForHTTPRequestsWithYield(lock);
2294
return true;
2295
}
2296
2297
u32 Achievements::GetPauseThrottleFrames()
2298
{
2299
if (!IsActive())
2300
return 0;
2301
2302
const auto lock = GetLock();
2303
if (!IsHardcoreModeActive())
2304
return 0;
2305
2306
u32 frames_remaining = 0;
2307
return rc_client_can_pause(s_state.client, &frames_remaining) ? 0 : frames_remaining;
2308
}
2309
2310
u32 Achievements::GetPendingUnlockCount()
2311
{
2312
if (!IsActive())
2313
return 0;
2314
2315
const auto lock = GetLock();
2316
return rc_client_get_award_achievement_pending_count(s_state.client);
2317
}
2318
2319
void Achievements::Logout()
2320
{
2321
if (IsActive())
2322
{
2323
const auto lock = GetLock();
2324
2325
if (HasActiveGame())
2326
{
2327
ClearGameInfo();
2328
DisableHardcoreMode(false, false);
2329
}
2330
2331
CancelHashDatabaseRequests();
2332
2333
INFO_LOG("Logging out...");
2334
rc_client_logout(s_state.client);
2335
}
2336
2337
INFO_LOG("Clearing credentials...");
2338
Core::DeleteBaseSettingValue("Cheevos", "Username");
2339
Core::DeleteBaseSettingValue("Cheevos", "Token");
2340
Core::DeleteBaseSettingValue("Cheevos", "LoginTimestamp");
2341
Host::CommitBaseSettingChanges();
2342
ClearProgressDatabase();
2343
}
2344
2345
void Achievements::ConfirmHardcoreModeDisableAsync(std::string_view trigger, std::function<void(bool)> callback)
2346
{
2347
Host::ConfirmMessageAsync(
2348
TRANSLATE_STR("Achievements", "Confirm Hardcore Mode Disable"),
2349
fmt::format(TRANSLATE_FS("Achievements", "{0} cannot be performed while hardcore mode is active. Do you want to "
2350
"disable hardcore mode? {0} will be cancelled if you select No."),
2351
trigger),
2352
[callback = std::move(callback)](bool res) mutable {
2353
// don't run the callback in the middle of rendering the UI
2354
Host::RunOnCoreThread([callback = std::move(callback), res]() {
2355
if (res)
2356
DisableHardcoreMode(true, true);
2357
callback(res);
2358
});
2359
});
2360
}
2361
2362
#if defined(_WIN32)
2363
#include "common/windows_headers.h"
2364
#elif !defined(__ANDROID__)
2365
#include <unistd.h>
2366
#endif
2367
2368
#include "common/thirdparty/SmallVector.h"
2369
#include "common/thirdparty/aes.h"
2370
2371
#ifndef __ANDROID__
2372
2373
static TinyString GetLoginEncryptionMachineKey()
2374
{
2375
TinyString ret;
2376
2377
#ifdef _WIN32
2378
HKEY hKey;
2379
DWORD error;
2380
if ((error = RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Cryptography", 0, KEY_READ, &hKey)) !=
2381
ERROR_SUCCESS)
2382
{
2383
WARNING_LOG("Open SOFTWARE\\Microsoft\\Cryptography failed for machine key failed: {}", error);
2384
return ret;
2385
}
2386
2387
DWORD machine_guid_length;
2388
if ((error = RegGetValueA(hKey, NULL, "MachineGuid", RRF_RT_REG_SZ, NULL, NULL, &machine_guid_length)) !=
2389
ERROR_SUCCESS)
2390
{
2391
WARNING_LOG("Get MachineGuid failed: {}", error);
2392
RegCloseKey(hKey);
2393
return ret;
2394
}
2395
2396
ret.resize(machine_guid_length);
2397
if ((error = RegGetValueA(hKey, NULL, "MachineGuid", RRF_RT_REG_SZ, NULL, ret.data(), &machine_guid_length)) !=
2398
ERROR_SUCCESS ||
2399
machine_guid_length <= 1)
2400
{
2401
WARNING_LOG("Read MachineGuid failed: {}", error);
2402
ret = {};
2403
RegCloseKey(hKey);
2404
return ret;
2405
}
2406
2407
ret.resize(machine_guid_length);
2408
RegCloseKey(hKey);
2409
#else
2410
#if defined(__linux__)
2411
// use /etc/machine-id on Linux
2412
std::optional<std::string> machine_id = FileSystem::ReadFileToString("/etc/machine-id");
2413
if (machine_id.has_value())
2414
ret = std::string_view(machine_id.value());
2415
#elif defined(__APPLE__)
2416
// use gethostuuid(2) on macOS
2417
const struct timespec ts{};
2418
uuid_t uuid{};
2419
if (gethostuuid(uuid, &ts) == 0)
2420
ret.append_hex(uuid, sizeof(uuid), false);
2421
#endif
2422
2423
if (ret.empty())
2424
{
2425
WARNING_LOG("Falling back to gethostid()");
2426
2427
// fallback to POSIX gethostid()
2428
const long hostid = gethostid();
2429
ret.format("{:08X}", hostid);
2430
}
2431
#endif
2432
2433
return ret;
2434
}
2435
2436
#endif
2437
2438
static std::array<u8, 32> GetLoginEncryptionKey(std::string_view username)
2439
{
2440
// super basic key stretching
2441
static constexpr u32 EXTRA_ROUNDS = 100;
2442
2443
SHA256Digest digest;
2444
2445
#ifndef __ANDROID__
2446
// Only use machine key if we're not running in portable mode.
2447
if (!EmuFolders::IsRunningInPortableMode())
2448
{
2449
const TinyString machine_key = GetLoginEncryptionMachineKey();
2450
if (!machine_key.empty())
2451
digest.Update(machine_key.cbspan());
2452
else
2453
WARNING_LOG("Failed to get machine key, token will be decipherable.");
2454
}
2455
#endif
2456
2457
// salt with username
2458
digest.Update(username.data(), username.length());
2459
2460
std::array<u8, 32> key = digest.Final();
2461
2462
for (u32 i = 0; i < EXTRA_ROUNDS; i++)
2463
key = SHA256Digest::GetDigest(key);
2464
2465
return key;
2466
}
2467
2468
TinyString Achievements::EncryptLoginToken(std::string_view token, std::string_view username)
2469
{
2470
TinyString ret;
2471
if (token.empty() || username.empty())
2472
return ret;
2473
2474
const auto key = GetLoginEncryptionKey(username);
2475
std::array<u32, AES_KEY_SCHEDULE_SIZE> key_schedule;
2476
aes_key_setup(&key[0], key_schedule.data(), 128);
2477
2478
// has to be padded to the block size
2479
llvm::SmallVector<u8, 64> data(reinterpret_cast<const u8*>(token.data()),
2480
reinterpret_cast<const u8*>(token.data() + token.length()));
2481
data.resize(Common::AlignUpPow2(token.length(), AES_BLOCK_SIZE), 0);
2482
aes_encrypt_cbc(data.data(), data.size(), data.data(), key_schedule.data(), 128, &key[16]);
2483
2484
// base64 encode it
2485
const std::span<const u8> data_span(data.data(), data.size());
2486
ret.resize(static_cast<u32>(StringUtil::EncodedBase64Length(data_span)));
2487
StringUtil::EncodeBase64(ret.span(), data_span);
2488
return ret;
2489
}
2490
2491
TinyString Achievements::DecryptLoginToken(std::string_view encrypted_token, std::string_view username)
2492
{
2493
TinyString ret;
2494
if (encrypted_token.empty() || username.empty())
2495
return ret;
2496
2497
const size_t encrypted_data_length = StringUtil::DecodedBase64Length(encrypted_token);
2498
if (encrypted_data_length == 0 || (encrypted_data_length % AES_BLOCK_SIZE) != 0)
2499
return ret;
2500
2501
const auto key = GetLoginEncryptionKey(username);
2502
std::array<u32, AES_KEY_SCHEDULE_SIZE> key_schedule;
2503
aes_key_setup(&key[0], key_schedule.data(), 128);
2504
2505
// has to be padded to the block size
2506
llvm::SmallVector<u8, 64> encrypted_data;
2507
encrypted_data.resize(encrypted_data_length);
2508
if (StringUtil::DecodeBase64(std::span<u8>(encrypted_data.data(), encrypted_data.size()), encrypted_token) !=
2509
encrypted_data_length)
2510
{
2511
WARNING_LOG("Failed to base64 decode encrypted login token.");
2512
return ret;
2513
}
2514
2515
aes_decrypt_cbc(encrypted_data.data(), encrypted_data.size(), encrypted_data.data(), key_schedule.data(), 128,
2516
&key[16]);
2517
2518
// remove any trailing null bytes
2519
const size_t real_length =
2520
StringUtil::Strnlen(reinterpret_cast<const char*>(encrypted_data.data()), encrypted_data_length);
2521
ret.append(reinterpret_cast<const char*>(encrypted_data.data()), static_cast<u32>(real_length));
2522
return ret;
2523
}
2524
2525
std::string Achievements::GetHashDatabasePath()
2526
{
2527
return Path::Combine(EmuFolders::Cache, "achievement_gamedb.cache");
2528
}
2529
2530
std::string Achievements::GetProgressDatabasePath()
2531
{
2532
return Path::Combine(EmuFolders::Cache, "achievement_progress.cache");
2533
}
2534
2535
void Achievements::BeginRefreshHashDatabase()
2536
{
2537
INFO_LOG("Starting hash database refresh...");
2538
2539
// kick off both requests
2540
CancelHashDatabaseRequests();
2541
s_state.fetch_hash_library_request =
2542
rc_client_begin_fetch_hash_library(s_state.client, RC_CONSOLE_PLAYSTATION, FetchHashLibraryCallback, nullptr);
2543
s_state.fetch_all_progress_request =
2544
rc_client_begin_fetch_all_user_progress(s_state.client, RC_CONSOLE_PLAYSTATION, FetchAllProgressCallback, nullptr);
2545
if (!s_state.fetch_hash_library_request || !s_state.fetch_hash_library_request)
2546
{
2547
ERROR_LOG("Failed to create hash database refresh requests.");
2548
CancelHashDatabaseRequests();
2549
}
2550
}
2551
2552
void Achievements::FetchHashLibraryCallback(int result, const char* error_message, rc_client_hash_library_t* list,
2553
rc_client_t* client, void* callback_userdata)
2554
{
2555
s_state.fetch_hash_library_request = nullptr;
2556
2557
if (result != RC_OK)
2558
{
2559
ERROR_LOG("Fetch hash library failed: {}: {}", rc_error_str(result), error_message);
2560
CancelHashDatabaseRequests();
2561
return;
2562
}
2563
2564
s_state.fetch_hash_library_result = list;
2565
FinishRefreshHashDatabase();
2566
}
2567
2568
void Achievements::FetchAllProgressCallback(int result, const char* error_message, rc_client_all_user_progress_t* list,
2569
rc_client_t* client, void* callback_userdata)
2570
{
2571
s_state.fetch_all_progress_request = nullptr;
2572
2573
if (result != RC_OK)
2574
{
2575
ERROR_LOG("Fetch all progress failed: {}: {}", rc_error_str(result), error_message);
2576
CancelHashDatabaseRequests();
2577
return;
2578
}
2579
2580
s_state.fetch_all_progress_result = list;
2581
FinishRefreshHashDatabase();
2582
}
2583
2584
void Achievements::CancelHashDatabaseRequests()
2585
{
2586
if (s_state.fetch_all_progress_result)
2587
{
2588
rc_client_destroy_all_user_progress(s_state.fetch_all_progress_result);
2589
s_state.fetch_all_progress_result = nullptr;
2590
}
2591
if (s_state.fetch_all_progress_request)
2592
{
2593
rc_client_abort_async(s_state.client, s_state.fetch_all_progress_request);
2594
s_state.fetch_all_progress_request = nullptr;
2595
}
2596
2597
if (s_state.fetch_hash_library_result)
2598
{
2599
rc_client_destroy_hash_library(s_state.fetch_hash_library_result);
2600
s_state.fetch_hash_library_result = nullptr;
2601
}
2602
if (s_state.fetch_hash_library_request)
2603
{
2604
rc_client_abort_async(s_state.client, s_state.fetch_hash_library_request);
2605
s_state.fetch_hash_library_request = nullptr;
2606
}
2607
}
2608
2609
void Achievements::FinishRefreshHashDatabase()
2610
{
2611
if (!s_state.fetch_hash_library_result || !s_state.fetch_all_progress_result)
2612
{
2613
// not done yet
2614
return;
2615
}
2616
2617
// build mapping of hashes to game ids and achievement counts
2618
BuildHashDatabase(s_state.fetch_hash_library_result, s_state.fetch_all_progress_result);
2619
2620
// update the progress tracking while we're at it
2621
BuildProgressDatabase(s_state.fetch_all_progress_result);
2622
2623
// tidy up
2624
rc_client_destroy_all_user_progress(s_state.fetch_all_progress_result);
2625
s_state.fetch_all_progress_result = nullptr;
2626
rc_client_destroy_hash_library(s_state.fetch_hash_library_result);
2627
s_state.fetch_hash_library_result = nullptr;
2628
2629
// update game list, we might have some new games that weren't in the seed database
2630
GameList::UpdateAllAchievementData();
2631
}
2632
2633
bool Achievements::RefreshAllProgressDatabase(ProgressCallback* progress, Error* error)
2634
{
2635
auto lock = GetLock();
2636
if (!IsLoggedIn())
2637
{
2638
Error::SetStringView(error, TRANSLATE_SV("Achievements", "User is not logged in."));
2639
return false;
2640
}
2641
2642
if (s_state.fetch_hash_library_request || s_state.fetch_all_progress_request || s_state.refresh_all_progress_request)
2643
{
2644
Error::SetStringView(error, TRANSLATE_SV("Achievements", "Progress is already being updated."));
2645
return false;
2646
}
2647
2648
// refresh in progress
2649
progress->SetStatusText(TRANSLATE_SV("Achievements", "Refreshing achievement progress..."));
2650
progress->SetProgressRange(0);
2651
progress->SetProgressValue(0);
2652
2653
std::pair<bool, Error*> result = {false, error};
2654
s_state.refresh_all_progress_request = rc_client_begin_fetch_all_user_progress(s_state.client, RC_CONSOLE_PLAYSTATION,
2655
RefreshAllProgressCallback, &result);
2656
while (s_state.refresh_all_progress_request)
2657
WaitForHTTPRequestsWithYield(lock);
2658
2659
return true;
2660
}
2661
2662
void Achievements::RefreshAllProgressCallback(int result, const char* error_message,
2663
rc_client_all_user_progress_t* list, rc_client_t* client,
2664
void* callback_userdata)
2665
{
2666
s_state.refresh_all_progress_request = nullptr;
2667
2668
std::pair<bool, Error*>* result_ud = static_cast<std::pair<bool, Error*>*>(callback_userdata);
2669
if (result != RC_OK)
2670
{
2671
if (result_ud)
2672
{
2673
result_ud->first = false;
2674
result_ud->second->SetStringFmt("{}: {}\n{}", TRANSLATE_SV("Achievements", "Refresh all progress failed"),
2675
rc_error_str(result), error_message);
2676
}
2677
2678
return;
2679
}
2680
2681
BuildProgressDatabase(list);
2682
rc_client_destroy_all_user_progress(list);
2683
2684
GameList::UpdateAllAchievementData();
2685
2686
if (result_ud)
2687
result_ud->first = true;
2688
}
2689
2690
void Achievements::BuildHashDatabase(const rc_client_hash_library_t* hashlib,
2691
const rc_client_all_user_progress_t* allprog)
2692
{
2693
std::vector<HashDatabaseEntry> dbentries;
2694
dbentries.reserve(hashlib->num_entries);
2695
2696
for (const rc_client_hash_library_entry_t& entry :
2697
std::span<const rc_client_hash_library_entry_t>(hashlib->entries, hashlib->num_entries))
2698
{
2699
HashDatabaseEntry dbentry;
2700
dbentry.game_id = entry.game_id;
2701
dbentry.num_achievements = 0;
2702
if (StringUtil::DecodeHex(dbentry.hash, entry.hash) != GAME_HASH_LENGTH)
2703
{
2704
WARNING_LOG("Invalid hash '{}' in game ID {}", entry.hash, entry.game_id);
2705
continue;
2706
}
2707
2708
// Just in case...
2709
if (std::any_of(dbentries.begin(), dbentries.end(),
2710
[&dbentry](const HashDatabaseEntry& e) { return (e.hash == dbentry.hash); }))
2711
{
2712
WARNING_LOG("Duplicate hash {}", entry.hash);
2713
continue;
2714
}
2715
2716
dbentries.push_back(dbentry);
2717
}
2718
2719
// fill in achievement counts
2720
for (const rc_client_all_user_progress_entry_t& entry :
2721
std::span<const rc_client_all_user_progress_entry_t>(allprog->entries, allprog->num_entries))
2722
{
2723
// can have multiple hashes with the same game id, update count on all of them
2724
bool found_one = false;
2725
for (HashDatabaseEntry& dbentry : dbentries)
2726
{
2727
if (dbentry.game_id == entry.game_id)
2728
{
2729
dbentry.num_achievements = entry.num_achievements;
2730
found_one = true;
2731
}
2732
}
2733
2734
if (!found_one)
2735
WARNING_LOG("All progress contained game ID {} without hash", entry.game_id);
2736
}
2737
2738
s_state.hashdb_entries = std::move(dbentries);
2739
s_state.hashdb_loaded = true;
2740
2741
Error error;
2742
if (!SortAndSaveHashDatabase(&error))
2743
ERROR_LOG("Failed to sort/save hash database from server: {}", error.GetDescription());
2744
}
2745
2746
bool Achievements::CreateHashDatabaseFromSeedDatabase(const std::string& path, Error* error)
2747
{
2748
std::optional<std::string> yaml_data = Host::ReadResourceFileToString("achievement_hashlib.yaml", false, error);
2749
if (!yaml_data.has_value())
2750
{
2751
Error::SetStringView(error, "Seed database is missing.");
2752
return false;
2753
}
2754
2755
const ryml::Tree yaml =
2756
ryml::parse_in_place(to_csubstr(path), c4::substr(reinterpret_cast<char*>(yaml_data->data()), yaml_data->size()));
2757
const ryml::ConstNodeRef root = yaml.rootref();
2758
if (root.empty())
2759
{
2760
Error::SetStringView(error, "Seed database is empty.");
2761
return false;
2762
}
2763
2764
std::vector<HashDatabaseEntry> dbentries;
2765
2766
if (const ryml::ConstNodeRef hashes = root.find_child(to_csubstr("hashes")); hashes.valid())
2767
{
2768
dbentries.reserve(hashes.num_children());
2769
for (const ryml::ConstNodeRef& current : hashes.cchildren())
2770
{
2771
const std::string_view hash = to_stringview(current.key());
2772
const std::optional<u32> game_id = StringUtil::FromChars<u32>(to_stringview(current.val()));
2773
if (!game_id.has_value())
2774
{
2775
WARNING_LOG("Invalid game ID {} in hash {}", to_stringview(current.val()), hash);
2776
continue;
2777
}
2778
2779
HashDatabaseEntry dbentry;
2780
dbentry.game_id = game_id.value();
2781
dbentry.num_achievements = 0;
2782
if (StringUtil::DecodeHex(dbentry.hash, hash) != GAME_HASH_LENGTH)
2783
{
2784
WARNING_LOG("Invalid hash '{}' in game ID {}", hash, game_id.value());
2785
continue;
2786
}
2787
2788
dbentries.push_back(dbentry);
2789
}
2790
}
2791
2792
if (const ryml::ConstNodeRef achievements = root.find_child(to_csubstr("achievements")); achievements.valid())
2793
{
2794
for (const ryml::ConstNodeRef& current : achievements.cchildren())
2795
{
2796
const std::optional<u32> game_id = StringUtil::FromChars<u32>(to_stringview(current.key()));
2797
const std::optional<u32> num_achievements = StringUtil::FromChars<u32>(to_stringview(current.val()));
2798
if (!game_id.has_value() || !num_achievements.has_value())
2799
{
2800
WARNING_LOG("Invalid achievements entry in game ID {}", to_stringview(current.key()));
2801
continue;
2802
}
2803
2804
// can have multiple hashes with the same game id, update count on all of them
2805
bool found_one = false;
2806
for (HashDatabaseEntry& dbentry : dbentries)
2807
{
2808
if (dbentry.game_id == game_id.value())
2809
{
2810
dbentry.num_achievements = num_achievements.value();
2811
found_one = true;
2812
}
2813
}
2814
2815
if (!found_one)
2816
WARNING_LOG("Seed database contained game ID {} without hash", game_id.value());
2817
}
2818
}
2819
2820
if (dbentries.empty())
2821
{
2822
Error::SetStringView(error, "Parsed seed database was empty");
2823
return false;
2824
}
2825
2826
s_state.hashdb_entries = std::move(dbentries);
2827
s_state.hashdb_loaded = true;
2828
2829
Error save_error;
2830
if (!SortAndSaveHashDatabase(&save_error))
2831
ERROR_LOG("Failed to sort/save hash database from server: {}", save_error.GetDescription());
2832
2833
return true;
2834
}
2835
2836
bool Achievements::SortAndSaveHashDatabase(Error* error)
2837
{
2838
// sort hashes for quick lookup
2839
s_state.hashdb_entries.shrink_to_fit();
2840
std::sort(s_state.hashdb_entries.begin(), s_state.hashdb_entries.end(),
2841
[](const HashDatabaseEntry& lhs, const HashDatabaseEntry& rhs) {
2842
return std::memcmp(lhs.hash.data(), rhs.hash.data(), GAME_HASH_LENGTH) < 0;
2843
});
2844
2845
FileSystem::AtomicRenamedFile fp = FileSystem::CreateAtomicRenamedFile(GetHashDatabasePath().c_str(), error);
2846
if (!fp)
2847
{
2848
Error::AddPrefix(error, "Failed to open cache for writing: ");
2849
return false;
2850
}
2851
2852
BinaryFileWriter writer(fp.get());
2853
writer.WriteU32(static_cast<u32>(s_state.hashdb_entries.size()));
2854
for (const HashDatabaseEntry& entry : s_state.hashdb_entries)
2855
{
2856
writer.Write(entry.hash.data(), GAME_HASH_LENGTH);
2857
writer.WriteU32(entry.game_id);
2858
writer.WriteU32(entry.num_achievements);
2859
}
2860
2861
if (!writer.Flush(error) || !FileSystem::CommitAtomicRenamedFile(fp, error))
2862
{
2863
Error::AddPrefix(error, "Failed to write cache: ");
2864
return false;
2865
}
2866
2867
INFO_LOG("Wrote {} games to hash database", s_state.hashdb_entries.size());
2868
return true;
2869
}
2870
2871
bool Achievements::LoadHashDatabase(const std::string& path, Error* error)
2872
{
2873
FileSystem::ManagedCFilePtr fp = FileSystem::OpenManagedCFile(path.c_str(), "rb", error);
2874
if (!fp)
2875
{
2876
Error::AddPrefix(error, "Failed to open cache for reading: ");
2877
return false;
2878
}
2879
2880
BinaryFileReader reader(fp.get());
2881
const u32 count = reader.ReadU32();
2882
2883
// simple sanity check on file size
2884
constexpr size_t entry_size = (GAME_HASH_LENGTH + sizeof(u32) + sizeof(u32));
2885
if (static_cast<s64>((count * entry_size) + sizeof(u32)) > FileSystem::FSize64(fp.get()))
2886
{
2887
Error::SetStringFmt(error, "Invalid entry count: {}", count);
2888
return false;
2889
}
2890
2891
s_state.hashdb_entries.resize(count);
2892
for (HashDatabaseEntry& entry : s_state.hashdb_entries)
2893
{
2894
reader.Read(entry.hash.data(), entry.hash.size());
2895
reader.ReadU32(&entry.game_id);
2896
reader.ReadU32(&entry.num_achievements);
2897
}
2898
if (reader.HasError())
2899
{
2900
Error::SetStringView(error, "Error while reading cache");
2901
s_state.hashdb_entries = {};
2902
return false;
2903
}
2904
2905
VERBOSE_LOG("Loaded {} entries from cached hash database", s_state.hashdb_entries.size());
2906
return true;
2907
}
2908
2909
const Achievements::HashDatabaseEntry* Achievements::LookupGameHash(const GameHash& hash)
2910
{
2911
if (!s_state.hashdb_loaded) [[unlikely]]
2912
{
2913
// loaded by another thread?
2914
std::unique_lock lock(s_state.mutex);
2915
if (!s_state.hashdb_loaded)
2916
{
2917
Error error;
2918
std::string path = GetHashDatabasePath();
2919
const bool hashdb_exists = FileSystem::FileExists(path.c_str());
2920
if (!hashdb_exists || !LoadHashDatabase(path, &error))
2921
{
2922
if (hashdb_exists)
2923
WARNING_LOG("Failed to load hash database: {}", error.GetDescription());
2924
2925
if (!CreateHashDatabaseFromSeedDatabase(path, &error))
2926
ERROR_LOG("Failed to create hash database from seed database: {}", error.GetDescription());
2927
}
2928
}
2929
2930
s_state.hashdb_loaded = true;
2931
}
2932
2933
const auto iter = std::lower_bound(s_state.hashdb_entries.begin(), s_state.hashdb_entries.end(), hash,
2934
[](const HashDatabaseEntry& entry, const GameHash& search) {
2935
return (std::memcmp(entry.hash.data(), search.data(), GAME_HASH_LENGTH) < 0);
2936
});
2937
return (iter != s_state.hashdb_entries.end() && std::memcmp(iter->hash.data(), hash.data(), GAME_HASH_LENGTH) == 0) ?
2938
&(*iter) :
2939
nullptr;
2940
}
2941
2942
void Achievements::PreloadHashDatabase()
2943
{
2944
const std::string hash_database_path = GetHashDatabasePath();
2945
const std::string progress_database_path = GetProgressDatabasePath();
2946
2947
bool has_hash_database = (s_state.hashdb_loaded && !s_state.hashdb_entries.empty());
2948
const bool has_progress_database = FileSystem::FileExists(progress_database_path.c_str());
2949
2950
// if we don't have a progress database, just redownload everything, it's probably our first login
2951
if (!has_hash_database && has_progress_database && FileSystem::FileExists(hash_database_path.c_str()))
2952
{
2953
// try loading binary cache
2954
VERBOSE_LOG("Trying to load hash database from {}", hash_database_path);
2955
2956
Error error;
2957
has_hash_database = LoadHashDatabase(hash_database_path, &error);
2958
if (!has_hash_database)
2959
ERROR_LOG("Failed to load hash database: {}", error.GetDescription());
2960
}
2961
2962
// don't try to load the hash database from the game list now
2963
s_state.hashdb_loaded = true;
2964
2965
// got everything?
2966
if (has_hash_database && has_progress_database)
2967
return;
2968
2969
// kick off a new download, game list will be notified when it's done
2970
BeginRefreshHashDatabase();
2971
}
2972
2973
FileSystem::ManagedCFilePtr Achievements::OpenProgressDatabase(bool for_write, bool truncate, Error* error)
2974
{
2975
const std::string path = GetProgressDatabasePath();
2976
const FileSystem::FileShareMode share_mode =
2977
for_write ? FileSystem::FileShareMode::DenyReadWrite : FileSystem::FileShareMode::DenyWrite;
2978
#ifdef _WIN32
2979
const char* mode = for_write ? (truncate ? "w+b" : "r+b") : "rb";
2980
#else
2981
// Always open read/write on Linux, since we need it for flock().
2982
const char* mode = truncate ? "w+b" : "r+b";
2983
#endif
2984
2985
FileSystem::ManagedCFilePtr fp = FileSystem::OpenManagedSharedCFile(path.c_str(), mode, share_mode, error);
2986
if (fp)
2987
return fp;
2988
2989
// Doesn't exist? Create it.
2990
if (errno == ENOENT)
2991
{
2992
if (!for_write)
2993
return nullptr;
2994
2995
mode = "w+b";
2996
fp = FileSystem::OpenManagedSharedCFile(path.c_str(), mode, share_mode, error);
2997
if (fp)
2998
return fp;
2999
}
3000
3001
// If there's a sharing violation, try again for 100ms.
3002
if (errno != EACCES)
3003
return nullptr;
3004
3005
Timer timer;
3006
while (timer.GetTimeMilliseconds() <= 100.0f)
3007
{
3008
fp = FileSystem::OpenManagedSharedCFile(path.c_str(), mode, share_mode, error);
3009
if (fp)
3010
return fp;
3011
3012
if (errno != EACCES)
3013
return nullptr;
3014
}
3015
3016
Error::SetStringView(error, "Timed out while trying to open progress database.");
3017
return nullptr;
3018
}
3019
3020
void Achievements::BuildProgressDatabase(const rc_client_all_user_progress_t* allprog)
3021
{
3022
// no point storing it in memory, just write directly to the file
3023
Error error;
3024
FileSystem::ManagedCFilePtr fp = OpenProgressDatabase(true, true, &error);
3025
if (!fp)
3026
{
3027
ERROR_LOG("Failed to build progress database: {}", error.GetDescription());
3028
return;
3029
}
3030
3031
#ifdef HAS_POSIX_FILE_LOCK
3032
FileSystem::POSIXLock lock(fp.get());
3033
#endif
3034
3035
// save a rewrite at the beginning
3036
u32 games_with_unlocks = 0;
3037
for (u32 i = 0; i < allprog->num_entries; i++)
3038
{
3039
games_with_unlocks += BoolToUInt32(
3040
(allprog->entries[i].num_unlocked_achievements + allprog->entries[i].num_unlocked_achievements_hardcore) > 0);
3041
}
3042
3043
BinaryFileWriter writer(fp.get());
3044
writer.WriteU32(games_with_unlocks);
3045
if (games_with_unlocks > 0)
3046
{
3047
for (const rc_client_all_user_progress_entry_t& entry :
3048
std::span<const rc_client_all_user_progress_entry_t>(allprog->entries, allprog->num_entries))
3049
{
3050
if ((entry.num_unlocked_achievements + entry.num_unlocked_achievements_hardcore) == 0)
3051
continue;
3052
3053
writer.WriteU32(entry.game_id);
3054
writer.WriteU16(Truncate16(entry.num_unlocked_achievements));
3055
writer.WriteU16(Truncate16(entry.num_unlocked_achievements_hardcore));
3056
}
3057
}
3058
3059
if (!writer.Flush(&error))
3060
ERROR_LOG("Failed to write progress database: {}", error.GetDescription());
3061
}
3062
3063
void Achievements::UpdateProgressDatabase()
3064
{
3065
// don't write updates in spectator mode
3066
if (rc_client_get_spectator_mode_enabled(s_state.client))
3067
return;
3068
3069
// query list to get both hardcore and softcore counts
3070
rc_client_achievement_list_t* const achievements =
3071
rc_client_create_achievement_list(s_state.client, RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE, 0);
3072
u32 num_achievements = 0;
3073
u32 achievements_unlocked = 0;
3074
u32 achievements_unlocked_hardcore = 0;
3075
if (achievements)
3076
{
3077
for (const rc_client_achievement_bucket_t& bucket :
3078
std::span<const rc_client_achievement_bucket_t>(achievements->buckets, achievements->num_buckets))
3079
{
3080
for (const rc_client_achievement_t* achievement :
3081
std::span<const rc_client_achievement_t*>(bucket.achievements, bucket.num_achievements))
3082
{
3083
achievements_unlocked += BoolToUInt32((achievement->unlocked & RC_CLIENT_ACHIEVEMENT_UNLOCKED_SOFTCORE) != 0);
3084
achievements_unlocked_hardcore +=
3085
BoolToUInt32((achievement->unlocked & RC_CLIENT_ACHIEVEMENT_UNLOCKED_HARDCORE) != 0);
3086
}
3087
3088
num_achievements += bucket.num_achievements;
3089
}
3090
rc_client_destroy_achievement_list(achievements);
3091
}
3092
3093
// update the game list, this should be fairly quick
3094
if (s_state.game_hash.has_value())
3095
{
3096
GameList::UpdateAchievementData(s_state.game_hash.value(), s_state.game_id, num_achievements, achievements_unlocked,
3097
achievements_unlocked_hardcore);
3098
}
3099
3100
// done asynchronously so we don't hitch on disk I/O
3101
Host::QueueAsyncTask([game_id = s_state.game_id, achievements_unlocked, achievements_unlocked_hardcore]() {
3102
// no point storing it in memory, just write directly to the file
3103
Error error;
3104
FileSystem::ManagedCFilePtr fp = OpenProgressDatabase(true, false, &error);
3105
const s64 size = fp ? FileSystem::FSize64(fp.get(), &error) : -1;
3106
if (!fp || size < 0)
3107
{
3108
ERROR_LOG("Failed to update progress database: {}", error.GetDescription());
3109
return;
3110
}
3111
3112
#ifdef HAS_POSIX_FILE_LOCK
3113
FileSystem::POSIXLock lock(fp.get());
3114
#endif
3115
3116
BinaryFileReader reader(fp.get());
3117
const u32 game_count = (size > 0) ? reader.ReadU32() : 0;
3118
3119
// entry exists?
3120
s64 found_offset = -1;
3121
for (u32 i = 0; i < game_count; i++)
3122
{
3123
const u32 check_game_id = reader.ReadU32();
3124
if (check_game_id == game_id)
3125
{
3126
// do we even need to change it?
3127
const u16 current_achievements_unlocked = reader.ReadU16();
3128
const u16 current_achievements_unlocked_hardcore = reader.ReadU16();
3129
if (current_achievements_unlocked == achievements_unlocked &&
3130
current_achievements_unlocked_hardcore == achievements_unlocked_hardcore)
3131
{
3132
VERBOSE_LOG("No update to progress database needed for game {}", game_id);
3133
return;
3134
}
3135
3136
found_offset = FileSystem::FTell64(fp.get()) - sizeof(u16) - sizeof(u16);
3137
break;
3138
}
3139
3140
if (!FileSystem::FSeek64(fp.get(), sizeof(u16) + sizeof(u16), SEEK_CUR, &error)) [[unlikely]]
3141
{
3142
ERROR_LOG("Failed to seek in progress database: {}", error.GetDescription());
3143
return;
3144
}
3145
}
3146
3147
// make sure we had no read errors, don't want to make corrupted files
3148
if (reader.HasError())
3149
{
3150
ERROR_LOG("Failed to read in progress database: {}", error.GetDescription());
3151
return;
3152
}
3153
3154
BinaryFileWriter writer(fp.get());
3155
3156
// append/update the entry
3157
if (found_offset > 0)
3158
{
3159
INFO_LOG("Updating game {} with {}/{} unlocked", game_id, achievements_unlocked, achievements_unlocked_hardcore);
3160
3161
// need to seek when switching read->write
3162
if (!FileSystem::FSeek64(fp.get(), found_offset, SEEK_SET, &error))
3163
{
3164
ERROR_LOG("Failed to write seek in progress database: {}", error.GetDescription());
3165
return;
3166
}
3167
3168
writer.WriteU16(Truncate16(achievements_unlocked));
3169
writer.WriteU16(Truncate16(achievements_unlocked_hardcore));
3170
}
3171
else
3172
{
3173
// don't write zeros to the file. we could still end up with zeros here after reset, but that's rare
3174
if (achievements_unlocked == 0 && achievements_unlocked_hardcore == 0)
3175
return;
3176
3177
INFO_LOG("Appending game {} with {}/{} unlocked", game_id, achievements_unlocked, achievements_unlocked_hardcore);
3178
3179
if (size == 0)
3180
{
3181
// if the file is empty, need to write the header
3182
writer.WriteU32(1);
3183
}
3184
else
3185
{
3186
// update the count
3187
if (!FileSystem::FSeek64(fp.get(), 0, SEEK_SET, &error) || !writer.WriteU32(game_count + 1) ||
3188
!FileSystem::FSeek64(fp.get(), 0, SEEK_END, &error))
3189
{
3190
ERROR_LOG("Failed to write seek/update header in progress database: {}", error.GetDescription());
3191
return;
3192
}
3193
}
3194
3195
writer.WriteU32(game_id);
3196
writer.WriteU16(Truncate16(achievements_unlocked));
3197
writer.WriteU16(Truncate16(achievements_unlocked_hardcore));
3198
}
3199
3200
if (!writer.Flush(&error))
3201
{
3202
ERROR_LOG("Failed to write count in progress database: {}", error.GetDescription());
3203
return;
3204
}
3205
});
3206
}
3207
3208
void Achievements::ClearProgressDatabase()
3209
{
3210
std::string path = GetProgressDatabasePath();
3211
if (FileSystem::FileExists(path.c_str()))
3212
{
3213
INFO_LOG("Deleting progress database {}", path);
3214
3215
Error error;
3216
if (!FileSystem::DeleteFile(path.c_str(), &error))
3217
ERROR_LOG("Failed to delete progress database: {}", error.GetDescription());
3218
}
3219
3220
GameList::UpdateAllAchievementData();
3221
}
3222
3223
Achievements::ProgressDatabase::ProgressDatabase() = default;
3224
3225
Achievements::ProgressDatabase::~ProgressDatabase() = default;
3226
3227
bool Achievements::ProgressDatabase::Load(Error* error)
3228
{
3229
FileSystem::ManagedCFilePtr fp = OpenProgressDatabase(false, false, error);
3230
if (!fp)
3231
return false;
3232
3233
#ifdef HAS_POSIX_FILE_LOCK
3234
FileSystem::POSIXLock lock(fp.get());
3235
#endif
3236
3237
BinaryFileReader reader(fp.get());
3238
const u32 count = reader.ReadU32();
3239
3240
// simple sanity check on file size
3241
constexpr size_t entry_size = (sizeof(u32) + sizeof(u16) + sizeof(u16));
3242
if (static_cast<s64>((count * entry_size) + sizeof(u32)) > FileSystem::FSize64(fp.get()))
3243
{
3244
Error::SetStringFmt(error, "Invalid entry count: {}", count);
3245
return false;
3246
}
3247
3248
m_entries.reserve(count);
3249
for (u32 i = 0; i < count; i++)
3250
{
3251
const Entry entry = {.game_id = reader.ReadU32(),
3252
.num_achievements_unlocked = reader.ReadU16(),
3253
.num_hc_achievements_unlocked = reader.ReadU16()};
3254
3255
// Just in case...
3256
if (std::any_of(m_entries.begin(), m_entries.end(),
3257
[id = entry.game_id](const Entry& e) { return (e.game_id == id); }))
3258
{
3259
WARNING_LOG("Duplicate game ID {}", entry.game_id);
3260
continue;
3261
}
3262
3263
m_entries.push_back(entry);
3264
}
3265
3266
// sort for quick lookup
3267
m_entries.shrink_to_fit();
3268
std::sort(m_entries.begin(), m_entries.end(),
3269
[](const Entry& lhs, const Entry& rhs) { return (lhs.game_id < rhs.game_id); });
3270
3271
return true;
3272
}
3273
3274
const Achievements::ProgressDatabase::Entry* Achievements::ProgressDatabase::LookupGame(u32 game_id) const
3275
{
3276
const auto iter = std::lower_bound(m_entries.begin(), m_entries.end(), game_id,
3277
[](const Entry& entry, u32 search) { return (entry.game_id < search); });
3278
return (iter != m_entries.end() && iter->game_id == game_id) ? &(*iter) : nullptr;
3279
}
3280
3281
#ifdef RC_CLIENT_SUPPORTS_RAINTEGRATION
3282
3283
#include "util/input_manager.h" // Host::GetTopLevelWindowInfo()
3284
3285
#include "common/windows_headers.h"
3286
3287
#include "rc_client_raintegration.h"
3288
3289
namespace Achievements {
3290
3291
static void FinishLoadRAIntegration();
3292
static void FinishLoadRAIntegrationOnCoreThread();
3293
3294
static void RAIntegrationBeginLoadCallback(int result, const char* error_message, rc_client_t* client, void* userdata);
3295
static void RAIntegrationEventHandler(const rc_client_raintegration_event_t* event, rc_client_t* client);
3296
static void RAIntegrationWriteMemoryCallback(uint32_t address, uint8_t* buffer, uint32_t num_bytes,
3297
rc_client_t* client);
3298
static void RAIntegrationGetGameNameCallback(char* buffer, uint32_t buffer_size, rc_client_t* client);
3299
3300
} // namespace Achievements
3301
3302
bool Achievements::IsUsingRAIntegration()
3303
{
3304
return s_state.using_raintegration;
3305
}
3306
3307
bool Achievements::IsRAIntegrationAvailable()
3308
{
3309
return (FileSystem::FileExists(Path::Combine(EmuFolders::AppRoot, "RA_Integration-x64.dll").c_str()) ||
3310
FileSystem::FileExists(Path::Combine(EmuFolders::AppRoot, "RA_Integration.dll").c_str()));
3311
}
3312
3313
bool Achievements::IsRAIntegrationInitializing()
3314
{
3315
return (s_state.using_raintegration && (s_state.load_raintegration_request || s_state.raintegration_loading));
3316
}
3317
3318
void Achievements::BeginLoadRAIntegration()
3319
{
3320
// set the flag so we don't try to log in immediately, need to wait for RAIntegration to load first
3321
s_state.using_raintegration = true;
3322
s_state.raintegration_loading = true;
3323
3324
const std::wstring wapproot = StringUtil::UTF8StringToWideString(EmuFolders::AppRoot);
3325
s_state.load_raintegration_request = rc_client_begin_load_raintegration_deferred(
3326
s_state.client, wapproot.c_str(), RAIntegrationBeginLoadCallback, nullptr);
3327
}
3328
3329
void Achievements::RAIntegrationBeginLoadCallback(int result, const char* error_message, rc_client_t* client,
3330
void* userdata)
3331
{
3332
s_state.load_raintegration_request = nullptr;
3333
3334
if (result != RC_OK)
3335
{
3336
s_state.raintegration_loading = false;
3337
3338
std::string message = fmt::format("Failed to load RAIntegration:\n{}", error_message ? error_message : "");
3339
Host::ReportErrorAsync("RAIntegration Error", message);
3340
return;
3341
}
3342
3343
INFO_COLOR_LOG(StrongGreen, "RAIntegration DLL loaded, initializing.");
3344
Host::RunOnUIThread(&Achievements::FinishLoadRAIntegration);
3345
}
3346
3347
void Achievements::FinishLoadRAIntegration()
3348
{
3349
const std::optional<WindowInfo> wi = Host::GetTopLevelWindowInfo();
3350
const auto lock = GetLock();
3351
3352
// disabled externally?
3353
if (!s_state.using_raintegration)
3354
return;
3355
3356
const char* error_message = nullptr;
3357
const int res = rc_client_finish_load_raintegration(
3358
s_state.client, (wi.has_value() && wi->type == WindowInfoType::Win32) ? static_cast<HWND>(wi->window_handle) : NULL,
3359
"DuckStation", g_scm_tag_str, &error_message);
3360
if (res != RC_OK)
3361
{
3362
std::string message = fmt::format("Failed to initialize RAIntegration:\n{}", error_message ? error_message : "");
3363
Host::ReportErrorAsync("RAIntegration Error", message);
3364
s_state.using_raintegration = false;
3365
Host::RunOnCoreThread(&Achievements::FinishLoadRAIntegrationOnCoreThread);
3366
return;
3367
}
3368
3369
rc_client_raintegration_set_write_memory_function(s_state.client, RAIntegrationWriteMemoryCallback);
3370
rc_client_raintegration_set_console_id(s_state.client, RC_CONSOLE_PLAYSTATION);
3371
rc_client_raintegration_set_get_game_name_function(s_state.client, RAIntegrationGetGameNameCallback);
3372
rc_client_raintegration_set_event_handler(s_state.client, RAIntegrationEventHandler);
3373
3374
Host::OnRAIntegrationMenuChanged();
3375
3376
Host::RunOnCoreThread(&Achievements::FinishLoadRAIntegrationOnCoreThread);
3377
}
3378
3379
void Achievements::FinishLoadRAIntegrationOnCoreThread()
3380
{
3381
// note: this is executed even for the failure case.
3382
// we want to finish initializing with internal client if RAIntegration didn't load.
3383
const auto lock = GetLock();
3384
s_state.raintegration_loading = false;
3385
FinishInitialize();
3386
}
3387
3388
void Achievements::UnloadRAIntegration()
3389
{
3390
DebugAssert(s_state.using_raintegration && s_state.client);
3391
3392
if (s_state.load_raintegration_request)
3393
{
3394
rc_client_abort_async(s_state.client, s_state.load_raintegration_request);
3395
s_state.load_raintegration_request = nullptr;
3396
}
3397
3398
// Have to unload it on the UI thread, otherwise the DLL unload races the UI thread message processing.
3399
s_state.http_downloader->WaitForAllRequests();
3400
s_state.http_downloader.reset();
3401
s_state.raintegration_loading = false;
3402
s_state.using_raintegration = false;
3403
Host::RunOnUIThread([client = std::exchange(s_state.client, nullptr)]() {
3404
rc_client_unload_raintegration(client);
3405
rc_client_destroy(client);
3406
});
3407
3408
Host::OnRAIntegrationMenuChanged();
3409
}
3410
3411
void Achievements::RAIntegrationEventHandler(const rc_client_raintegration_event_t* event, rc_client_t* client)
3412
{
3413
switch (event->type)
3414
{
3415
case RC_CLIENT_RAINTEGRATION_EVENT_MENUITEM_CHECKED_CHANGED:
3416
case RC_CLIENT_RAINTEGRATION_EVENT_MENU_CHANGED:
3417
{
3418
Host::OnRAIntegrationMenuChanged();
3419
}
3420
break;
3421
3422
case RC_CLIENT_RAINTEGRATION_EVENT_HARDCORE_CHANGED:
3423
{
3424
// Could get called from a different thread...
3425
Host::RunOnCoreThread([]() {
3426
const auto lock = GetLock();
3427
OnHardcoreModeChanged(rc_client_get_hardcore_enabled(s_state.client) != 0, false, false);
3428
});
3429
}
3430
break;
3431
3432
case RC_CLIENT_RAINTEGRATION_EVENT_PAUSE:
3433
{
3434
Host::RunOnCoreThread([]() { System::PauseSystem(true); });
3435
}
3436
break;
3437
3438
default:
3439
ERROR_LOG("Unhandled RAIntegration event {}", static_cast<u32>(event->type));
3440
break;
3441
}
3442
}
3443
3444
void Achievements::RAIntegrationWriteMemoryCallback(uint32_t address, uint8_t* buffer, uint32_t num_bytes,
3445
rc_client_t* client)
3446
{
3447
if ((address + num_bytes) > 0x200400U) [[unlikely]]
3448
return;
3449
3450
// This can be called on the UI thread, so always queue it.
3451
llvm::SmallVector<u8, 16> data(buffer, buffer + num_bytes);
3452
Host::RunOnCoreThread([address, data = std::move(data)]() {
3453
u8* src = (address >= 0x200000U) ? CPU::g_state.scratchpad.data() : Bus::g_ram;
3454
const u32 offset = (address & Bus::RAM_2MB_MASK); // size guarded by check above
3455
3456
switch (data.size())
3457
{
3458
case 1:
3459
std::memcpy(&src[offset], data.data(), 1);
3460
break;
3461
case 2:
3462
std::memcpy(&src[offset], data.data(), 2);
3463
break;
3464
case 4:
3465
std::memcpy(&src[offset], data.data(), 4);
3466
break;
3467
default:
3468
[[unlikely]] std::memcpy(&src[offset], data.data(), data.size());
3469
break;
3470
}
3471
});
3472
}
3473
3474
void Achievements::RAIntegrationGetGameNameCallback(char* buffer, uint32_t buffer_size, rc_client_t* client)
3475
{
3476
StringUtil::Strlcpy(buffer, System::GetGameTitle(), buffer_size);
3477
}
3478
3479
#else
3480
3481
bool Achievements::IsUsingRAIntegration()
3482
{
3483
return false;
3484
}
3485
3486
bool Achievements::IsRAIntegrationAvailable()
3487
{
3488
return false;
3489
}
3490
3491
bool Achievements::IsRAIntegrationInitializing()
3492
{
3493
return false;
3494
}
3495
3496
#endif
3497
3498