Achievements: Add option to prefetch badges

Will pre-download locked badges to avoid load delays when unlocking.
pull/3699/head
Stenzek 5 months ago
parent dc31d7d67f
commit 2bccb29297
No known key found for this signature in database

@ -132,11 +132,15 @@ static void UpdateModeSettings(const Settings& old_config);
static DynamicHeapArray<u8> SaveStateToBuffer();
static void LoadStateFromBuffer(std::span<const u8> data, std::unique_lock<std::recursive_mutex>& lock);
static bool SaveStateToBuffer(std::span<u8> data);
static std::string GetAchievementBadgeURL(const rc_client_achievement_t* achievement, u32 image_type);
static std::string GetImageURL(const char* image_name, u32 type);
static std::string GetLocalImagePath(const std::string_view image_name, u32 type);
static void DownloadImage(std::string url, std::string cache_path);
static void PrefetchNextAchievementBadge();
static void PrefetchNextAchievementBadge(const rc_client_achievement_t* const last_cheevo);
static void PrefetchAllAchievementBadges();
static void SendNextPrefetchBadgeRequest();
static void ClearPrefetchBadgeRequests();
static TinyString DecryptLoginToken(std::string_view encrypted_token, std::string_view username);
static TinyString EncryptLoginToken(std::string_view token, std::string_view username);
@ -256,6 +260,8 @@ struct State
rc_client_all_user_progress_t* fetch_all_progress_result = nullptr;
rc_client_async_handle_t* refresh_all_progress_request = nullptr;
std::vector<std::pair<std::string, std::string>> prefetch_badge_requests; // (path, url)
#ifdef RC_CLIENT_SUPPORTS_RAINTEGRATION
rc_client_async_handle_t* load_raintegration_request = nullptr;
bool using_raintegration = false;
@ -504,6 +510,93 @@ void Achievements::PrefetchNextAchievementBadge(const rc_client_achievement_t* c
GetAchievementBadgePath(next_cheevo, false);
}
void Achievements::PrefetchAllAchievementBadges()
{
static constexpr u32 PREFETCH_IMAGE_TYPE = RC_IMAGE_TYPE_ACHIEVEMENT;
// This is here so that we can hopefully avoid the delay in downloading the badge image on unlock.
if (!HasAchievements())
return;
rc_client_achievement_list_t* const achievements =
rc_client_create_achievement_list(Achievements::GetClient(), RC_CLIENT_ACHIEVEMENT_CATEGORY_CORE_AND_UNOFFICIAL,
RC_CLIENT_ACHIEVEMENT_LIST_GROUPING_LOCK_STATE);
if (!achievements)
return;
for (u32 i = 0; i < achievements->num_buckets; i++)
{
// Ignore unlocked achievements, since we're not going to be showing a notification for them.
const rc_client_achievement_bucket_t& bucket = achievements->buckets[i];
if (bucket.bucket_type != RC_CLIENT_ACHIEVEMENT_BUCKET_LOCKED)
continue;
for (u32 j = 0; j < bucket.num_achievements; j++)
{
const rc_client_achievement_t* const cheevo = bucket.achievements[j];
std::string path = GetLocalImagePath(cheevo->badge_name, PREFETCH_IMAGE_TYPE);
if (path.empty() || FileSystem::FileExists(path.c_str()))
continue;
std::string url = GetAchievementBadgeURL(cheevo, PREFETCH_IMAGE_TYPE);
VERBOSE_LOG("Prefetching badge for locked achievement '{}' ({})", cheevo->title, cheevo->badge_url);
s_state.prefetch_badge_requests.emplace_back(std::move(path), std::move(url));
}
}
rc_client_destroy_achievement_list(achievements);
if (s_state.prefetch_badge_requests.empty())
return;
// reverse the list, fetch the first achievement first since it's the most likely to be unlocked next
std::ranges::reverse(s_state.prefetch_badge_requests);
SendNextPrefetchBadgeRequest();
}
void Achievements::SendNextPrefetchBadgeRequest()
{
if (s_state.prefetch_badge_requests.empty())
return;
std::string cache_path = std::move(s_state.prefetch_badge_requests.back().first);
std::string url = std::move(s_state.prefetch_badge_requests.back().second);
s_state.prefetch_badge_requests.pop_back();
// free memory when done
if (s_state.prefetch_badge_requests.empty())
s_state.prefetch_badge_requests = {};
auto callback = [cache_path = std::move(cache_path)](s32 status_code, const Error& error,
const std::string& content_type,
HTTPDownloader::Request::Data data) mutable {
if (status_code != HTTPDownloader::HTTP_STATUS_OK)
{
ERROR_LOG("Failed to download badge '{}': {}", Path::GetFileName(cache_path), error.GetDescription());
return;
}
Error write_error;
if (!FileSystem::WriteBinaryFile(cache_path.c_str(), data, &write_error))
{
ERROR_LOG("Failed to write badge image to '{}': {}", cache_path, write_error.GetDescription());
return;
}
VideoThread::RunOnThread(
[cache_path = std::move(cache_path)]() { FullscreenUI::InvalidateCachedTexture(cache_path); });
SendNextPrefetchBadgeRequest();
};
s_state.http_downloader->CreateRequest(std::move(url), std::move(callback));
if (!s_state.prefetch_badge_requests.empty())
VERBOSE_LOG("{} badge requests remaining", s_state.prefetch_badge_requests.size());
}
void Achievements::ClearPrefetchBadgeRequests()
{
s_state.prefetch_badge_requests = {};
}
bool Achievements::IsActive()
{
return (s_state.client != nullptr);
@ -1130,6 +1223,8 @@ void Achievements::GameChanged(CDImage* image)
if (!IdentifyGame(image))
return;
ClearPrefetchBadgeRequests();
// cancel previous requests
if (s_state.load_game_request)
{
@ -1200,6 +1295,9 @@ void Achievements::BeginLoadGame()
return;
}
// Clear prefetch requests, since if we're loading state we'll get blocked until they all download otherwise.
ClearPrefetchBadgeRequests();
s_state.load_game_request = rc_client_begin_load_game(s_state.client, GameHashToString(s_state.game_hash).c_str(),
ClientLoadGameCallback, nullptr);
}
@ -1294,7 +1392,12 @@ void Achievements::ClientLoadGameCallback(int result, const char* error_message,
// update progress database on first load, in case it was played on another PC
UpdateGameSummary(true);
PrefetchNextAchievementBadge();
// Defer starting the prefetch, because otherwise when loading state we'll block until it's all downloaded.
if (g_settings.achievements_prefetch_badges)
Host::RunOnCoreThread(&Achievements::PrefetchAllAchievementBadges);
else
PrefetchNextAchievementBadge();
// needed for notifications
SoundEffectManager::EnsureInitialized();
@ -1307,6 +1410,8 @@ void Achievements::ClearGameInfo()
{
FullscreenUI::ClearAchievementsState();
ClearPrefetchBadgeRequests();
if (s_state.load_game_request)
{
rc_client_abort_async(s_state.client, s_state.load_game_request);
@ -1958,6 +2063,24 @@ bool Achievements::DoState(StateWrapper& sw)
}
}
std::string Achievements::GetAchievementBadgeURL(const rc_client_achievement_t* achievement, u32 image_type)
{
std::string url;
const char* url_ptr;
// RAIntegration doesn't set the URL fields.
if (IsUsingRAIntegration() ||
!(url_ptr =
(image_type == RC_IMAGE_TYPE_ACHIEVEMENT_LOCKED) ? achievement->badge_locked_url : achievement->badge_url))
{
return GetImageURL(achievement->badge_name, image_type);
}
else
{
return std::string(url_ptr);
}
}
std::string Achievements::GetAchievementBadgePath(const rc_client_achievement_t* achievement, bool locked,
bool download_if_missing)
{
@ -1965,15 +2088,7 @@ std::string Achievements::GetAchievementBadgePath(const rc_client_achievement_t*
const std::string path = GetLocalImagePath(achievement->badge_name, image_type);
if (download_if_missing && !path.empty() && !FileSystem::FileExists(path.c_str()))
{
std::string url;
const char* url_ptr;
// RAIntegration doesn't set the URL fields.
if (IsUsingRAIntegration() || !(url_ptr = locked ? achievement->badge_locked_url : achievement->badge_url))
url = GetImageURL(achievement->badge_name, image_type);
else
url = std::string(url_ptr);
std::string url = GetAchievementBadgeURL(achievement, image_type);
if (url.empty()) [[unlikely]]
{
ReportFmtError("Achievement {} with badge name {} has no badge URL", achievement->id, achievement->badge_name);
@ -1981,7 +2096,7 @@ std::string Achievements::GetAchievementBadgePath(const rc_client_achievement_t*
else
{
DEV_LOG("Downloading badge for achievement {} from URL: {}", achievement->id, url);
DownloadImage(std::string(url), path);
DownloadImage(std::move(url), path);
}
}

@ -4979,6 +4979,10 @@ void FullscreenUI::DrawAchievementsSettingsPage(std::unique_lock<std::mutex>& se
bsi, FSUI_ICONVSTR(ICON_FA_MUSIC, "Sound Effects"),
FSUI_VSTR("Plays sound effects for events such as achievement unlocks and leaderboard submissions."), "Cheevos",
"SoundEffects", true, enabled);
DrawToggleSetting(bsi, FSUI_ICONVSTR(ICON_FA_DOWNLOAD, "Prefetch Badges"),
FSUI_VSTR("Downloads all locked achievement badges while starting the game. This will reduce "
"delays in the images being shown when unlocking achievements."),
"Cheevos", "PrefetchBadges", false, enabled);
DrawEnumSetting(bsi, FSUI_ICONVSTR(ICON_FA_ENVELOPE, "Notification Location"),
FSUI_VSTR("Selects the screen location for achievement and leaderboard notifications."), "Cheevos",
@ -5038,7 +5042,7 @@ void FullscreenUI::DrawAchievementsSettingsPage(std::unique_lock<std::mutex>& se
if (is_custom)
DrawIntSpinBoxSetting(bsi, custom_title, custom_summary, "Cheevos", key, 100, 1, 500, 1, "%d%%", enabled);
};
draw_scale_setting("NotificationScale", FSUI_ICONVSTR(ICON_FA_MAGNIFYING_GLASS, "Notification Scale"),
draw_scale_setting("NotificationScale", FSUI_ICONVSTR(ICON_FA_MAGNIFYING_GLASS, "Notification Size"),
FSUI_VSTR("Determines the size of achievement notification popups."),
FSUI_ICONVSTR(ICON_FA_EXPAND, "Custom Notification Scale"),
FSUI_VSTR("Sets the custom scale percentage for achievement notifications."));

@ -285,6 +285,7 @@ TRANSLATE_NOOP("FullscreenUI", "Do you want to continue from the automatic save
TRANSLATE_NOOP("FullscreenUI", "Double-Click Toggles Fullscreen");
TRANSLATE_NOOP("FullscreenUI", "Download Covers");
TRANSLATE_NOOP("FullscreenUI", "Download Game Icons");
TRANSLATE_NOOP("FullscreenUI", "Downloads all locked achievement badges while starting the game. This will reduce delays in the images being shown when unlocking achievements.");
TRANSLATE_NOOP("FullscreenUI", "Downloads covers from a user-specified URL template.");
TRANSLATE_NOOP("FullscreenUI", "Downloads icons for all games from RetroAchievements.");
TRANSLATE_NOOP("FullscreenUI", "Downsamples the rendered image prior to displaying it. Can improve overall image quality in mixed 2D/3D games.");
@ -519,7 +520,7 @@ TRANSLATE_NOOP("FullscreenUI", "None (Normal Speed)");
TRANSLATE_NOOP("FullscreenUI", "Not Logged In");
TRANSLATE_NOOP("FullscreenUI", "Not Scanning Subdirectories");
TRANSLATE_NOOP("FullscreenUI", "Notification Location");
TRANSLATE_NOOP("FullscreenUI", "Notification Scale");
TRANSLATE_NOOP("FullscreenUI", "Notification Size");
TRANSLATE_NOOP("FullscreenUI", "Notifications");
TRANSLATE_NOOP("FullscreenUI", "OK");
TRANSLATE_NOOP("FullscreenUI", "OSD Scale");
@ -563,6 +564,7 @@ TRANSLATE_NOOP("FullscreenUI", "Post-Processing Settings");
TRANSLATE_NOOP("FullscreenUI", "Post-processing chain cleared.");
TRANSLATE_NOOP("FullscreenUI", "Post-processing shaders reloaded.");
TRANSLATE_NOOP("FullscreenUI", "Prefer OpenGL ES Context");
TRANSLATE_NOOP("FullscreenUI", "Prefetch Badges");
TRANSLATE_NOOP("FullscreenUI", "Preload Images to RAM");
TRANSLATE_NOOP("FullscreenUI", "Preload Replacement Textures");
TRANSLATE_NOOP("FullscreenUI", "Preserve Projection Precision");

@ -496,6 +496,7 @@ void Settings::Load(const SettingsInterface& si, const SettingsInterface& contro
achievements_leaderboard_trackers = si.GetBoolValue("Cheevos", "LeaderboardTrackers", true);
achievements_sound_effects = si.GetBoolValue("Cheevos", "SoundEffects", true);
achievements_progress_indicators = si.GetBoolValue("Cheevos", "ProgressIndicators", true);
achievements_prefetch_badges = si.GetBoolValue("Cheevos", "PrefetchBadges", false);
achievements_notification_location =
ParseNotificationLocation(si.GetStringValue("Cheevos", "NotificationLocation").c_str())
.value_or(DEFAULT_ACHIEVEMENT_NOTIFICATION_LOCATION);
@ -830,6 +831,7 @@ void Settings::Save(SettingsInterface& si, bool ignore_base) const
si.SetBoolValue("Cheevos", "LeaderboardTrackers", achievements_leaderboard_trackers);
si.SetBoolValue("Cheevos", "SoundEffects", achievements_sound_effects);
si.SetBoolValue("Cheevos", "ProgressIndicators", achievements_progress_indicators);
si.SetBoolValue("Cheevos", "PrefetchBadges", achievements_prefetch_badges);
si.SetStringValue("Cheevos", "NotificationLocation", GetNotificationLocationName(achievements_notification_location));
si.SetStringValue("Cheevos", "IndicatorLocation", GetNotificationLocationName(achievements_indicator_location));
si.SetStringValue("Cheevos", "ChallengeIndicatorMode",

@ -369,6 +369,7 @@ struct Settings : public GPUSettings
bool achievements_leaderboard_trackers : 1 = true;
bool achievements_sound_effects : 1 = true;
bool achievements_progress_indicators : 1 = true;
bool achievements_prefetch_badges : 1 = false;
u8 achievements_notification_duration = DEFAULT_ACHIEVEMENT_NOTIFICATION_TIME;
u8 achievements_leaderboard_duration = DEFAULT_LEADERBOARD_NOTIFICATION_TIME;

@ -50,6 +50,7 @@ AchievementSettingsWidget::AchievementSettingsWidget(SettingsWindow* dialog, QWi
Settings::DEFAULT_ACHIEVEMENT_NOTIFICATION_LOCATION, NotificationLocation::MaxCount);
SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.leaderboardTrackers, "Cheevos", "LeaderboardTrackers", true);
SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.soundEffects, "Cheevos", "SoundEffects", true);
SettingWidgetBinder::BindWidgetToBoolSetting(sif, m_ui.prefetchBadges, "Cheevos", "PrefetchBadges", false);
SettingWidgetBinder::BindWidgetToEnumSetting(
sif, m_ui.challengeIndicatorMode, "Cheevos", "ChallengeIndicatorMode",
&Settings::ParseAchievementChallengeIndicatorMode, &Settings::GetAchievementChallengeIndicatorModeName,
@ -63,8 +64,8 @@ AchievementSettingsWidget::AchievementSettingsWidget(SettingsWindow* dialog, QWi
m_ui.changeSoundsLink->setText(
QStringLiteral("<a href=\"https://github.com/stenzek/duckstation/wiki/Resource-Overrides\"><span "
"style=\"text-decoration: none;\">%1</span></a>")
.arg(tr("Change Sounds")));
"style=\"text-decoration: none;\">&nbsp;%1</span></a>")
.arg(tr("(Customize)")));
dialog->registerWidgetHelp(m_ui.enable, tr("Enable Achievements"), tr("Unchecked"),
tr("When enabled and logged in, DuckStation will scan for achievements on startup."));
@ -90,9 +91,12 @@ AchievementSettingsWidget::AchievementSettingsWidget(SettingsWindow* dialog, QWi
dialog->registerWidgetHelp(
m_ui.soundEffects, tr("Enable Sound Effects"), tr("Checked"),
tr("Plays sound effects for events such as achievement unlocks and leaderboard submissions."));
dialog->registerWidgetHelp(m_ui.prefetchBadges, tr("Prefetch Badges"), tr("Unchecked"),
tr("Downloads all locked achievement badges while starting the game. This will reduce "
"delays in the images being shown when unlocking achievements."));
dialog->registerWidgetHelp(m_ui.notificationLocation, tr("Notification Location"), tr("Top Left"),
tr("Selects the screen location for achievement and leaderboard notifications."));
dialog->registerWidgetHelp(m_ui.notificationScale, tr("Notification Scale"), tr("Automatic"),
dialog->registerWidgetHelp(m_ui.notificationScale, tr("Notification Size"), tr("Automatic"),
tr("Determines the size of achievement notification popups. Automatic will use the same "
"scaling as the Big Picture UI."));
dialog->registerWidgetHelp(m_ui.notificationScaleCustom, tr("Custom Notification Scale"), tr("100%"),
@ -103,7 +107,7 @@ AchievementSettingsWidget::AchievementSettingsWidget(SettingsWindow* dialog, QWi
dialog->registerWidgetHelp(
m_ui.indicatorLocation, tr("Indicator Location"), tr("Bottom Right"),
tr("Selects the screen location for challenge/progress indicators, and leaderboard trackers."));
dialog->registerWidgetHelp(m_ui.indicatorScale, tr("Indicator Scale"), tr("Automatic"),
dialog->registerWidgetHelp(m_ui.indicatorScale, tr("Indicator Size"), tr("Automatic"),
tr("Determines the size of challenge/progress indicators. Automatic will use the same "
"scaling as the Big Picture UI."));
dialog->registerWidgetHelp(m_ui.indicatorScaleCustom, tr("Custom Indicator Scale"), tr("100%"),

@ -228,22 +228,33 @@
</layout>
</item>
<item row="2" column="0">
<widget class="QCheckBox" name="soundEffects">
<property name="text">
<string>Enable Sound Effects</string>
<layout class="QHBoxLayout" name="horizontalLayout_4" stretch="0,1">
<property name="spacing">
<number>0</number>
</property>
</widget>
<item>
<widget class="QCheckBox" name="soundEffects">
<property name="text">
<string>Enable Sound Effects</string>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="changeSoundsLink">
<property name="openExternalLinks">
<bool>true</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::TextInteractionFlag::LinksAccessibleByKeyboard|Qt::TextInteractionFlag::LinksAccessibleByMouse</set>
</property>
</widget>
</item>
</layout>
</item>
<item row="2" column="1">
<widget class="QLabel" name="changeSoundsLink">
<property name="alignment">
<set>Qt::AlignmentFlag::AlignRight|Qt::AlignmentFlag::AlignTrailing|Qt::AlignmentFlag::AlignVCenter</set>
</property>
<property name="openExternalLinks">
<bool>true</bool>
</property>
<property name="textInteractionFlags">
<set>Qt::TextInteractionFlag::LinksAccessibleByKeyboard|Qt::TextInteractionFlag::LinksAccessibleByMouse</set>
<widget class="QCheckBox" name="prefetchBadges">
<property name="text">
<string>Prefetch Badges</string>
</property>
</widget>
</item>
@ -260,7 +271,7 @@
<item row="4" column="0">
<widget class="QLabel" name="notificationScaleLabel">
<property name="text">
<string>Notification Scale:</string>
<string>Notification Size:</string>
</property>
</widget>
</item>
@ -316,7 +327,7 @@
<item row="2" column="0">
<widget class="QLabel" name="indicatorScaleLabel">
<property name="text">
<string>Indicator Scale:</string>
<string>Indicator Size:</string>
</property>
</widget>
</item>

Loading…
Cancel
Save