From 0e3668a7bb69c8fb249fa973717f909c746b6b82 Mon Sep 17 00:00:00 2001 From: Stenzek Date: Sat, 25 Jan 2025 19:21:40 +1000 Subject: [PATCH] GameList: Show achievement information in game list --- data/resources/images/trophy-icon-gray.svg | 13 ++ data/resources/images/trophy-icon-star.svg | 16 ++ data/resources/images/trophy-icon.svg | 13 ++ src/core/achievements.cpp | 19 +- src/core/fullscreen_ui.cpp | 90 ++++++++- src/core/game_list.cpp | 204 +++++++++++++++++++-- src/core/game_list.h | 17 ++ src/duckstation-qt/gamelistmodel.cpp | 48 ++++- src/duckstation-qt/gamelistmodel.h | 10 +- src/duckstation-qt/gamelistwidget.cpp | 118 ++++++++++-- src/duckstation-qt/gamelistwidget.h | 3 +- src/duckstation-qt/mainwindow.cpp | 19 ++ src/duckstation-qt/mainwindow.h | 1 + src/duckstation-qt/qthost.cpp | 8 +- src/duckstation-qt/qthost.h | 1 + src/util/imgui_fullscreen.cpp | 33 ++++ src/util/imgui_fullscreen.h | 1 + 17 files changed, 560 insertions(+), 54 deletions(-) create mode 100644 data/resources/images/trophy-icon-gray.svg create mode 100644 data/resources/images/trophy-icon-star.svg create mode 100644 data/resources/images/trophy-icon.svg 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 @@ + + + + + + trophy + + + + + + + \ 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 @@ + + + + + + trophy + + + + + + + + + + \ 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 @@ + + + + + + trophy + + + + + + + \ 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();