From 3e5b3580dfb8bbd1e009ee7763a145d4857b6e43 Mon Sep 17 00:00:00 2001 From: "Herman S." <429230+has207@users.noreply.github.com> Date: Fri, 10 Oct 2025 09:41:13 +0900 Subject: [PATCH] [UI] Surface corrupted dashboard gpds in game list and allow removal --- src/xenia/app/game_list_dialog_qt.cc | 199 ++++++++++++++++-- src/xenia/app/game_list_dialog_qt.h | 7 + src/xenia/kernel/xam/user_profile.h | 30 +++ src/xenia/kernel/xam/xdbf/gpd_info_profile.cc | 24 ++- src/xenia/kernel/xam/xdbf/gpd_info_title.cc | 17 +- src/xenia/kernel/xam/xdbf/gpd_info_title.h | 8 +- 6 files changed, 253 insertions(+), 32 deletions(-) diff --git a/src/xenia/app/game_list_dialog_qt.cc b/src/xenia/app/game_list_dialog_qt.cc index 3e5495964..deb191673 100644 --- a/src/xenia/app/game_list_dialog_qt.cc +++ b/src/xenia/app/game_list_dialog_qt.cc @@ -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(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 gpd_data(file_size); + file.read(reinterpret_cast(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 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(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_); diff --git a/src/xenia/app/game_list_dialog_qt.h b/src/xenia/app/game_list_dialog_qt.h index bc50e8615..3a3b0d5aa 100644 --- a/src/xenia/app/game_list_dialog_qt.h +++ b/src/xenia/app/game_list_dialog_qt.h @@ -39,6 +39,12 @@ struct GameListEntry { time_t last_run_time; uint32_t title_id; std::vector 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& 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_; diff --git a/src/xenia/kernel/xam/user_profile.h b/src/xenia/kernel/xam/user_profile.h index b848a902c..7a8690bfe 100644 --- a/src/xenia/kernel/xam/user_profile.h +++ b/src/xenia/kernel/xam/user_profile.h @@ -161,6 +161,36 @@ class UserProfile { return std::vector(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; diff --git a/src/xenia/kernel/xam/xdbf/gpd_info_profile.cc b/src/xenia/kernel/xam/xdbf/gpd_info_profile.cc index 16e10d1e2..af2a08cbc 100644 --- a/src/xenia/kernel/xam/xdbf/gpd_info_profile.cc +++ b/src/xenia/kernel/xam/xdbf/gpd_info_profile.cc @@ -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 @@ -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(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(GpdSection::kTitle)) { + if (e.data.size() >= sizeof(X_XDBF_GPD_TITLE_PLAYED)) { + auto* title_data = + reinterpret_cast(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; } diff --git a/src/xenia/kernel/xam/xdbf/gpd_info_title.cc b/src/xenia/kernel/xam/xdbf/gpd_info_title.cc index a5a50a0c6..a705c34e5 100644 --- a/src/xenia/kernel/xam/xdbf/gpd_info_title.cc +++ b/src/xenia/kernel/xam/xdbf/gpd_info_title.cc @@ -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(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(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(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(this)->GetAchievementEntry(id); if (entry->is_achievement_unlocked()) { count += 1; } diff --git a/src/xenia/kernel/xam/xdbf/gpd_info_title.h b/src/xenia/kernel/xam/xdbf/gpd_info_title.h index 574572e07..899de5c9c 100644 --- a/src/xenia/kernel/xam/xdbf/gpd_info_title.h +++ b/src/xenia/kernel/xam/xdbf/gpd_info_title.h @@ -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);