[UI] Surface corrupted dashboard gpds in game list and allow removal

This commit is contained in:
Herman S.
2025-10-10 09:41:13 +09:00
parent 6a63399744
commit 3e5b3580df
6 changed files with 253 additions and 32 deletions
+183 -16
View File
@@ -43,6 +43,7 @@
#include "xenia/kernel/xam/user_tracker.h"
#include "xenia/kernel/xam/xam_state.h"
#include "xenia/kernel/xam/xdbf/gpd_info_profile.h"
#include "xenia/kernel/xam/xdbf/gpd_info_title.h"
namespace xe {
namespace app {
@@ -441,7 +442,7 @@ void GameListDialogQt::LoadGameList() {
}
void GameListDialogQt::TryLoadIcons() {
// Icons are only loaded if a user is logged in
// Icons and achievement data are only loaded if a user is logged in
if (!emulator_window_ || !emulator_window_->emulator()) {
has_logged_in_profile_ = false;
return;
@@ -472,10 +473,27 @@ void GameListDialogQt::TryLoadIcons() {
if (profile) {
has_logged_in_profile_ = true;
// Load icons for all game entries from this profile's title GPDs
XELOGI("Loading icons for {} game entries from profile {:016X}",
game_entries_.size(), profile->xuid());
for (const auto& game_entry : game_entries_) {
// Load icons and achievement data for all game entries from this
// profile's title GPDs
XELOGI(
"Loading icons and achievement data for {} game entries from profile "
"{:016X}",
game_entries_.size(), profile->xuid());
for (auto& game_entry : game_entries_) {
// Get achievement stats from the profile
auto stats = profile->GetTitleAchievementStats(game_entry.title_id);
game_entry.achievements_total = stats.achievements_total;
game_entry.achievements_unlocked = stats.achievements_unlocked;
game_entry.gamerscore_total = stats.gamerscore_total;
game_entry.gamerscore_earned = stats.gamerscore_earned;
if (stats.achievements_total > 0) {
XELOGI("Title {:08X}: {}/{} achievements, {}/{} gamerscore",
game_entry.title_id, game_entry.achievements_unlocked,
game_entry.achievements_total, game_entry.gamerscore_earned,
game_entry.gamerscore_total);
}
// Skip if we already loaded this icon
if (title_icons_.find(game_entry.title_id) != title_icons_.end()) {
continue;
@@ -591,14 +609,23 @@ void GameListDialogQt::PopulateTable() {
title_layout->setContentsMargins(0, 0, 0, 0);
title_layout->setSpacing(2);
// Check if file is corrupted (GPD has no title name)
bool file_corrupted = entry.title_name.empty();
// Title - large font
auto* title_label = new QLabel(QString::fromStdString(entry.title_name));
QString display_title = file_corrupted
? QString("File Corrupted")
: QString::fromStdString(entry.title_name);
auto* title_label = new QLabel(display_title);
title_label->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
title_label->setAttribute(Qt::WA_TransparentForMouseEvents);
QFont title_font = title_label->font();
title_font.setBold(true);
title_font.setPointSize(title_font.pointSize() * 1.5); // 1.5x larger
title_label->setFont(title_font);
if (file_corrupted) {
title_label->setStyleSheet("color: red;");
}
title_layout->addWidget(title_label);
// Path - smaller font (only show if path is available)
@@ -614,6 +641,35 @@ void GameListDialogQt::PopulateTable() {
title_layout->addWidget(path_label);
}
// Achievement info - smaller font (only show if there are achievements)
if (entry.achievements_total > 0) {
QString achievement_text =
QString("Achievements: %1/%2 | Gamerscore: %3/%4")
.arg(entry.achievements_unlocked)
.arg(entry.achievements_total)
.arg(entry.gamerscore_earned)
.arg(entry.gamerscore_total);
auto* achievement_label = new QLabel(achievement_text);
achievement_label->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
achievement_label->setAttribute(Qt::WA_TransparentForMouseEvents);
QFont achievement_font = achievement_label->font();
achievement_font.setPointSize(achievement_font.pointSize() *
0.8); // Smaller font
achievement_label->setFont(achievement_font);
// Color based on completion
if (entry.achievements_unlocked == entry.achievements_total &&
entry.achievements_total > 0) {
achievement_label->setStyleSheet("color: #4CAF50;"); // Green for 100%
} else if (entry.achievements_unlocked > 0) {
achievement_label->setStyleSheet(
"color: #FFA726;"); // Orange for partial
} else {
achievement_label->setStyleSheet("color: gray;"); // Gray for none
}
title_layout->addWidget(achievement_label);
}
row_layout->addWidget(title_container, 1); // Stretch factor
// Last played
@@ -627,10 +683,11 @@ void GameListDialogQt::PopulateTable() {
table_widget_->setCellWidget(row, 0, row_widget);
table_widget_->setRowHeight(row, 100);
// Store the path in the row for later retrieval
// Store the path and title_id in the row for later retrieval
table_widget_->setItem(row, 0, new QTableWidgetItem());
table_widget_->item(row, 0)->setData(
Qt::UserRole, QString::fromStdString(entry.path_to_file.string()));
table_widget_->item(row, 0)->setData(Qt::UserRole + 1, entry.title_id);
}
}
@@ -674,23 +731,34 @@ void GameListDialogQt::OnGameRightClicked(const QPoint& pos) {
}
QString path_str = item->data(Qt::UserRole).toString();
if (path_str.isEmpty()) {
return;
}
uint32_t title_id = item->data(Qt::UserRole + 1).toUInt();
std::filesystem::path path = path_str.toStdString();
bool has_path = !path_str.isEmpty();
QMenu context_menu(this);
QAction* launch_action = context_menu.addAction("Launch");
QAction* open_folder_action =
context_menu.addAction("Open containing folder");
QMenu context_menu;
// Only show Launch and Open containing folder if the entry has a path
QAction* launch_action = nullptr;
QAction* open_folder_action = nullptr;
if (has_path) {
launch_action = context_menu.addAction("Launch");
open_folder_action = context_menu.addAction("Open containing folder");
context_menu.addSeparator();
}
// Remove option is always available for all entries
QAction* remove_action = context_menu.addAction("Remove from list");
QAction* selected = context_menu.exec(table_widget_->mapToGlobal(pos));
if (selected == launch_action) {
if (selected == launch_action && launch_action) {
LaunchGame(path);
} else if (selected == open_folder_action) {
} else if (selected == open_folder_action && open_folder_action) {
OpenContainingFolder(path);
} else if (selected == remove_action) {
RemoveTitleFromDashboard(title_id);
}
}
@@ -710,6 +778,105 @@ void GameListDialogQt::OpenContainingFolder(const std::filesystem::path& path) {
path_open.detach();
}
void GameListDialogQt::RemoveTitleFromDashboard(uint32_t title_id) {
if (!emulator_window_ || !emulator_window_->emulator()) {
return;
}
auto kernel_state = emulator_window_->emulator()->kernel_state();
if (!kernel_state) {
return;
}
auto xam_state = kernel_state->xam_state();
if (!xam_state) {
return;
}
auto profile_manager = xam_state->profile_manager();
if (!profile_manager) {
return;
}
// Ask for confirmation
QMessageBox::StandardButton reply;
reply = QMessageBox::question(
nullptr, "Remove Title",
QString("Are you sure you want to remove this title from the game list?"),
QMessageBox::Yes | QMessageBox::No);
if (reply != QMessageBox::Yes) {
return;
}
// Remove the title from all profiles' dashboard GPDs
auto content_root = emulator_window_->emulator()->content_root();
auto profiles_directory = xe::filesystem::FilterByName(
xe::filesystem::ListDirectories(content_root),
std::regex("[0-9A-F]{16}"));
bool removed_from_any = false;
for (const auto& profile_dir : profiles_directory) {
const std::string profile_xuid = xe::path_to_utf8(profile_dir.name);
if (profile_xuid == fmt::format("{:016X}", 0)) {
continue; // Skip shared content directory
}
// Construct path to dashboard GPD
std::filesystem::path dashboard_gpd_path =
profile_dir.path / profile_dir.name / kernel::xam::kDashboardStringID /
fmt::format("{:08X}", static_cast<uint32_t>(XContentType::kProfile)) /
profile_dir.name / fmt::format("{:08X}.gpd", kernel::kDashboardID);
if (!std::filesystem::exists(dashboard_gpd_path)) {
continue;
}
// Read dashboard GPD file
std::ifstream file(dashboard_gpd_path, std::ios::binary);
if (!file.is_open()) {
continue;
}
file.seekg(0, std::ios::end);
size_t file_size = file.tellg();
file.seekg(0, std::ios::beg);
std::vector<uint8_t> gpd_data(file_size);
file.read(reinterpret_cast<char*>(gpd_data.data()), file_size);
file.close();
// Parse dashboard GPD
kernel::xam::GpdInfoProfile dashboard_gpd(gpd_data);
if (!dashboard_gpd.IsValid()) {
continue;
}
// Try to remove the title
if (dashboard_gpd.RemoveTitle(title_id)) {
// Write back the modified GPD
std::vector<uint8_t> serialized_gpd = dashboard_gpd.Serialize();
std::ofstream out_file(dashboard_gpd_path, std::ios::binary);
if (out_file.is_open()) {
out_file.write(reinterpret_cast<const char*>(serialized_gpd.data()),
serialized_gpd.size());
out_file.close();
removed_from_any = true;
}
}
}
if (removed_from_any) {
// Reload the game list
LoadGameList();
} else {
QMessageBox::warning(nullptr, "Remove Title",
"Failed to remove title from dashboard.");
}
}
void GameListDialogQt::OnPlayClicked() {
if (!selected_game_path_.empty()) {
LaunchGame(selected_game_path_);
+7
View File
@@ -39,6 +39,12 @@ struct GameListEntry {
time_t last_run_time;
uint32_t title_id;
std::vector<uint8_t> icon;
// Achievement data
uint32_t achievements_unlocked = 0;
uint32_t achievements_total = 0;
uint32_t gamerscore_earned = 0;
uint32_t gamerscore_total = 0;
};
class GameListDialogQt : public QWidget {
@@ -75,6 +81,7 @@ class GameListDialogQt : public QWidget {
QPixmap CreateIconPixmap(const std::vector<uint8_t>& icon_data);
void LaunchGame(const std::filesystem::path& path);
void OpenContainingFolder(const std::filesystem::path& path);
void RemoveTitleFromDashboard(uint32_t title_id);
std::string FormatLastPlayed(time_t timestamp);
EmulatorWindow* emulator_window_;
+30
View File
@@ -161,6 +161,36 @@ class UserProfile {
return std::vector<uint8_t>(icon_data.begin(), icon_data.end());
}
// Public accessor for getting achievement stats from title GPD
struct TitleAchievementStats {
uint32_t achievements_total = 0;
uint32_t achievements_unlocked = 0;
uint32_t gamerscore_total = 0;
uint32_t gamerscore_earned = 0;
};
TitleAchievementStats GetTitleAchievementStats(uint32_t title_id) const {
TitleAchievementStats stats;
// Get the GPD for this title
auto it = games_gpd_.find(title_id);
if (it == games_gpd_.end()) {
return stats;
}
const GpdInfoTitle& title_gpd = it->second;
if (!title_gpd.IsValid()) {
return stats;
}
stats.achievements_total = title_gpd.GetAchievementCount();
stats.achievements_unlocked = title_gpd.GetUnlockedAchievementCount();
stats.gamerscore_total = title_gpd.GetTotalGamerscore();
stats.gamerscore_earned = title_gpd.GetGamerscore();
return stats;
}
friend class UserTracker;
friend class GpdAchievementBackend;
friend class ProfileManager;
+20 -4
View File
@@ -9,6 +9,7 @@
#include "xenia/kernel/xam/xdbf/gpd_info_profile.h"
#include "xenia/base/logging.h"
#include "xenia/base/string_util.h"
#include <ranges>
@@ -89,13 +90,28 @@ void GpdInfoProfile::AddNewTitle(const SpaInfo* title_data) {
}
bool GpdInfoProfile::RemoveTitle(const uint32_t title_id) {
const Entry* entry =
GetEntry(static_cast<uint16_t>(GpdSection::kTitle), title_id);
if (!entry) {
// Find the entry by searching through all title entries and matching the
// title_id in the data (not the entry ID, which is different!)
const Entry* entry_to_delete = nullptr;
for (const auto& e : entries_) {
if (e.info.section.get() == static_cast<uint16_t>(GpdSection::kTitle)) {
if (e.data.size() >= sizeof(X_XDBF_GPD_TITLE_PLAYED)) {
auto* title_data =
reinterpret_cast<const X_XDBF_GPD_TITLE_PLAYED*>(e.data.data());
if (title_data->title_id.get() == title_id) {
entry_to_delete = &e;
break;
}
}
}
}
if (!entry_to_delete) {
return false;
}
DeleteEntry(entry);
DeleteEntry(entry_to_delete);
return true;
}
+9 -8
View File
@@ -153,37 +153,38 @@ void GpdInfoTitle::AddAchievement(const AchievementDetails* header) {
UpsertEntry(&new_entry);
}
uint32_t GpdInfoTitle::GetTotalGamerscore() {
uint32_t GpdInfoTitle::GetTotalGamerscore() const {
const auto ids = GetAchievementsIds();
uint32_t gamerscore = 0;
for (const auto id : ids) {
gamerscore += GetAchievementEntry(id)->gamerscore;
gamerscore +=
const_cast<GpdInfoTitle*>(this)->GetAchievementEntry(id)->gamerscore;
}
return gamerscore;
}
uint32_t GpdInfoTitle::GetGamerscore() {
uint32_t GpdInfoTitle::GetGamerscore() const {
const auto ids = GetAchievementsIds();
uint32_t gamerscore = 0;
for (const auto id : ids) {
const auto entry = GetAchievementEntry(id);
const auto entry = const_cast<GpdInfoTitle*>(this)->GetAchievementEntry(id);
if (entry->is_achievement_unlocked()) {
gamerscore += GetAchievementEntry(id)->gamerscore;
gamerscore += entry->gamerscore;
}
}
return gamerscore;
}
uint32_t GpdInfoTitle::GetAchievementCount() {
uint32_t GpdInfoTitle::GetAchievementCount() const {
return static_cast<uint32_t>(GetAchievementsIds().size());
}
uint32_t GpdInfoTitle::GetUnlockedAchievementCount() {
uint32_t GpdInfoTitle::GetUnlockedAchievementCount() const {
const auto ids = GetAchievementsIds();
uint32_t count = 0;
for (const auto id : ids) {
const auto entry = GetAchievementEntry(id);
const auto entry = const_cast<GpdInfoTitle*>(this)->GetAchievementEntry(id);
if (entry->is_achievement_unlocked()) {
count += 1;
}
+4 -4
View File
@@ -43,10 +43,10 @@ class GpdInfoTitle : public GpdInfo {
std::u16string GetAchievementDescription(const uint32_t id);
std::u16string GetAchievementUnachievedDescription(const uint32_t id);
uint32_t GetTotalGamerscore();
uint32_t GetGamerscore();
uint32_t GetAchievementCount();
uint32_t GetUnlockedAchievementCount();
uint32_t GetTotalGamerscore() const;
uint32_t GetGamerscore() const;
uint32_t GetAchievementCount() const;
uint32_t GetUnlockedAchievementCount() const;
private:
const char16_t* GetAchievementTitlePtr(const uint32_t id);