diff --git a/data/resources/images/trophy-icon-gray.svg b/data/resources/images/trophy-icon-gray.svg
new file mode 100644
index 0000000000..4cc2a87b46
--- /dev/null
+++ b/data/resources/images/trophy-icon-gray.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/data/resources/images/trophy-icon-star.svg b/data/resources/images/trophy-icon-star.svg
new file mode 100644
index 0000000000..9fdadaa842
--- /dev/null
+++ b/data/resources/images/trophy-icon-star.svg
@@ -0,0 +1,16 @@
+
\ No newline at end of file
diff --git a/data/resources/images/trophy-icon.svg b/data/resources/images/trophy-icon.svg
new file mode 100644
index 0000000000..118eddf539
--- /dev/null
+++ b/data/resources/images/trophy-icon.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/src/core/achievements.cpp b/src/core/achievements.cpp
index c143c9ac53..4e24767ccf 100644
--- a/src/core/achievements.cpp
+++ b/src/core/achievements.cpp
@@ -9,6 +9,7 @@
#include "bus.h"
#include "cpu_core.h"
#include "fullscreen_ui.h"
+#include "game_list.h"
#include "gpu_thread.h"
#include "host.h"
#include "imgui_overlays.h"
@@ -2209,13 +2210,12 @@ void Achievements::Logout()
rc_client_logout(s_state.client);
}
- ClearProgressDatabase();
-
INFO_LOG("Clearing credentials...");
Host::DeleteBaseSettingValue("Cheevos", "Username");
Host::DeleteBaseSettingValue("Cheevos", "Token");
Host::DeleteBaseSettingValue("Cheevos", "LoginTimestamp");
Host::CommitBaseSettingChanges();
+ ClearProgressDatabase();
}
bool Achievements::ConfirmSystemReset()
@@ -3836,6 +3836,9 @@ void Achievements::FinishRefreshHashDatabase()
s_state.fetch_all_progress_result = nullptr;
rc_client_destroy_hash_library(s_state.fetch_hash_library_result);
s_state.fetch_hash_library_result = nullptr;
+
+ // update game list, we might have some new games that weren't in the seed database
+ GameList::UpdateAllAchievementData();
}
void Achievements::BuildHashDatabase(const rc_client_hash_library_t* hashlib,
@@ -4204,8 +4207,6 @@ void Achievements::BuildProgressDatabase(const rc_client_all_progress_list_t* al
if (!writer.Flush(&error))
ERROR_LOG("Failed to write progress database: {}", error.GetDescription());
-
- // TODO: Notify game list
}
void Achievements::UpdateProgressDatabase(bool force)
@@ -4214,7 +4215,13 @@ void Achievements::UpdateProgressDatabase(bool force)
if (rc_client_get_spectator_mode_enabled(s_state.client))
return;
- // TODO: Update in game list
+ // update the game list, this should be fairly quick
+ if (!s_state.game_hash.has_value())
+ {
+ GameList::UpdateAchievementData(s_state.game_hash.value(), s_state.game_id,
+ s_state.game_summary.num_core_achievements,
+ s_state.game_summary.num_unlocked_achievements, IsHardcoreModeActive());
+ }
// done asynchronously so we don't hitch on disk I/O
System::QueueAsyncTask([game_id = s_state.game_id,
@@ -4339,6 +4346,8 @@ void Achievements::ClearProgressDatabase()
if (!FileSystem::DeleteFile(path.c_str(), &error))
ERROR_LOG("Failed to delete progress database: {}", error.GetDescription());
}
+
+ GameList::UpdateAllAchievementData();
}
Achievements::ProgressDatabase::ProgressDatabase() = default;
diff --git a/src/core/fullscreen_ui.cpp b/src/core/fullscreen_ui.cpp
index 28972c4af1..d6e98894c2 100644
--- a/src/core/fullscreen_ui.cpp
+++ b/src/core/fullscreen_ui.cpp
@@ -117,6 +117,7 @@ using ImGuiFullscreen::IsFocusResetFromWindowChange;
using ImGuiFullscreen::IsFocusResetQueued;
using ImGuiFullscreen::IsGamepadInputSource;
using ImGuiFullscreen::LayoutScale;
+using ImGuiFullscreen::LayoutUnscale;
using ImGuiFullscreen::LoadTexture;
using ImGuiFullscreen::MenuButton;
using ImGuiFullscreen::MenuButtonFrame;
@@ -442,6 +443,7 @@ static void SwitchToGameList();
static void PopulateGameListEntryList();
static GPUTexture* GetTextureForGameListEntryType(GameList::EntryType type);
static GPUTexture* GetGameListCover(const GameList::Entry* entry, bool fallback_to_icon);
+static GPUTexture* GetGameListCoverTrophy(const GameList::Entry* entry, const ImVec2& image_size);
static GPUTexture* GetCoverForCurrentGame();
//////////////////////////////////////////////////////////////////////////
@@ -543,6 +545,7 @@ struct ALIGN_TO_CACHE_LINE UIState
std::unordered_map icon_image_map;
std::vector game_list_sorted_entries;
GameListView game_list_view = GameListView::Grid;
+ bool game_list_show_trophy_icons = true;
};
} // namespace
@@ -7478,6 +7481,28 @@ void FullscreenUI::PopulateGameListEntryList()
}
}
break;
+
+ case 8: // Achievements
+ {
+ // sort by unlock percentage
+ const float unlock_lhs =
+ (lhs->num_achievements > 0) ?
+ (static_cast(std::max(lhs->unlocked_achievements, lhs->unlocked_achievements_hc)) /
+ static_cast(lhs->num_achievements)) :
+ 0;
+ const float unlock_rhs =
+ (rhs->num_achievements > 0) ?
+ (static_cast(std::max(rhs->unlocked_achievements, rhs->unlocked_achievements_hc)) /
+ static_cast(rhs->num_achievements)) :
+ 0;
+ if (std::abs(unlock_lhs - unlock_rhs) >= 0.0001f)
+ return reverse ? (unlock_lhs >= unlock_rhs) : (unlock_lhs < unlock_rhs);
+
+ // order by achievement count
+ if (lhs->num_achievements != rhs->num_achievements)
+ return reverse ? (rhs->num_achievements < lhs->num_achievements) :
+ (lhs->num_achievements < rhs->num_achievements);
+ }
}
// fallback to title when all else is equal
@@ -7752,6 +7777,21 @@ void FullscreenUI::DrawGameList(const ImVec2& heading_size)
// release date
ImGui::Text(FSUI_CSTR("Release Date: %s"), selected_entry->GetReleaseDateString().c_str());
+ // achievements
+ if (selected_entry->num_achievements > 0)
+ {
+ if (selected_entry->unlocked_achievements_hc > 0)
+ {
+ ImGui::Text(FSUI_CSTR("Achievements: %u (%u) / %u"), selected_entry->unlocked_achievements,
+ selected_entry->unlocked_achievements_hc, selected_entry->num_achievements);
+ }
+ else
+ {
+ ImGui::Text(FSUI_CSTR("Achievements: %u / %u"), selected_entry->unlocked_achievements,
+ selected_entry->num_achievements);
+ }
+ }
+
// compatibility
ImGui::TextUnformatted(FSUI_CSTR("Compatibility: "));
ImGui::SameLine();
@@ -7877,6 +7917,15 @@ void FullscreenUI::DrawGameGrid(const ImVec2& heading_size)
ImGui::GetWindowDrawList()->AddImage(cover_texture, image_rect.Min, image_rect.Max, ImVec2(0.0f, 0.0f),
ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255));
+ GPUTexture* const cover_trophy = GetGameListCoverTrophy(entry, image_size);
+ if (cover_trophy)
+ {
+ const ImVec2 trophy_size =
+ ImVec2(static_cast(cover_trophy->GetWidth()), static_cast(cover_trophy->GetHeight()));
+ ImGui::GetWindowDrawList()->AddImage(cover_trophy, image_rect.Max - trophy_size, image_rect.Max,
+ ImVec2(0.0f, 0.0f), ImVec2(1.0f, 1.0f), IM_COL32(255, 255, 255, 255));
+ }
+
const ImRect title_bb(ImVec2(bb.Min.x, bb.Min.y + image_height + title_spacing), bb.Max);
const std::string_view title(
std::string_view(entry->title).substr(0, (entry->title.length() > 31) ? 31 : std::string_view::npos));
@@ -8174,8 +8223,16 @@ void FullscreenUI::DrawGameListSettingsWindow()
{
static constexpr const char* view_types[] = {FSUI_NSTR("Game Grid"), FSUI_NSTR("Game List")};
static constexpr const char* sort_types[] = {
- FSUI_NSTR("Type"), FSUI_NSTR("Serial"), FSUI_NSTR("Title"), FSUI_NSTR("File Title"),
- FSUI_NSTR("Time Played"), FSUI_NSTR("Last Played"), FSUI_NSTR("File Size"), FSUI_NSTR("Uncompressed Size")};
+ FSUI_NSTR("Type"),
+ FSUI_NSTR("Serial"),
+ FSUI_NSTR("Title"),
+ FSUI_NSTR("File Title"),
+ FSUI_NSTR("Time Played"),
+ FSUI_NSTR("Last Played"),
+ FSUI_NSTR("File Size"),
+ FSUI_NSTR("Uncompressed Size"),
+ FSUI_NSTR("Achievement Unlock/Count"),
+ };
DrawIntListSetting(bsi, FSUI_ICONSTR(ICON_FA_BORDER_ALL, "Default View"),
FSUI_CSTR("Selects the view that the game list will open to."), "Main",
@@ -8190,6 +8247,13 @@ void FullscreenUI::DrawGameListSettingsWindow()
DrawToggleSetting(bsi, FSUI_ICONSTR(ICON_FA_LIST, "Merge Multi-Disc Games"),
FSUI_CSTR("Merges multi-disc games into one item in the game list."), "Main",
"FullscreenUIMergeDiscSets", true);
+ if (DrawToggleSetting(
+ bsi, FSUI_ICONSTR(ICON_FA_TROPHY, "Show Achievement Trophy Icons"),
+ FSUI_CSTR("Shows trophy icons in game grid when games have achievements or have been mastered."), "Main",
+ "FullscreenUIShowTrophyIcons", true))
+ {
+ s_state.game_list_show_trophy_icons = bsi->GetBoolValue("Main", "FullscreenUIShowTrophyIcons", true);
+ }
}
MenuHeading(FSUI_CSTR("Cover Settings"));
@@ -8228,6 +8292,7 @@ void FullscreenUI::SwitchToGameList()
s_state.current_main_window = MainWindowType::GameList;
s_state.game_list_view =
static_cast(Host::GetBaseIntSettingValue("Main", "DefaultFullscreenUIGameView", 0));
+ s_state.game_list_show_trophy_icons = Host::GetBaseBoolSettingValue("Main", "FullscreenUIShowTrophyIcons", true);
// Wipe icon map, because a new save might give us an icon.
for (const auto& it : s_state.icon_image_map)
@@ -8264,6 +8329,22 @@ GPUTexture* FullscreenUI::GetGameListCover(const GameList::Entry* entry, bool fa
return tex ? tex : GetTextureForGameListEntryType(entry->type);
}
+GPUTexture* FullscreenUI::GetGameListCoverTrophy(const GameList::Entry* entry, const ImVec2& image_size)
+{
+ if (!s_state.game_list_show_trophy_icons || entry->num_achievements == 0)
+ return nullptr;
+
+ // this'll get re-scaled up, so undo layout scale
+ const ImVec2 trophy_size = LayoutUnscale(image_size / 6.0f);
+
+ GPUTexture* texture =
+ GetCachedTextureAsync(entry->AreAchievementsMastered() ? "images/trophy-icon-star.svg" : "images/trophy-icon.svg",
+ static_cast(trophy_size.x), static_cast(trophy_size.y));
+
+ // don't draw the placeholder, it's way too large
+ return (texture == GetPlaceholderTexture().get()) ? nullptr : texture;
+}
+
GPUTexture* FullscreenUI::GetTextureForGameListEntryType(GameList::EntryType type)
{
switch (type)
@@ -8679,9 +8760,12 @@ TRANSLATE_NOOP("FullscreenUI", "About DuckStation");
TRANSLATE_NOOP("FullscreenUI", "Account");
TRANSLATE_NOOP("FullscreenUI", "Accurate Blending");
TRANSLATE_NOOP("FullscreenUI", "Achievement Notifications");
+TRANSLATE_NOOP("FullscreenUI", "Achievement Unlock/Count");
TRANSLATE_NOOP("FullscreenUI", "Achievements");
TRANSLATE_NOOP("FullscreenUI", "Achievements Settings");
TRANSLATE_NOOP("FullscreenUI", "Achievements are not enabled.");
+TRANSLATE_NOOP("FullscreenUI", "Achievements: %u (%u) / %u");
+TRANSLATE_NOOP("FullscreenUI", "Achievements: %u / %u");
TRANSLATE_NOOP("FullscreenUI", "Add Search Directory");
TRANSLATE_NOOP("FullscreenUI", "Add Shader");
TRANSLATE_NOOP("FullscreenUI", "Adds a new directory to the game search list.");
@@ -9201,6 +9285,7 @@ TRANSLATE_NOOP("FullscreenUI", "Settings");
TRANSLATE_NOOP("FullscreenUI", "Settings and Operations");
TRANSLATE_NOOP("FullscreenUI", "Shader {} added as stage {}.");
TRANSLATE_NOOP("FullscreenUI", "Shared Card Name");
+TRANSLATE_NOOP("FullscreenUI", "Show Achievement Trophy Icons");
TRANSLATE_NOOP("FullscreenUI", "Show CPU Usage");
TRANSLATE_NOOP("FullscreenUI", "Show Controller Input");
TRANSLATE_NOOP("FullscreenUI", "Show Enhancement Settings");
@@ -9228,6 +9313,7 @@ TRANSLATE_NOOP("FullscreenUI", "Shows the game you are currently playing as part
TRANSLATE_NOOP("FullscreenUI", "Shows the host's CPU usage of each system thread in the top-right corner of the display.");
TRANSLATE_NOOP("FullscreenUI", "Shows the host's GPU usage in the top-right corner of the display.");
TRANSLATE_NOOP("FullscreenUI", "Shows the number of frames (or v-syncs) displayed per second by the system in the top-right corner of the display.");
+TRANSLATE_NOOP("FullscreenUI", "Shows trophy icons in game grid when games have achievements or have been mastered.");
TRANSLATE_NOOP("FullscreenUI", "Simulates the region check present in original, unmodified consoles.");
TRANSLATE_NOOP("FullscreenUI", "Simulates the system ahead of time and rolls back/replays to reduce input lag. Very high system requirements.");
TRANSLATE_NOOP("FullscreenUI", "Skip Duplicate Frame Display");
diff --git a/src/core/game_list.cpp b/src/core/game_list.cpp
index c978c6ecf6..e008b06fb1 100644
--- a/src/core/game_list.cpp
+++ b/src/core/game_list.cpp
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: CC-BY-NC-ND-4.0
#include "game_list.h"
+#include "achievements.h"
#include "bios.h"
#include "fullscreen_ui.h"
#include "host.h"
@@ -50,7 +51,7 @@ namespace {
enum : u32
{
GAME_LIST_CACHE_SIGNATURE = 0x45434C48,
- GAME_LIST_CACHE_VERSION = 36,
+ GAME_LIST_CACHE_VERSION = 37,
PLAYED_TIME_SERIAL_LENGTH = 32,
PLAYED_TIME_LAST_TIME_LENGTH = 20, // uint64
@@ -86,6 +87,8 @@ using PlayedTimeMap = PreferUnorderedStringMap;
static_assert(std::is_same_v);
+static bool ShouldLoadAchievementsProgress();
+
static bool GetExeListEntry(const std::string& path, Entry* entry);
static bool GetPsfListEntry(const std::string& path, Entry* entry);
static bool GetDiscListEntry(const std::string& path, Entry* entry);
@@ -93,18 +96,22 @@ static bool GetDiscListEntry(const std::string& path, Entry* entry);
static void ApplyCustomAttributes(const std::string& path, Entry* entry,
const INISettingsInterface& custom_attributes_ini);
static bool RescanCustomAttributesForPath(const std::string& path, const INISettingsInterface& custom_attributes_ini);
+static void PopulateEntryAchievements(Entry* entry, const Achievements::ProgressDatabase& achievements_progress);
static bool GetGameListEntryFromCache(const std::string& path, Entry* entry,
- const INISettingsInterface& custom_attributes_ini);
+ const INISettingsInterface& custom_attributes_ini,
+ const Achievements::ProgressDatabase& achievements_progress);
static Entry* GetMutableEntryForPath(std::string_view path);
static void ScanDirectory(const char* path, bool recursive, bool only_cache,
const std::vector& excluded_paths, const PlayedTimeMap& played_time_map,
- const INISettingsInterface& custom_attributes_ini, BinaryFileWriter& cache_writer,
+ const INISettingsInterface& custom_attributes_ini,
+ const Achievements::ProgressDatabase& achievements_progress, BinaryFileWriter& cache_writer,
ProgressCallback* progress);
static bool AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map,
- const INISettingsInterface& custom_attributes_ini);
+ const INISettingsInterface& custom_attributes_ini,
+ const Achievements::ProgressDatabase& achievements_progress);
static bool ScanFile(std::string path, std::time_t timestamp, std::unique_lock& lock,
const PlayedTimeMap& played_time_map, const INISettingsInterface& custom_attributes_ini,
- BinaryFileWriter& cache_writer);
+ const Achievements::ProgressDatabase& achievements_progress, BinaryFileWriter& cache_writer);
static bool LoadOrInitializeCache(std::FILE* fp, bool invalidate_cache);
static bool LoadEntriesFromCache(BinaryFileReader& reader);
@@ -173,6 +180,11 @@ bool GameList::IsScannableFilename(std::string_view path)
return System::IsLoadablePath(path);
}
+bool GameList::ShouldLoadAchievementsProgress()
+{
+ return Host::ContainsBaseSettingValue("Cheevos", "Token");
+}
+
bool GameList::GetExeListEntry(const std::string& path, GameList::Entry* entry)
{
const auto fp = FileSystem::OpenManagedCFile(path.c_str(), "rb");
@@ -289,8 +301,16 @@ bool GameList::GetDiscListEntry(const std::string& path, Entry* entry)
entry->uncompressed_size = static_cast(CDImage::RAW_SECTOR_SIZE) * static_cast(cdi->GetLBACount());
entry->type = EntryType::Disc;
- std::string id;
- System::GetGameDetailsFromImage(cdi.get(), &id, &entry->hash);
+ // use the same buffer for game and achievement hashing, to avoid double decompression
+ std::string id, executable_name;
+ std::vector executable_data;
+ if (System::GetGameDetailsFromImage(cdi.get(), &id, &entry->hash, &executable_name, &executable_data))
+ {
+ // used for achievement count lookup later
+ const std::optional hash = Achievements::GetGameHash(executable_name, executable_data);
+ if (hash.has_value())
+ entry->achievements_hash = hash.value();
+ }
// try the database first
const GameDatabase::Entry* dentry = GameDatabase::GetEntryForGameDetails(id, entry->hash);
@@ -359,7 +379,8 @@ bool GameList::PopulateEntryFromPath(const std::string& path, Entry* entry)
}
bool GameList::GetGameListEntryFromCache(const std::string& path, Entry* entry,
- const INISettingsInterface& custom_attributes_ini)
+ const INISettingsInterface& custom_attributes_ini,
+ const Achievements::ProgressDatabase& achievements_progress)
{
auto iter = s_cache_map.find(path);
if (iter == s_cache_map.end())
@@ -369,6 +390,9 @@ bool GameList::GetGameListEntryFromCache(const std::string& path, Entry* entry,
entry->dbentry = GameDatabase::GetEntryForSerial(entry->serial);
s_cache_map.erase(iter);
ApplyCustomAttributes(path, entry, custom_attributes_ini);
+ if (entry->IsDisc())
+ PopulateEntryAchievements(entry, achievements_progress);
+
return true;
}
@@ -395,6 +419,7 @@ bool GameList::LoadEntriesFromCache(BinaryFileReader& reader)
!reader.ReadSizePrefixedString(&ge.disc_set_name) || !reader.ReadU64(&ge.hash) ||
!reader.ReadS64(&ge.file_size) || !reader.ReadU64(&ge.uncompressed_size) ||
!reader.ReadU64(reinterpret_cast(&ge.last_modified_time)) || !reader.ReadS8(&ge.disc_set_index) ||
+ !reader.Read(ge.achievements_hash.data(), ge.achievements_hash.size()) ||
region >= static_cast(DiscRegion::Count) || type >= static_cast(EntryType::Count))
{
WARNING_LOG("Game list cache entry is corrupted");
@@ -428,6 +453,7 @@ bool GameList::WriteEntryToCache(const Entry* entry, BinaryFileWriter& writer)
writer.WriteU64(entry->uncompressed_size);
writer.WriteU64(entry->last_modified_time);
writer.WriteS8(entry->disc_set_index);
+ writer.Write(entry->achievements_hash.data(), entry->achievements_hash.size());
return writer.IsGood();
}
@@ -471,8 +497,9 @@ static bool IsPathExcluded(const std::vector& excluded_paths, const
void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache,
const std::vector& excluded_paths, const PlayedTimeMap& played_time_map,
- const INISettingsInterface& custom_attributes_ini, BinaryFileWriter& cache_writer,
- ProgressCallback* progress)
+ const INISettingsInterface& custom_attributes_ini,
+ const Achievements::ProgressDatabase& achievements_progress,
+ BinaryFileWriter& cache_writer, ProgressCallback* progress)
{
INFO_LOG("Scanning {}{}", path, recursive ? " (recursively)" : "");
@@ -503,14 +530,17 @@ void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache,
std::unique_lock lock(s_mutex);
if (GetEntryForPath(ffd.FileName) ||
- AddFileFromCache(ffd.FileName, ffd.ModificationTime, played_time_map, custom_attributes_ini) || only_cache)
+ AddFileFromCache(ffd.FileName, ffd.ModificationTime, played_time_map, custom_attributes_ini,
+ achievements_progress) ||
+ only_cache)
{
continue;
}
progress->SetStatusText(SmallString::from_format(TRANSLATE_FS("GameList", "Scanning '{}'..."),
FileSystem::GetDisplayNameFromPath(ffd.FileName)));
- ScanFile(std::move(ffd.FileName), ffd.ModificationTime, lock, played_time_map, custom_attributes_ini, cache_writer);
+ ScanFile(std::move(ffd.FileName), ffd.ModificationTime, lock, played_time_map, custom_attributes_ini,
+ achievements_progress, cache_writer);
progress->SetProgressValue(files_scanned);
}
@@ -519,11 +549,15 @@ void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache,
}
bool GameList::AddFileFromCache(const std::string& path, std::time_t timestamp, const PlayedTimeMap& played_time_map,
- const INISettingsInterface& custom_attributes_ini)
+ const INISettingsInterface& custom_attributes_ini,
+ const Achievements::ProgressDatabase& achievements_progress)
{
Entry entry;
- if (!GetGameListEntryFromCache(path, &entry, custom_attributes_ini) || entry.last_modified_time != timestamp)
+ if (!GetGameListEntryFromCache(path, &entry, custom_attributes_ini, achievements_progress) ||
+ entry.last_modified_time != timestamp)
+ {
return false;
+ }
auto iter = played_time_map.find(entry.serial);
if (iter != played_time_map.end())
@@ -538,7 +572,7 @@ bool GameList::AddFileFromCache(const std::string& path, std::time_t timestamp,
bool GameList::ScanFile(std::string path, std::time_t timestamp, std::unique_lock& lock,
const PlayedTimeMap& played_time_map, const INISettingsInterface& custom_attributes_ini,
- BinaryFileWriter& cache_writer)
+ const Achievements::ProgressDatabase& achievements_progress, BinaryFileWriter& cache_writer)
{
// don't block UI while scanning
lock.unlock();
@@ -567,6 +601,9 @@ bool GameList::ScanFile(std::string path, std::time_t timestamp, std::unique_loc
ApplyCustomAttributes(entry.path, &entry, custom_attributes_ini);
+ if (entry.IsDisc())
+ PopulateEntryAchievements(&entry, achievements_progress);
+
lock.lock();
// replace if present
@@ -667,6 +704,124 @@ void GameList::ApplyCustomAttributes(const std::string& path, Entry* entry,
}
}
+void GameList::PopulateEntryAchievements(Entry* entry, const Achievements::ProgressDatabase& achievements_progress)
+{
+ const Achievements::HashDatabaseEntry* hentry = Achievements::LookupGameHash(entry->achievements_hash);
+ if (!hentry)
+ return;
+
+ entry->achievements_game_id = hentry->game_id;
+ entry->num_achievements = Truncate16(hentry->num_achievements);
+ if (entry->num_achievements > 0)
+ {
+ const Achievements::ProgressDatabase::Entry* apd_entry = achievements_progress.LookupGame(hentry->game_id);
+ if (apd_entry)
+ {
+ entry->unlocked_achievements = apd_entry->num_achievements_unlocked;
+ entry->unlocked_achievements_hc = apd_entry->num_hc_achievements_unlocked;
+ }
+ }
+}
+
+void GameList::UpdateAchievementData(const std::span hash, u32 game_id, u32 num_achievements, u32 num_unlocked,
+ bool hardcore)
+{
+ std::unique_lock lock(s_mutex);
+ llvm::SmallVector changed_indices;
+
+ for (size_t i = 0; i < s_entries.size(); i++)
+ {
+ Entry& entry = s_entries[i];
+ if (std::memcmp(entry.achievements_hash.data(), hash.data(), hash.size()) != 0 &&
+ entry.achievements_game_id != game_id)
+ {
+ continue;
+ }
+
+ const u32 current_unlocked = hardcore ? entry.unlocked_achievements_hc : entry.unlocked_achievements;
+ if (entry.achievements_game_id == game_id && entry.num_achievements == num_achievements &&
+ current_unlocked == num_unlocked)
+ {
+ continue;
+ }
+
+ entry.achievements_game_id = game_id;
+ entry.num_achievements = Truncate16(num_achievements);
+ if (hardcore)
+ entry.unlocked_achievements_hc = Truncate16(num_achievements);
+ else
+ entry.unlocked_achievements = Truncate16(num_unlocked);
+
+ changed_indices.push_back(static_cast(i));
+ }
+
+ if (!changed_indices.empty())
+ Host::OnGameListEntriesChanged(changed_indices);
+}
+
+void GameList::UpdateAllAchievementData()
+{
+ Achievements::ProgressDatabase achievements_progress;
+ if (ShouldLoadAchievementsProgress())
+ {
+ Error error;
+ if (!achievements_progress.Load(&error))
+ WARNING_LOG("Failed to load achievements progress: {}", error.GetDescription());
+ }
+
+ std::unique_lock lock(s_mutex);
+
+ // this is pretty jank, but the frontend should collapse it into a single update
+ std::vector changed_indices;
+ for (size_t i = 0; i < s_entries.size(); i++)
+ {
+ Entry& entry = s_entries[i];
+ if (!entry.IsDisc())
+ continue;
+
+ // Game ID is delibately not tested, because it has no effect on the UI.
+ const u16 old_num_achievements = entry.num_achievements;
+ const u16 old_unlocked_achievements = entry.unlocked_achievements;
+ const u16 old_unlocked_achievements_hc = entry.unlocked_achievements_hc;
+ PopulateEntryAchievements(&entry, achievements_progress);
+ if (entry.num_achievements == old_num_achievements && entry.unlocked_achievements == old_unlocked_achievements &&
+ entry.unlocked_achievements_hc == old_unlocked_achievements_hc)
+ {
+ // no update needed
+ continue;
+ }
+
+ changed_indices.push_back(static_cast(i));
+ }
+
+ // and now the disc sets, messier :(
+ for (size_t i = 0; i < s_entries.size(); i++)
+ {
+ Entry& entry = s_entries[i];
+ if (!entry.IsDiscSet())
+ continue;
+
+ const Entry* any_entry = GetEntryBySerial(entry.serial);
+ if (!any_entry)
+ continue;
+
+ if (entry.num_achievements != any_entry->num_achievements ||
+ entry.unlocked_achievements != any_entry->unlocked_achievements ||
+ entry.unlocked_achievements_hc != any_entry->unlocked_achievements_hc)
+ {
+ changed_indices.push_back(static_cast(i));
+ }
+
+ entry.achievements_game_id = any_entry->achievements_game_id;
+ entry.num_achievements = any_entry->num_achievements;
+ entry.unlocked_achievements = any_entry->unlocked_achievements;
+ entry.unlocked_achievements_hc = any_entry->unlocked_achievements_hc;
+ }
+
+ if (!changed_indices.empty())
+ Host::OnGameListEntriesChanged(changed_indices);
+}
+
std::unique_lock GameList::GetLock()
{
return std::unique_lock(s_mutex);
@@ -814,6 +969,13 @@ void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback*
INISettingsInterface custom_attributes_ini(GetCustomPropertiesFile());
custom_attributes_ini.Load();
+ Achievements::ProgressDatabase achievements_progress;
+ if (ShouldLoadAchievementsProgress())
+ {
+ if (!achievements_progress.Load(&error))
+ WARNING_LOG("Failed to load achievements progress: {}", error.GetDescription());
+ }
+
#ifdef __ANDROID__
recursive_dirs.push_back(Path::Combine(EmuFolders::DataRoot, "games"));
#endif
@@ -830,8 +992,8 @@ void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback*
if (progress->IsCancelled())
break;
- ScanDirectory(dir.c_str(), false, only_cache, excluded_paths, played_time, custom_attributes_ini, cache_writer,
- progress);
+ ScanDirectory(dir.c_str(), false, only_cache, excluded_paths, played_time, custom_attributes_ini,
+ achievements_progress, cache_writer, progress);
progress->SetProgressValue(++directory_counter);
}
for (const std::string& dir : recursive_dirs)
@@ -839,8 +1001,8 @@ void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback*
if (progress->IsCancelled())
break;
- ScanDirectory(dir.c_str(), true, only_cache, excluded_paths, played_time, custom_attributes_ini, cache_writer,
- progress);
+ ScanDirectory(dir.c_str(), true, only_cache, excluded_paths, played_time, custom_attributes_ini,
+ achievements_progress, cache_writer, progress);
progress->SetProgressValue(++directory_counter);
}
}
@@ -912,6 +1074,10 @@ void GameList::CreateDiscSetEntries(const std::vector& excluded_pat
set_entry.last_modified_time = entry.last_modified_time;
set_entry.last_played_time = 0;
set_entry.total_played_time = 0;
+ set_entry.achievements_hash = entry.achievements_hash;
+ set_entry.num_achievements = entry.num_achievements;
+ set_entry.unlocked_achievements = entry.unlocked_achievements;
+ set_entry.unlocked_achievements_hc = entry.unlocked_achievements_hc;
// figure out play time for all discs, and sum it
// we do this via lookups, rather than the other entries, because of duplicates
diff --git a/src/core/game_list.h b/src/core/game_list.h
index 68fc89d5e1..246d1180af 100644
--- a/src/core/game_list.h
+++ b/src/core/game_list.h
@@ -56,6 +56,12 @@ struct Entry
std::time_t last_played_time = 0;
std::time_t total_played_time = 0;
+ std::array achievements_hash = {};
+ u32 achievements_game_id = 0;
+ u16 num_achievements = 0;
+ u16 unlocked_achievements = 0;
+ u16 unlocked_achievements_hc = 0;
+
std::string_view GetLanguageIcon() const;
TinyString GetLanguageIconName() const;
@@ -67,6 +73,12 @@ struct Entry
ALWAYS_INLINE bool IsDiscSet() const { return (type == EntryType::DiscSet); }
ALWAYS_INLINE bool HasCustomLanguage() const { return (custom_language != GameDatabase::Language::MaxCount); }
ALWAYS_INLINE EntryType GetSortType() const { return (type == EntryType::DiscSet) ? EntryType::Disc : type; }
+ ALWAYS_INLINE bool AreAchievementsMastered() const
+ {
+ return (num_achievements > 0 &&
+ ((unlocked_achievements > unlocked_achievements_hc) ? unlocked_achievements : unlocked_achievements_hc) ==
+ num_achievements);
+ }
};
using EntryList = std::vector;
@@ -142,6 +154,11 @@ std::optional GetCustomRegionForPath(const std::string_view path);
std::string GetGameIconPath(std::string_view serial, std::string_view path);
void ReloadMemcardTimestampCache();
+/// Updates game list with new achievement unlocks.
+void UpdateAchievementData(const std::span hash, u32 game_id, u32 num_achievements, u32 num_unlocked,
+ bool hardcore);
+void UpdateAllAchievementData();
+
}; // namespace GameList
namespace Host {
diff --git a/src/duckstation-qt/gamelistmodel.cpp b/src/duckstation-qt/gamelistmodel.cpp
index dc08839279..0c59e48754 100644
--- a/src/duckstation-qt/gamelistmodel.cpp
+++ b/src/duckstation-qt/gamelistmodel.cpp
@@ -20,7 +20,7 @@
static constexpr std::array s_column_names = {
{"Icon", "Serial", "Title", "File Title", "Developer", "Publisher", "Genre", "Year", "Players", "Time Played",
- "Last Played", "Size", "File Size", "Region", "Compatibility", "Cover"}};
+ "Last Played", "Size", "File Size", "Region", "Achievements", "Compatibility", "Cover"}};
static constexpr int COVER_ART_WIDTH = 512;
static constexpr int COVER_ART_HEIGHT = 512;
@@ -371,8 +371,8 @@ const QPixmap& GameListModel::getIconPixmapForEntry(const GameList::Entry* ge) c
const QPixmap& GameListModel::getFlagPixmapForEntry(const GameList::Entry* ge) const
{
- static constexpr u32 FLAG_PIXMAP_WIDTH = 42;
- static constexpr u32 FLAG_PIXMAP_HEIGHT = 30;
+ static constexpr u32 FLAG_PIXMAP_WIDTH = 30;
+ static constexpr u32 FLAG_PIXMAP_HEIGHT = 20;
const std::string_view name = ge->GetLanguageIcon();
auto it = m_flag_pixmap_cache.find(name);
@@ -544,11 +544,14 @@ QVariant GameListModel::data(const QModelIndex& index, int role, const GameList:
case Column_FileSize:
return (ge->file_size >= 0) ?
- QString("%1 MB").arg(static_cast(ge->file_size) / 1048576.0, 0, 'f', 2) :
+ QStringLiteral("%1 MB").arg(static_cast(ge->file_size) / 1048576.0, 0, 'f', 2) :
tr("Unknown");
case Column_UncompressedSize:
- return QString("%1 MB").arg(static_cast(ge->uncompressed_size) / 1048576.0, 0, 'f', 2);
+ return QStringLiteral("%1 MB").arg(static_cast(ge->uncompressed_size) / 1048576.0, 0, 'f', 2);
+
+ case Column_Achievements:
+ return {};
case Column_TimePlayed:
{
@@ -829,6 +832,31 @@ bool GameListModel::lessThan(const GameList::Entry* left, const GameList::Entry*
return (left_players < right_players);
}
+ case Column_Achievements:
+ {
+ // sort by unlock percentage
+ const float unlock_left =
+ (left->num_achievements > 0) ?
+ (static_cast(std::max(left->unlocked_achievements, left->unlocked_achievements_hc)) /
+ static_cast(left->num_achievements)) :
+ 0;
+ const float unlock_right =
+ (right->num_achievements > 0) ?
+ (static_cast(std::max(right->unlocked_achievements, right->unlocked_achievements_hc)) /
+ static_cast(right->num_achievements)) :
+ 0;
+ if (std::abs(unlock_left - unlock_right) < 0.0001f)
+ {
+ // order by achievement count
+ if (left->num_achievements == right->num_achievements)
+ return titlesLessThan(left, right);
+
+ return (left->num_achievements < right->num_achievements);
+ }
+
+ return (unlock_left < unlock_right);
+ }
+
default:
return false;
}
@@ -851,6 +879,15 @@ void GameListModel::loadCommonImages()
}
m_placeholder_image.load(QStringLiteral("%1/images/cover-placeholder.png").arg(QtHost::GetResourcesBasePath()));
+
+ constexpr int ACHIEVEMENT_ICON_SIZE = 16;
+ m_no_achievements_pixmap = QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon-gray.svg", true)))
+ .pixmap(ACHIEVEMENT_ICON_SIZE);
+ m_has_achievements_pixmap = QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon.svg", true)))
+ .pixmap(ACHIEVEMENT_ICON_SIZE);
+ m_mastered_achievements_pixmap =
+ QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon-star.svg", true)))
+ .pixmap(ACHIEVEMENT_ICON_SIZE);
}
void GameListModel::setColumnDisplayNames()
@@ -864,6 +901,7 @@ void GameListModel::setColumnDisplayNames()
m_column_display_names[Column_Genre] = tr("Genre");
m_column_display_names[Column_Year] = tr("Year");
m_column_display_names[Column_Players] = tr("Players");
+ m_column_display_names[Column_Achievements] = tr("Achievements");
m_column_display_names[Column_TimePlayed] = tr("Time Played");
m_column_display_names[Column_LastPlayed] = tr("Last Played");
m_column_display_names[Column_FileSize] = tr("Size");
diff --git a/src/duckstation-qt/gamelistmodel.h b/src/duckstation-qt/gamelistmodel.h
index 41ebfd54a5..cecce96759 100644
--- a/src/duckstation-qt/gamelistmodel.h
+++ b/src/duckstation-qt/gamelistmodel.h
@@ -39,6 +39,7 @@ class GameListModel final : public QAbstractTableModel
Column_FileSize,
Column_UncompressedSize,
Column_Region,
+ Column_Achievements,
Column_Compatibility,
Column_Cover,
@@ -56,7 +57,10 @@ class GameListModel final : public QAbstractTableModel
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
- ALWAYS_INLINE const QString& getColumnDisplayName(int column) { return m_column_display_names[column]; }
+ ALWAYS_INLINE const QString& getColumnDisplayName(int column) const { return m_column_display_names[column]; }
+ ALWAYS_INLINE const QPixmap& getNoAchievementsPixmap() const { return m_no_achievements_pixmap; }
+ ALWAYS_INLINE const QPixmap& getHasAchievementsPixmap() const { return m_has_achievements_pixmap; }
+ ALWAYS_INLINE const QPixmap& getMasteredAchievementsPixmap() const { return m_mastered_achievements_pixmap; }
bool hasTakenGameList() const;
void takeGameList();
@@ -119,6 +123,10 @@ private Q_SLOTS:
QImage m_placeholder_image;
QPixmap m_loading_pixmap;
+ QPixmap m_no_achievements_pixmap;
+ QPixmap m_has_achievements_pixmap;
+ QPixmap m_mastered_achievements_pixmap;
+
mutable PreferUnorderedStringMap m_flag_pixmap_cache;
mutable LRUCache m_cover_pixmap_cache;
diff --git a/src/duckstation-qt/gamelistwidget.cpp b/src/duckstation-qt/gamelistwidget.cpp
index ba00ee919b..af062aa504 100644
--- a/src/duckstation-qt/gamelistwidget.cpp
+++ b/src/duckstation-qt/gamelistwidget.cpp
@@ -1,4 +1,4 @@
-// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin
+// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin
// SPDX-License-Identifier: CC-BY-NC-ND-4.0
#include "gamelistwidget.h"
@@ -108,33 +108,106 @@ class GameListSortModel final : public QSortFilterProxyModel
};
namespace {
-class GameListIconStyleDelegate final : public QStyledItemDelegate
+class GameListCenterIconStyleDelegate final : public QStyledItemDelegate
{
public:
- GameListIconStyleDelegate(QWidget* parent) : QStyledItemDelegate(parent) {}
- ~GameListIconStyleDelegate() = default;
+ GameListCenterIconStyleDelegate(QWidget* parent) : QStyledItemDelegate(parent) {}
+ ~GameListCenterIconStyleDelegate() = default;
void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override
{
// https://stackoverflow.com/questions/32216568/how-to-set-icon-center-in-qtableview
Q_ASSERT(index.isValid());
- // draw default item
- QStyleOptionViewItem opt = option;
- initStyleOption(&opt, index);
- opt.icon = QIcon();
- QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &opt, painter, 0);
-
- const QRect r = option.rect;
+ const QRect& r = option.rect;
const QPixmap pix = qvariant_cast(index.data(Qt::DecorationRole));
const int pix_width = static_cast(pix.width() / pix.devicePixelRatio());
- const int pix_height = static_cast(pix.width() / pix.devicePixelRatio());
+ const int pix_height = static_cast(pix.height() / pix.devicePixelRatio());
// draw pixmap at center of item
const QPoint p = QPoint((r.width() - pix_width) / 2, (r.height() - pix_height) / 2);
painter->drawPixmap(r.topLeft() + p, pix);
}
};
+
+class GameListAchievementsStyleDelegate : public QStyledItemDelegate
+{
+public:
+ GameListAchievementsStyleDelegate(QWidget* parent, GameListModel* model, GameListSortModel* sort_model)
+ : QStyledItemDelegate(parent), m_model(model), m_sort_model(sort_model)
+ {
+ }
+
+ void paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const override
+ {
+ Q_ASSERT(index.isValid());
+
+ u32 num_achievements = 0;
+ u32 num_unlocked = 0;
+ u32 num_unlocked_hardcore = 0;
+ bool mastered = false;
+
+ {
+ const QModelIndex source_index = m_sort_model->mapToSource(index);
+ const auto lock = GameList::GetLock();
+ const GameList::Entry* entry = GameList::GetEntryByIndex(static_cast(source_index.row()));
+ if (!entry)
+ return;
+
+ num_achievements = entry->num_achievements;
+ num_unlocked = entry->unlocked_achievements;
+ num_unlocked_hardcore = entry->unlocked_achievements_hc;
+ mastered = entry->AreAchievementsMastered();
+ }
+
+ QRect r = option.rect;
+
+ const QPixmap& icon = (num_achievements > 0) ? (mastered ? m_model->getMasteredAchievementsPixmap() :
+ m_model->getHasAchievementsPixmap()) :
+ m_model->getNoAchievementsPixmap();
+ const int icon_height = static_cast(icon.width() / icon.devicePixelRatio());
+ painter->drawPixmap(r.topLeft() + QPoint(4, (r.height() - icon_height) / 2), icon);
+ r.setLeft(r.left() + 4 + icon.width());
+
+ if (num_achievements > 0)
+ {
+ const QFontMetrics fm(painter->fontMetrics());
+
+ // display hardcore in parenthesis only if there are actually hc unlocks
+ const bool display_hardcore = (num_unlocked > 0 && num_unlocked_hardcore > 0);
+ const bool display_hardcore_only = (num_unlocked == 0 && num_unlocked_hardcore > 0);
+ const QString first = QStringLiteral("%1").arg(display_hardcore_only ? num_unlocked_hardcore : num_unlocked);
+ const QString total = QStringLiteral("/%3").arg(num_achievements);
+
+ const QPalette& palette = static_cast(parent())->palette();
+ const QColor hc_color = QColor(44, 151, 250);
+
+ painter->setPen(display_hardcore_only ? hc_color : palette.color(QPalette::WindowText));
+ painter->drawText(r, Qt::AlignVCenter, first);
+ r.setLeft(r.left() + fm.size(Qt::TextSingleLine, first).width());
+
+ if (display_hardcore)
+ {
+ const QString hc = QStringLiteral("(%2)").arg(num_unlocked_hardcore);
+ painter->setPen(hc_color);
+ painter->drawText(r, Qt::AlignVCenter, hc);
+ r.setLeft(r.left() + fm.size(Qt::TextSingleLine, hc).width());
+ }
+
+ painter->setPen(palette.color(QPalette::WindowText));
+ painter->drawText(r, Qt::AlignVCenter, total);
+ }
+ else
+ {
+ painter->drawText(r, Qt::AlignVCenter, QStringLiteral("N/A"));
+ }
+ }
+
+private:
+ GameListModel* m_model;
+ GameListSortModel* m_sort_model;
+};
+
} // namespace
GameListWidget::GameListWidget(QWidget* parent /* = nullptr */) : QWidget(parent)
@@ -185,6 +258,7 @@ void GameListWidget::initialize()
[this](const QString& text) { m_sort_model->setFilterName(text); });
connect(m_ui.searchText, &QLineEdit::returnPressed, this, &GameListWidget::onSearchReturnPressed);
+ GameListCenterIconStyleDelegate* center_icon_delegate = new GameListCenterIconStyleDelegate(this);
m_table_view = new QTableView(m_ui.stack);
m_table_view->setModel(m_sort_model);
m_table_view->setSortingEnabled(true);
@@ -199,7 +273,10 @@ void GameListWidget::initialize()
m_table_view->verticalHeader()->hide();
m_table_view->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn);
m_table_view->setVerticalScrollMode(QAbstractItemView::ScrollMode::ScrollPerPixel);
- m_table_view->setItemDelegateForColumn(0, new GameListIconStyleDelegate(this));
+ m_table_view->setItemDelegateForColumn(GameListModel::Column_Icon, center_icon_delegate);
+ m_table_view->setItemDelegateForColumn(GameListModel::Column_Region, center_icon_delegate);
+ m_table_view->setItemDelegateForColumn(GameListModel::Column_Achievements,
+ new GameListAchievementsStyleDelegate(this, m_model, m_sort_model));
loadTableViewColumnVisibilitySettings();
loadTableViewColumnSortSettings();
@@ -625,6 +702,7 @@ void GameListWidget::resizeTableViewColumnsToFit()
80, // file size
80, // size
50, // region
+ 90, // achievements
100 // compatibility
});
}
@@ -651,7 +729,8 @@ void GameListWidget::loadTableViewColumnVisibilitySettings()
true, // file size
false, // size
true, // region
- true // compatibility
+ false, // achievements
+ false // compatibility
}};
for (int column = 0; column < GameListModel::Column_Count; column++)
@@ -710,6 +789,17 @@ void GameListWidget::saveTableViewColumnSortSettings()
Host::CommitBaseSettingChanges();
}
+void GameListWidget::setTableViewColumnHidden(int column, bool hidden)
+{
+ DebugAssert(column < GameListModel::Column_Count);
+ if (m_table_view->isColumnHidden(column) == hidden)
+ return;
+
+ m_table_view->setColumnHidden(column, hidden);
+ saveTableViewColumnVisibilitySettings(column);
+ resizeTableViewColumnsToFit();
+}
+
const GameList::Entry* GameListWidget::getSelectedEntry() const
{
if (m_ui.stack->currentIndex() == 0)
diff --git a/src/duckstation-qt/gamelistwidget.h b/src/duckstation-qt/gamelistwidget.h
index 8d671d0d2c..70992a4982 100644
--- a/src/duckstation-qt/gamelistwidget.h
+++ b/src/duckstation-qt/gamelistwidget.h
@@ -1,4 +1,4 @@
-// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin
+// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin
// SPDX-License-Identifier: CC-BY-NC-ND-4.0
#pragma once
@@ -43,6 +43,7 @@ class GameListWidget : public QWidget
void initialize();
void resizeTableViewColumnsToFit();
+ void setTableViewColumnHidden(int column, bool hidden);
void refresh(bool invalidate_cache);
void refreshModel();
diff --git a/src/duckstation-qt/mainwindow.cpp b/src/duckstation-qt/mainwindow.cpp
index 10df551b59..0b5d053b8f 100644
--- a/src/duckstation-qt/mainwindow.cpp
+++ b/src/duckstation-qt/mainwindow.cpp
@@ -2205,6 +2205,7 @@ void MainWindow::connectSignals()
connect(g_emu_thread, &EmuThread::mouseModeRequested, this, &MainWindow::onMouseModeRequested);
connect(g_emu_thread, &EmuThread::fullscreenUIStartedOrStopped, this, &MainWindow::onFullscreenUIStartedOrStopped);
connect(g_emu_thread, &EmuThread::achievementsLoginRequested, this, &MainWindow::onAchievementsLoginRequested);
+ connect(g_emu_thread, &EmuThread::achievementsLoginSuccess, this, &MainWindow::onAchievementsLoginSuccess);
connect(g_emu_thread, &EmuThread::achievementsChallengeModeChanged, this,
&MainWindow::onAchievementsChallengeModeChanged);
connect(g_emu_thread, &EmuThread::onCoverDownloaderOpenRequested, this, &MainWindow::onToolsCoverDownloaderTriggered);
@@ -2796,6 +2797,24 @@ void MainWindow::onAchievementsLoginRequested(Achievements::LoginRequestReason r
dlg.exec();
}
+void MainWindow::onAchievementsLoginSuccess(const QString& username, quint32 points, quint32 sc_points,
+ quint32 unread_messages)
+{
+ m_ui.statusBar->showMessage(tr("RA: Logged in as %1 (%2, %3 softcore). %4 unread messages.")
+ .arg(username)
+ .arg(points)
+ .arg(sc_points)
+ .arg(unread_messages));
+
+ // Automatically show the achievements column after first login. If the user has manually hidden it,
+ // it will not be automatically shown again.
+ if (!Host::GetBaseBoolSettingValue("GameListTableView", "TriedShowingAchievementsColumn", false))
+ {
+ Host::SetBaseBoolSettingValue("GameListTableView", "TriedShowingAchievementsColumn", true);
+ m_game_list_widget->setTableViewColumnHidden(GameListModel::Column_Achievements, false);
+ }
+}
+
void MainWindow::onAchievementsChallengeModeChanged(bool enabled)
{
if (enabled)
diff --git a/src/duckstation-qt/mainwindow.h b/src/duckstation-qt/mainwindow.h
index 303b0f5206..07726b23b9 100644
--- a/src/duckstation-qt/mainwindow.h
+++ b/src/duckstation-qt/mainwindow.h
@@ -149,6 +149,7 @@ private Q_SLOTS:
void onMediaCaptureStarted();
void onMediaCaptureStopped();
void onAchievementsLoginRequested(Achievements::LoginRequestReason reason);
+ void onAchievementsLoginSuccess(const QString& username, quint32 points, quint32 sc_points, quint32 unread_messages);
void onAchievementsChallengeModeChanged(bool enabled);
bool onCreateAuxiliaryRenderWindow(RenderAPI render_api, qint32 x, qint32 y, quint32 width, quint32 height,
const QString& title, const QString& icon_name,
diff --git a/src/duckstation-qt/qthost.cpp b/src/duckstation-qt/qthost.cpp
index 118001b99e..55e8696c0a 100644
--- a/src/duckstation-qt/qthost.cpp
+++ b/src/duckstation-qt/qthost.cpp
@@ -1586,13 +1586,7 @@ void Host::OnAchievementsLoginRequested(Achievements::LoginRequestReason reason)
void Host::OnAchievementsLoginSuccess(const char* username, u32 points, u32 sc_points, u32 unread_messages)
{
- const QString message = qApp->translate("QtHost", "RA: Logged in as %1 (%2, %3 softcore). %4 unread messages.")
- .arg(QString::fromUtf8(username))
- .arg(points)
- .arg(sc_points)
- .arg(unread_messages);
-
- emit g_emu_thread->statusMessage(message);
+ emit g_emu_thread->achievementsLoginSuccess(QString::fromUtf8(username), points, sc_points, unread_messages);
}
void Host::OnAchievementsRefreshed()
diff --git a/src/duckstation-qt/qthost.h b/src/duckstation-qt/qthost.h
index 2b753a10ae..0ccfa20cfb 100644
--- a/src/duckstation-qt/qthost.h
+++ b/src/duckstation-qt/qthost.h
@@ -152,6 +152,7 @@ class EmuThread : public QThread
void mouseModeRequested(bool relative, bool hide_cursor);
void fullscreenUIStartedOrStopped(bool running);
void achievementsLoginRequested(Achievements::LoginRequestReason reason);
+ void achievementsLoginSuccess(const QString& username, quint32 points, quint32 sc_points, quint32 unread_messages);
void achievementsRefreshed(quint32 id, const QString& game_info_string);
void achievementsChallengeModeChanged(bool enabled);
void cheatEnabled(quint32 index, bool enabled);
diff --git a/src/util/imgui_fullscreen.cpp b/src/util/imgui_fullscreen.cpp
index 48fb1553bc..b15ca0ed30 100644
--- a/src/util/imgui_fullscreen.cpp
+++ b/src/util/imgui_fullscreen.cpp
@@ -418,6 +418,39 @@ GPUTexture* ImGuiFullscreen::GetCachedTextureAsync(std::string_view name)
return tex_ptr->get();
}
+GPUTexture* ImGuiFullscreen::GetCachedTextureAsync(std::string_view name, u32 svg_width, u32 svg_height)
+{
+ // ignore size hints if it's not needed, don't duplicate
+ if (!TextureNeedsSVGDimensions(name))
+ return GetCachedTextureAsync(name);
+
+ svg_width = static_cast(std::ceil(LayoutScale(static_cast(svg_width))));
+ svg_height = static_cast(std::ceil(LayoutScale(static_cast(svg_height))));
+
+ const SmallString wh_name = SmallString::from_format("{}#{}x{}", name, svg_width, svg_height);
+ std::shared_ptr* tex_ptr = s_state.texture_cache.Lookup(wh_name.view());
+ if (!tex_ptr)
+ {
+ // insert the placeholder
+ tex_ptr = s_state.texture_cache.Insert(std::string(wh_name), s_state.placeholder_texture);
+
+ // queue the actual load
+ System::QueueAsyncTask([path = std::string(name), wh_name = std::string(wh_name), svg_width, svg_height]() mutable {
+ std::optional image(LoadTextureImage(path.c_str(), svg_width, svg_height));
+
+ // don't bother queuing back if it doesn't exist
+ if (!image.has_value())
+ return;
+
+ std::unique_lock lock(s_state.shared_state_mutex);
+ if (s_state.initialized)
+ s_state.texture_upload_queue.emplace_back(std::move(wh_name), std::move(image.value()));
+ });
+ }
+
+ return tex_ptr->get();
+}
+
bool ImGuiFullscreen::InvalidateCachedTexture(std::string_view path)
{
// need to do a partial match on this because SVG
diff --git a/src/util/imgui_fullscreen.h b/src/util/imgui_fullscreen.h
index 9472b52dd4..2daef33aea 100644
--- a/src/util/imgui_fullscreen.h
+++ b/src/util/imgui_fullscreen.h
@@ -141,6 +141,7 @@ std::shared_ptr LoadTexture(std::string_view path, u32 svg_width = 0
GPUTexture* GetCachedTexture(std::string_view name);
GPUTexture* GetCachedTexture(std::string_view name, u32 svg_width, u32 svg_height);
GPUTexture* GetCachedTextureAsync(std::string_view name);
+GPUTexture* GetCachedTextureAsync(std::string_view name, u32 svg_width, u32 svg_height);
bool InvalidateCachedTexture(std::string_view path);
bool TextureNeedsSVGDimensions(std::string_view path);
void UploadAsyncTextures();