GameList: Show achievement information in game list

This commit is contained in:
Stenzek 2025-01-25 19:21:40 +10:00
parent 6512ed8a8c
commit 0e3668a7bb
No known key found for this signature in database
17 changed files with 560 additions and 54 deletions

View File

@ -0,0 +1,13 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 113.48 122.88">
<!-- https://uxwing.com/wp-content/themes/uxwing/download/sport-and-awards/trophy-icon.png -->
<defs>
<style>.cls-1{fill:#808080;}.cls-2{fill:#A0A0A0;}.cls-3{fill:#222;}.cls-4{fill:#C0C0C0;}.cls-5{fill:#909090;}.cls-6{fill:#B0B0B0;}.cls-7,.cls-8{fill:#FFF;}.cls-8{fill-rule:evenodd;}</style>
</defs>
<title>trophy</title>
<path class="cls-1" d="M3.21,18.74H19.86q0-4,.06-8.26V0H93.05V10.49c0,2.92,0,5.66,0,8.26h17.24a3.08,3.08,0,0,1,3.07,2.93,77.67,77.67,0,0,1-.4,13.9A34,34,0,0,1,109.11,48a21.77,21.77,0,0,1-8.8,8.6A31.91,31.91,0,0,1,86.41,60C83.14,65.43,78.78,68,73.68,72.67c-6.17,4.71-10.81,8.26-7.2,19.13h5.39a7.84,7.84,0,0,1,7.82,7.82v3.15h.77A7.69,7.69,0,0,1,85.91,105h0a7.67,7.67,0,0,1,2.26,5.45v5.23a1.77,1.77,0,0,1-1.77,1.77H26.58a1.77,1.77,0,0,1-1.77-1.77v-5.23A7.66,7.66,0,0,1,27.07,105h0a7.66,7.66,0,0,1,5.44-2.26h.77V99.62a7.75,7.75,0,0,1,2.3-5.51v0a7.81,7.81,0,0,1,5.51-2.29h6.06c3.22-10.26-1-13.58-6.83-18.17A44.47,44.47,0,0,1,27.34,60,31.87,31.87,0,0,1,13,56.54a21.47,21.47,0,0,1-8.73-8.6A34.07,34.07,0,0,1,.51,35.58,78.1,78.1,0,0,1,.13,21.9v-.08a3.08,3.08,0,0,1,3.09-3.08ZM92.71,30a121.67,121.67,0,0,1-2,18,15.17,15.17,0,0,0,5-1.9,10.49,10.49,0,0,0,3.69-3.89,18,18,0,0,0,1.93-6,45.37,45.37,0,0,0,.5-6.25H92.71ZM20.12,30H12a49.78,49.78,0,0,0,.45,6.27,18.41,18.41,0,0,0,1.8,6,10.13,10.13,0,0,0,3.57,3.88A14.57,14.57,0,0,0,22.54,48a92,92,0,0,1-2.42-18Z"/>
<path class="cls-2" d="M20.08,21.82H3.21C2.75,31.1,3.34,40,7,46.43c3.43,6.11,9.7,10.15,20.62,10.46a27.9,27.9,0,0,1-3.48-5.51c-6.56-.88-10.36-3.59-12.54-7.67S8.94,34.32,8.94,28.14a1.23,1.23,0,0,1,1.23-1.23h9.91V21.82Zm72.77,5.1h10.82a1.23,1.23,0,0,1,1.23,1.22c0,6.2-.56,11.54-2.84,15.6s-6.16,6.75-12.7,7.64a25.38,25.38,0,0,1-3.69,5.52c11-.29,17.29-4.33,20.77-10.45,3.67-6.47,4.29-15.34,3.84-24.62H92.85v5.09Z"/>
<path class="cls-3" d="M79.69,102.76h.77A7.69,7.69,0,0,1,85.91,105h0a7.67,7.67,0,0,1,2.26,5.45v10.63a1.77,1.77,0,0,1-1.77,1.77H26.58a1.77,1.77,0,0,1-1.77-1.77V110.48A7.66,7.66,0,0,1,27.07,105h0a7.66,7.66,0,0,1,5.44-2.26H79.69Z"/>
<path class="cls-4" d="M70.64,108H35.72a4.22,4.22,0,0,0-3,1.25h0a4.26,4.26,0,0,0-1.25,3v5.28H81.55v-5.28a4.26,4.26,0,0,0-1.26-3,4.31,4.31,0,0,0-3-1.26Z"/>
<path class="cls-5" d="M50.71,93h6V75.21c-22.17-7.88-24.26-35-29.55-72.57H22.53V29.41C23,39.6,24.68,47.14,27,52.91a38.19,38.19,0,0,0,8.39,12.8,68.65,68.65,0,0,0,6.71,5.78C49.11,77,54.19,81,50.71,93Z"/>
<path class="cls-6" d="M56.71,93H63c-3.88-12.71,1.68-17,9-22.55,8.05-6.14,18.5-14.12,18.5-40.35V2.64H27.16C30.58,26.92,32.66,46.81,39.67,60A39.14,39.14,0,0,0,49,71.13a29.3,29.3,0,0,0,5.47,3.17,19.1,19.1,0,0,0,2.21.74v.15l.07,0V93Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -0,0 +1,16 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 113.48 122.88">
<!-- https://uxwing.com/wp-content/themes/uxwing/download/sport-and-awards/trophy-icon.png -->
<defs>
<style>.cls-1{fill:#f39d00;}.cls-2{fill:#f9c809;}.cls-3{fill:#222;}.cls-4{fill:#ead79e;}.cls-5{fill:#f8b705;}.cls-6{fill:#fac809;}.cls-7,.cls-8{fill:#fff;}.cls-8{fill-rule:evenodd;}</style>
</defs>
<title>trophy</title>
<path class="cls-1" d="M3.21,18.74H19.86q0-4,.06-8.26V0H93.05V10.49c0,2.92,0,5.66,0,8.26h17.24a3.08,3.08,0,0,1,3.07,2.93,77.67,77.67,0,0,1-.4,13.9A34,34,0,0,1,109.11,48a21.77,21.77,0,0,1-8.8,8.6A31.91,31.91,0,0,1,86.41,60C83.14,65.43,78.78,68,73.68,72.67c-6.17,4.71-10.81,8.26-7.2,19.13h5.39a7.84,7.84,0,0,1,7.82,7.82v3.15h.77A7.69,7.69,0,0,1,85.91,105h0a7.67,7.67,0,0,1,2.26,5.45v5.23a1.77,1.77,0,0,1-1.77,1.77H26.58a1.77,1.77,0,0,1-1.77-1.77v-5.23A7.66,7.66,0,0,1,27.07,105h0a7.66,7.66,0,0,1,5.44-2.26h.77V99.62a7.75,7.75,0,0,1,2.3-5.51v0a7.81,7.81,0,0,1,5.51-2.29h6.06c3.22-10.26-1-13.58-6.83-18.17A44.47,44.47,0,0,1,27.34,60,31.87,31.87,0,0,1,13,56.54a21.47,21.47,0,0,1-8.73-8.6A34.07,34.07,0,0,1,.51,35.58,78.1,78.1,0,0,1,.13,21.9v-.08a3.08,3.08,0,0,1,3.09-3.08ZM92.71,30a121.67,121.67,0,0,1-2,18,15.17,15.17,0,0,0,5-1.9,10.49,10.49,0,0,0,3.69-3.89,18,18,0,0,0,1.93-6,45.37,45.37,0,0,0,.5-6.25H92.71ZM20.12,30H12a49.78,49.78,0,0,0,.45,6.27,18.41,18.41,0,0,0,1.8,6,10.13,10.13,0,0,0,3.57,3.88A14.57,14.57,0,0,0,22.54,48a92,92,0,0,1-2.42-18Z"/>
<path class="cls-2" d="M20.08,21.82H3.21C2.75,31.1,3.34,40,7,46.43c3.43,6.11,9.7,10.15,20.62,10.46a27.9,27.9,0,0,1-3.48-5.51c-6.56-.88-10.36-3.59-12.54-7.67S8.94,34.32,8.94,28.14a1.23,1.23,0,0,1,1.23-1.23h9.91V21.82Zm72.77,5.1h10.82a1.23,1.23,0,0,1,1.23,1.22c0,6.2-.56,11.54-2.84,15.6s-6.16,6.75-12.7,7.64a25.38,25.38,0,0,1-3.69,5.52c11-.29,17.29-4.33,20.77-10.45,3.67-6.47,4.29-15.34,3.84-24.62H92.85v5.09Z"/>
<path class="cls-3" d="M79.69,102.76h.77A7.69,7.69,0,0,1,85.91,105h0a7.67,7.67,0,0,1,2.26,5.45v10.63a1.77,1.77,0,0,1-1.77,1.77H26.58a1.77,1.77,0,0,1-1.77-1.77V110.48A7.66,7.66,0,0,1,27.07,105h0a7.66,7.66,0,0,1,5.44-2.26H79.69Z"/>
<path class="cls-4" d="M70.64,108H35.72a4.22,4.22,0,0,0-3,1.25h0a4.26,4.26,0,0,0-1.25,3v5.28H81.55v-5.28a4.26,4.26,0,0,0-1.26-3,4.31,4.31,0,0,0-3-1.26Z"/>
<path class="cls-5" d="M50.71,93h6V75.21c-22.17-7.88-24.26-35-29.55-72.57H22.53V29.41C23,39.6,24.68,47.14,27,52.91a38.19,38.19,0,0,0,8.39,12.8,68.65,68.65,0,0,0,6.71,5.78C49.11,77,54.19,81,50.71,93Z"/>
<path class="cls-6" d="M56.71,93H63c-3.88-12.71,1.68-17,9-22.55,8.05-6.14,18.5-14.12,18.5-40.35V2.64H27.16C30.58,26.92,32.66,46.81,39.67,60A39.14,39.14,0,0,0,49,71.13a29.3,29.3,0,0,0,5.47,3.17,19.1,19.1,0,0,0,2.21.74v.15l.07,0V93Z"/>
<path class="cls-1" d="M58.26,20.13,61.06,27l7.39.56a1.9,1.9,0,0,1,1,3.41l-5.59,4.74,1.76,7.18a1.9,1.9,0,0,1-1.41,2.29,1.88,1.88,0,0,1-1.49-.26L56.5,41l-6.29,3.89a1.9,1.9,0,0,1-2.62-.62,1.85,1.85,0,0,1-.23-1.44l1.75-7.18-5.66-4.8a1.91,1.91,0,0,1,1.09-3.35L51.93,27l2.81-6.84a1.91,1.91,0,0,1,3.52,0Z"/>
<polygon class="cls-7" points="56.5 20.86 59.75 28.78 68.31 29.43 61.76 34.98 63.79 43.3 56.5 38.79 49.21 43.3 51.24 34.98 44.69 29.43 53.24 28.78 56.5 20.86 56.5 20.86 56.5 20.86"/>
<path class="cls-8" d="M76.62,47.62l-.07.1a3.79,3.79,0,0,0-5.17.83l-.1-.08a3.52,3.52,0,0,0,.62-2.75,3.57,3.57,0,0,0-1.44-2.42,26.79,26.79,0,0,0,2.82.53,3.58,3.58,0,0,0,2.42-1.45l.1.07a3.81,3.81,0,0,0,.82,5.17ZM84.27,34.8l-.07.1a3.78,3.78,0,0,0-5.17.82l-.1-.07a3.79,3.79,0,0,0-.83-5.17l.07-.1a3.8,3.8,0,0,0,5.18-.83l.09.08a3.79,3.79,0,0,0,.83,5.17Zm.06-13.56-.13.18a6.94,6.94,0,0,0-9.46,1.51l-.18-.13a6.5,6.5,0,0,0,1.14-5,6.49,6.49,0,0,0-2.65-4.43l.13-.18a6.94,6.94,0,0,0,9.46-1.51l.18.13a6.5,6.5,0,0,0-1.14,5,6.51,6.51,0,0,0,2.65,4.43Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -0,0 +1,13 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 113.48 122.88">
<!-- https://uxwing.com/wp-content/themes/uxwing/download/sport-and-awards/trophy-icon.png -->
<defs>
<style>.cls-1{fill:#f39d00;}.cls-2{fill:#f9c809;}.cls-3{fill:#222;}.cls-4{fill:#ead79e;}.cls-5{fill:#f8b705;}.cls-6{fill:#fac809;}.cls-7,.cls-8{fill:#fff;}.cls-8{fill-rule:evenodd;}</style>
</defs>
<title>trophy</title>
<path class="cls-1" d="M3.21,18.74H19.86q0-4,.06-8.26V0H93.05V10.49c0,2.92,0,5.66,0,8.26h17.24a3.08,3.08,0,0,1,3.07,2.93,77.67,77.67,0,0,1-.4,13.9A34,34,0,0,1,109.11,48a21.77,21.77,0,0,1-8.8,8.6A31.91,31.91,0,0,1,86.41,60C83.14,65.43,78.78,68,73.68,72.67c-6.17,4.71-10.81,8.26-7.2,19.13h5.39a7.84,7.84,0,0,1,7.82,7.82v3.15h.77A7.69,7.69,0,0,1,85.91,105h0a7.67,7.67,0,0,1,2.26,5.45v5.23a1.77,1.77,0,0,1-1.77,1.77H26.58a1.77,1.77,0,0,1-1.77-1.77v-5.23A7.66,7.66,0,0,1,27.07,105h0a7.66,7.66,0,0,1,5.44-2.26h.77V99.62a7.75,7.75,0,0,1,2.3-5.51v0a7.81,7.81,0,0,1,5.51-2.29h6.06c3.22-10.26-1-13.58-6.83-18.17A44.47,44.47,0,0,1,27.34,60,31.87,31.87,0,0,1,13,56.54a21.47,21.47,0,0,1-8.73-8.6A34.07,34.07,0,0,1,.51,35.58,78.1,78.1,0,0,1,.13,21.9v-.08a3.08,3.08,0,0,1,3.09-3.08ZM92.71,30a121.67,121.67,0,0,1-2,18,15.17,15.17,0,0,0,5-1.9,10.49,10.49,0,0,0,3.69-3.89,18,18,0,0,0,1.93-6,45.37,45.37,0,0,0,.5-6.25H92.71ZM20.12,30H12a49.78,49.78,0,0,0,.45,6.27,18.41,18.41,0,0,0,1.8,6,10.13,10.13,0,0,0,3.57,3.88A14.57,14.57,0,0,0,22.54,48a92,92,0,0,1-2.42-18Z"/>
<path class="cls-2" d="M20.08,21.82H3.21C2.75,31.1,3.34,40,7,46.43c3.43,6.11,9.7,10.15,20.62,10.46a27.9,27.9,0,0,1-3.48-5.51c-6.56-.88-10.36-3.59-12.54-7.67S8.94,34.32,8.94,28.14a1.23,1.23,0,0,1,1.23-1.23h9.91V21.82Zm72.77,5.1h10.82a1.23,1.23,0,0,1,1.23,1.22c0,6.2-.56,11.54-2.84,15.6s-6.16,6.75-12.7,7.64a25.38,25.38,0,0,1-3.69,5.52c11-.29,17.29-4.33,20.77-10.45,3.67-6.47,4.29-15.34,3.84-24.62H92.85v5.09Z"/>
<path class="cls-3" d="M79.69,102.76h.77A7.69,7.69,0,0,1,85.91,105h0a7.67,7.67,0,0,1,2.26,5.45v10.63a1.77,1.77,0,0,1-1.77,1.77H26.58a1.77,1.77,0,0,1-1.77-1.77V110.48A7.66,7.66,0,0,1,27.07,105h0a7.66,7.66,0,0,1,5.44-2.26H79.69Z"/>
<path class="cls-4" d="M70.64,108H35.72a4.22,4.22,0,0,0-3,1.25h0a4.26,4.26,0,0,0-1.25,3v5.28H81.55v-5.28a4.26,4.26,0,0,0-1.26-3,4.31,4.31,0,0,0-3-1.26Z"/>
<path class="cls-5" d="M50.71,93h6V75.21c-22.17-7.88-24.26-35-29.55-72.57H22.53V29.41C23,39.6,24.68,47.14,27,52.91a38.19,38.19,0,0,0,8.39,12.8,68.65,68.65,0,0,0,6.71,5.78C49.11,77,54.19,81,50.71,93Z"/>
<path class="cls-6" d="M56.71,93H63c-3.88-12.71,1.68-17,9-22.55,8.05-6.14,18.5-14.12,18.5-40.35V2.64H27.16C30.58,26.92,32.66,46.81,39.67,60A39.14,39.14,0,0,0,49,71.13a29.3,29.3,0,0,0,5.47,3.17,19.1,19.1,0,0,0,2.21.74v.15l.07,0V93Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -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;

View File

@ -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<std::string, std::string> icon_image_map;
std::vector<const GameList::Entry*> 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<float>(std::max(lhs->unlocked_achievements, lhs->unlocked_achievements_hc)) /
static_cast<float>(lhs->num_achievements)) :
0;
const float unlock_rhs =
(rhs->num_achievements > 0) ?
(static_cast<float>(std::max(rhs->unlocked_achievements, rhs->unlocked_achievements_hc)) /
static_cast<float>(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<float>(cover_trophy->GetWidth()), static_cast<float>(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<GameListView>(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<u32>(trophy_size.x), static_cast<u32>(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");

View File

@ -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<PlayedTimeEntry>;
static_assert(std::is_same_v<decltype(Entry::hash), GameHash>);
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<std::string>& 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<std::recursive_mutex>& 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<u64>(CDImage::RAW_SECTOR_SIZE) * static_cast<u64>(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<u8> executable_data;
if (System::GetGameDetailsFromImage(cdi.get(), &id, &entry->hash, &executable_name, &executable_data))
{
// used for achievement count lookup later
const std::optional<Achievements::GameHash> 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<u64*>(&ge.last_modified_time)) || !reader.ReadS8(&ge.disc_set_index) ||
!reader.Read(ge.achievements_hash.data(), ge.achievements_hash.size()) ||
region >= static_cast<u8>(DiscRegion::Count) || type >= static_cast<u8>(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<std::string>& excluded_paths, const
void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache,
const std::vector<std::string>& 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<std::recursive_mutex>& 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<u8, 16> hash, u32 game_id, u32 num_achievements, u32 num_unlocked,
bool hardcore)
{
std::unique_lock<std::recursive_mutex> lock(s_mutex);
llvm::SmallVector<u32, 32> 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<u32>(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<std::recursive_mutex> lock(s_mutex);
// this is pretty jank, but the frontend should collapse it into a single update
std::vector<u32> 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<u32>(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<u32>(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<std::recursive_mutex> GameList::GetLock()
{
return std::unique_lock<std::recursive_mutex>(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<std::string>& 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

View File

@ -56,6 +56,12 @@ struct Entry
std::time_t last_played_time = 0;
std::time_t total_played_time = 0;
std::array<u8, 16> 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<Entry>;
@ -142,6 +154,11 @@ std::optional<DiscRegion> 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<u8, 16> hash, u32 game_id, u32 num_achievements, u32 num_unlocked,
bool hardcore);
void UpdateAllAchievementData();
}; // namespace GameList
namespace Host {

View File

@ -20,7 +20,7 @@
static constexpr std::array<const char*, GameListModel::Column_Count> 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<double>(ge->file_size) / 1048576.0, 0, 'f', 2) :
QStringLiteral("%1 MB").arg(static_cast<double>(ge->file_size) / 1048576.0, 0, 'f', 2) :
tr("Unknown");
case Column_UncompressedSize:
return QString("%1 MB").arg(static_cast<double>(ge->uncompressed_size) / 1048576.0, 0, 'f', 2);
return QStringLiteral("%1 MB").arg(static_cast<double>(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<float>(std::max(left->unlocked_achievements, left->unlocked_achievements_hc)) /
static_cast<float>(left->num_achievements)) :
0;
const float unlock_right =
(right->num_achievements > 0) ?
(static_cast<float>(std::max(right->unlocked_achievements, right->unlocked_achievements_hc)) /
static_cast<float>(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");

View File

@ -39,6 +39,7 @@ public:
Column_FileSize,
Column_UncompressedSize,
Column_Region,
Column_Achievements,
Column_Compatibility,
Column_Cover,
@ -56,7 +57,10 @@ public:
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:
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<QPixmap> m_flag_pixmap_cache;
mutable LRUCache<std::string, QPixmap> m_cover_pixmap_cache;

View File

@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin <stenzek@gmail.com>
// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin <stenzek@gmail.com>
// SPDX-License-Identifier: CC-BY-NC-ND-4.0
#include "gamelistwidget.h"
@ -108,33 +108,106 @@ private:
};
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<QPixmap>(index.data(Qt::DecorationRole));
const int pix_width = static_cast<int>(pix.width() / pix.devicePixelRatio());
const int pix_height = static_cast<int>(pix.width() / pix.devicePixelRatio());
const int pix_height = static_cast<int>(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<u32>(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<int>(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<QWidget*>(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)

View File

@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2019-2024 Connor McLaughlin <stenzek@gmail.com>
// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin <stenzek@gmail.com>
// SPDX-License-Identifier: CC-BY-NC-ND-4.0
#pragma once
@ -43,6 +43,7 @@ public:
void initialize();
void resizeTableViewColumnsToFit();
void setTableViewColumnHidden(int column, bool hidden);
void refresh(bool invalidate_cache);
void refreshModel();

View File

@ -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)

View File

@ -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,

View File

@ -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()

View File

@ -152,6 +152,7 @@ Q_SIGNALS:
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);

View File

@ -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<u32>(std::ceil(LayoutScale(static_cast<float>(svg_width))));
svg_height = static_cast<u32>(std::ceil(LayoutScale(static_cast<float>(svg_height))));
const SmallString wh_name = SmallString::from_format("{}#{}x{}", name, svg_width, svg_height);
std::shared_ptr<GPUTexture>* 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> 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

View File

@ -141,6 +141,7 @@ std::shared_ptr<GPUTexture> 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();