From cc8451f764979db23208dd5a55884cd62fd8b673 Mon Sep 17 00:00:00 2001 From: crueter Date: Tue, 2 Jun 2026 04:08:24 +0200 Subject: [desktop] Rework game list to use MVP architecture (#4042) Closes #3480 moves the game list model/worker/private stuff to qt_common for later use in QML - `qt_common/game_list/model.{cpp,h}` is the model - `yuzu/game/game_{grid,tree}.*` are the views - `yuzu/game/game_list.cpp` is the presenter This was done very lazily in a manner that "works" while largely maintaining existing structure as much as possible. Most of it is copy-paste, with some bonus reworks/cleanups thrown in. Signed-off-by: crueter Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/4042 Reviewed-by: MaranBr Reviewed-by: Lizzie --- src/qt_common/game_list/worker.cpp | 497 +++++++++++++++++++++++++++++++++++++ 1 file changed, 497 insertions(+) create mode 100644 src/qt_common/game_list/worker.cpp (limited to 'src/qt_common/game_list/worker.cpp') diff --git a/src/qt_common/game_list/worker.cpp b/src/qt_common/game_list/worker.cpp new file mode 100644 index 0000000000..f1499265c6 --- /dev/null +++ b/src/qt_common/game_list/worker.cpp @@ -0,0 +1,497 @@ +// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project +// SPDX-License-Identifier: GPL-3.0-or-later + +// SPDX-FileCopyrightText: Copyright 2018 yuzu Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "common/fs/fs.h" +#include "common/fs/path_util.h" +#include "common/settings.h" +#include "core/core.h" +#include "core/file_sys/card_image.h" +#include "core/file_sys/content_archive.h" +#include "core/file_sys/control_metadata.h" +#include "core/file_sys/fs_filesystem.h" +#include "core/file_sys/nca_metadata.h" +#include "core/file_sys/patch_manager.h" +#include "core/file_sys/registered_cache.h" +#include "core/file_sys/submission_package.h" +#include "core/loader/loader.h" + +#include "qt_common/config/uisettings.h" +#include "qt_common/qt_common.h" + +#include "yuzu/compatibility_list.h" +#include "qt_common/game_list/game_list_p.h" + +#include "qt_common/game_list/worker.h" +#include "qt_common/game_list/model.h" + +namespace { + +QString GetGameListCachedObject(const std::string& filename, const std::string& ext, + const std::function& generator) { + if (!UISettings::values.cache_game_list || filename == "0000000000000000") { + return generator(); + } + + const auto path = + Common::FS::PathToUTF8String(Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / + "game_list" / fmt::format("{}.{}", filename, ext)); + + void(Common::FS::CreateParentDirs(path)); + + if (!Common::FS::Exists(path)) { + const auto str = generator(); + + QFile file{QString::fromStdString(path)}; + if (file.open(QFile::WriteOnly)) { + file.write(str.toUtf8()); + } + + return str; + } + + QFile file{QString::fromStdString(path)}; + if (file.open(QFile::ReadOnly)) { + return QString::fromUtf8(file.readAll()); + } + + return generator(); +} + +std::pair, std::string> GetGameListCachedObject( + const std::string& filename, const std::string& ext, + const std::function, std::string>()>& generator) { + if (!UISettings::values.cache_game_list || filename == "0000000000000000") { + return generator(); + } + + const auto game_list_dir = + Common::FS::GetEdenPath(Common::FS::EdenPath::CacheDir) / "game_list"; + const auto jpeg_name = fmt::format("{}.jpeg", filename); + const auto app_name = fmt::format("{}.appname.txt", filename); + + const auto path1 = Common::FS::PathToUTF8String(game_list_dir / jpeg_name); + const auto path2 = Common::FS::PathToUTF8String(game_list_dir / app_name); + + void(Common::FS::CreateParentDirs(path1)); + + if (!Common::FS::Exists(path1) || !Common::FS::Exists(path2)) { + const auto [icon, nacp] = generator(); + + QFile file1{QString::fromStdString(path1)}; + if (!file1.open(QFile::WriteOnly)) { + LOG_ERROR(Frontend, "Failed to open cache file."); + return generator(); + } + + if (!file1.resize(icon.size())) { + LOG_ERROR(Frontend, "Failed to resize cache file to necessary size."); + return generator(); + } + + if (file1.write(reinterpret_cast(icon.data()), icon.size()) != + s64(icon.size())) { + LOG_ERROR(Frontend, "Failed to write data to cache file."); + return generator(); + } + + QFile file2{QString::fromStdString(path2)}; + if (file2.open(QFile::WriteOnly)) { + file2.write(nacp.data(), nacp.size()); + } + + return std::make_pair(icon, nacp); + } + + QFile file1(QString::fromStdString(path1)); + QFile file2(QString::fromStdString(path2)); + + if (!file1.open(QFile::ReadOnly)) { + LOG_ERROR(Frontend, "Failed to open cache file for reading."); + return generator(); + } + + if (!file2.open(QFile::ReadOnly)) { + LOG_ERROR(Frontend, "Failed to open cache file for reading."); + return generator(); + } + + std::vector vec(file1.size()); + if (file1.read(reinterpret_cast(vec.data()), vec.size()) != + static_cast(vec.size())) { + return generator(); + } + + const auto data = file2.readAll(); + return std::make_pair(vec, data.toStdString()); +} + +void GetMetadataFromControlNCA(const FileSys::PatchManager& patch_manager, const FileSys::NCA& nca, + std::vector& icon, std::string& name) { + std::tie(icon, name) = GetGameListCachedObject( + fmt::format("{:016X}", patch_manager.GetTitleID()), {}, [&patch_manager, &nca] { + const auto [nacp, icon_f] = patch_manager.ParseControlNCA(nca); + return std::make_pair(icon_f->ReadAllBytes(), nacp->GetApplicationName()); + }); +} + +bool HasSupportedFileExtension(const std::string& file_name) { + const QFileInfo file = QFileInfo(QString::fromStdString(file_name)); + return QtCommon::supported_file_extensions.contains(file.suffix(), Qt::CaseInsensitive); +} + +bool IsExtractedNCAMain(const std::string& file_name) { + return QFileInfo(QString::fromStdString(file_name)).fileName() == QStringLiteral("main"); +} + +QString FormatGameName(const std::string& physical_name) { + const QString physical_name_as_qstring = QString::fromStdString(physical_name); + const QFileInfo file_info(physical_name_as_qstring); + + if (IsExtractedNCAMain(physical_name)) { + return file_info.dir().path(); + } + + return physical_name_as_qstring; +} + +QString FormatPatchNameVersions(const FileSys::PatchManager& patch_manager, + Loader::AppLoader& loader, bool updatable = true) { + QString out; + FileSys::VirtualFile update_raw; + loader.ReadUpdateRaw(update_raw); + for (const auto& patch : patch_manager.GetPatches(update_raw)) { + const bool is_update = patch.name == "Update"; + if (!updatable && is_update) { + continue; + } + + const QString type = + QString::fromStdString(patch.enabled ? patch.name : "[D] " + patch.name); + + if (patch.version.empty()) { + out.append(QStringLiteral("%1\n").arg(type)); + } else { + auto ver = patch.version; + + // Display container name for packed updates + if (is_update && ver == "PACKED") { + ver = Loader::GetFileTypeString(loader.GetFileType()); + } + + out.append(QStringLiteral("%1 (%2)\n").arg(type, QString::fromStdString(ver))); + } + } + + out.chop(1); + return out; +} + +QList MakeGameListEntry(const std::string& path, const std::string& name, + const std::size_t size, const std::vector& icon, + Loader::AppLoader& loader, u64 program_id, + const CompatibilityList& compatibility_list, + const PlayTime::PlayTimeManager& play_time_manager, + const FileSys::PatchManager& patch) { + auto const it = FindMatchingCompatibilityEntry(compatibility_list, program_id); + // The game list uses 99 as compatibility number for untested games + QString compatibility = + it != compatibility_list.end() ? it->second.first : QStringLiteral("99"); + + auto const file_type = loader.GetFileType(); + auto const file_type_string = QString::fromStdString(Loader::GetFileTypeString(file_type)); + + QString patch_versions = GetGameListCachedObject( + fmt::format("{:016X}", patch.GetTitleID()), "pv.txt", [&patch, &loader] { + return FormatPatchNameVersions(patch, loader, loader.IsRomFSUpdatable()); + }); + + u64 play_time = play_time_manager.GetPlayTime(program_id); + return QList{ + new GameListItemPath(FormatGameName(path), icon, QString::fromStdString(name), + file_type_string, program_id, play_time, patch_versions), + new GameListItem(file_type_string), + new GameListItemSize(size), + new GameListItemPlayTime(play_time), + new GameListItem(patch_versions), + new GameListItemCompat(compatibility), + }; +} +} // Anonymous namespace + +GameListWorker::GameListWorker(FileSys::VirtualFilesystem vfs_, + FileSys::ManualContentProvider* provider_, + QVector& game_dirs_, + const CompatibilityList& compatibility_list_, + const PlayTime::PlayTimeManager& play_time_manager_, + Core::System& system_) + : vfs{std::move(vfs_)}, provider{provider_}, game_dirs{game_dirs_}, + compatibility_list{compatibility_list_}, play_time_manager{play_time_manager_}, + system{system_} { + // We want the game list to manage our lifetime. + setAutoDelete(false); +} + +GameListWorker::~GameListWorker() { + this->disconnect(); + stop_requested.store(true); + processing_completed.Wait(); +} + +void GameListWorker::ProcessEvents(GameListModel* model) { + while (true) { + std::function func; + { + // Lock queue to protect concurrent modification. + std::scoped_lock lk(lock); + + // If we can't pop a function, return. + if (queued_events.empty()) { + return; + } + + // Pop a function. + func = std::move(queued_events.back()); + queued_events.pop_back(); + } + + // Run the function. + func(model); + } +} + +template +void GameListWorker::RecordEvent(F&& func) { + { + // Lock queue to protect concurrent modification. + std::scoped_lock lk(lock); + + // Add the function into the front of the queue. + queued_events.emplace_front(std::move(func)); + } + + // Data now available. + emit DataAvailable(); +} + +void GameListWorker::AddTitlesToGameList(GameListDir* parent_dir) { + using namespace FileSys; + + const auto& cache = system.GetContentProviderUnion(); + + auto installed_games = cache.ListEntriesFilterOrigin(std::nullopt, TitleType::Application, + ContentRecordType::Program); + + if (parent_dir->type() == static_cast(GameListItemType::SdmcDir)) { + installed_games = cache.ListEntriesFilterOrigin( + ContentProviderUnionSlot::SDMC, TitleType::Application, ContentRecordType::Program); + } else if (parent_dir->type() == static_cast(GameListItemType::UserNandDir)) { + installed_games = cache.ListEntriesFilterOrigin( + ContentProviderUnionSlot::UserNAND, TitleType::Application, ContentRecordType::Program); + } else if (parent_dir->type() == static_cast(GameListItemType::SysNandDir)) { + installed_games = cache.ListEntriesFilterOrigin( + ContentProviderUnionSlot::SysNAND, TitleType::Application, ContentRecordType::Program); + } + + for (const auto& [slot, game] : installed_games) { + if (slot == ContentProviderUnionSlot::FrontendManual) { + continue; + } + + const auto file = cache.GetEntryUnparsed(game.title_id, game.type); + std::unique_ptr loader = Loader::GetLoader(system, file); + if (!loader) { + continue; + } + + std::vector icon; + std::string name; + u64 program_id = 0; + const auto result = loader->ReadProgramId(program_id); + + if (result != Loader::ResultStatus::Success) { + continue; + } + + const PatchManager patch{program_id, system.GetFileSystemController(), + system.GetContentProvider()}; + LOG_INFO(Frontend, "PatchManager initiated for id {:X}", program_id); + const auto control = cache.GetEntry(game.title_id, ContentRecordType::Control); + if (control != nullptr) { + GetMetadataFromControlNCA(patch, *control, icon, name); + } + + auto entry = MakeGameListEntry(file->GetFullPath(), name, file->GetSize(), icon, *loader, + program_id, compatibility_list, play_time_manager, patch); + RecordEvent([=](GameListModel* model) { model->AddEntry(entry, parent_dir); }); + } +} + +void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_path, bool deep_scan, + GameListDir* parent_dir) { + const auto callback = [this, target, parent_dir](const std::filesystem::path& path) -> bool { + if (stop_requested) { + // Breaks the callback loop. + return false; + } + + const auto physical_name = Common::FS::PathToUTF8String(path); + const auto is_dir = Common::FS::IsDir(path); + + if (!is_dir && + (HasSupportedFileExtension(physical_name) || IsExtractedNCAMain(physical_name))) { + const auto file = vfs->OpenFile(physical_name, FileSys::OpenMode::Read); + if (!file) { + return true; + } + + auto loader = Loader::GetLoader(system, file); + if (!loader) { + return true; + } + + const auto file_type = loader->GetFileType(); + if (file_type == Loader::FileType::Unknown || file_type == Loader::FileType::Error) { + return true; + } + + if (target == ScanTarget::PopulateGameList && + (file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP) && + !Loader::IsBootableGameContainer(file, file_type)) { + return true; + } + + u64 program_id = 0; + const auto res2 = loader->ReadProgramId(program_id); + + if (target == ScanTarget::FillManualContentProvider) { + if (res2 == Loader::ResultStatus::Success && file_type == Loader::FileType::NCA) { + provider->AddEntry(FileSys::TitleType::Application, + FileSys::GetCRTypeFromNCAType(FileSys::NCA{file}.GetType()), + program_id, file); + } else if (Settings::values.ext_content_from_game_dirs.GetValue() && + (file_type == Loader::FileType::XCI || + file_type == Loader::FileType::NSP)) { + void(provider->AddEntriesFromContainer(file)); + } + } else { + std::vector program_ids; + loader->ReadProgramIds(program_ids); + + if (res2 == Loader::ResultStatus::Success && program_ids.size() > 1 && + (file_type == Loader::FileType::XCI || file_type == Loader::FileType::NSP)) { + for (const auto id : program_ids) { + // dravee suggested this, only viable way to + // not show sub-games in qlaunch for now. + if ((id & 0xFFF) != 0) { + continue; + } + loader = Loader::GetLoader(system, file, id); + if (!loader) { + continue; + } + + std::vector icon; + [[maybe_unused]] const auto res1 = loader->ReadIcon(icon); + + std::string name = " "; + [[maybe_unused]] const auto res3 = loader->ReadTitle(name); + + const FileSys::PatchManager patch{id, system.GetFileSystemController(), + system.GetContentProvider()}; + + auto entry = MakeGameListEntry( + physical_name, name, Common::FS::GetSize(physical_name), icon, *loader, + id, compatibility_list, play_time_manager, patch); + + RecordEvent( + [=](GameListModel* model) { model->AddEntry(entry, parent_dir); }); + } + } else { + std::vector icon; + [[maybe_unused]] const auto res1 = loader->ReadIcon(icon); + + std::string name = " "; + [[maybe_unused]] const auto res3 = loader->ReadTitle(name); + + const FileSys::PatchManager patch{program_id, system.GetFileSystemController(), + system.GetContentProvider()}; + + auto entry = MakeGameListEntry( + physical_name, name, Common::FS::GetSize(physical_name), icon, *loader, + program_id, compatibility_list, play_time_manager, patch); + + RecordEvent( + [=](GameListModel* model) { model->AddEntry(entry, parent_dir); }); + } + } + } else if (is_dir) { + watch_list.append(QString::fromStdString(physical_name)); + } + + return true; + }; + + if (deep_scan) { + Common::FS::IterateDirEntriesRecursively(dir_path, callback, + Common::FS::DirEntryFilter::All); + } else { + Common::FS::IterateDirEntries(dir_path, callback, Common::FS::DirEntryFilter::File); + } +} + +void GameListWorker::run() { + watch_list.clear(); + provider->ClearAllEntries(); + + const auto DirEntryReady = [&](GameListDir* game_list_dir) { + RecordEvent([=](GameListModel* model) { model->AddDirEntry(game_list_dir); }); + }; + + for (UISettings::GameDir& game_dir : game_dirs) { + if (stop_requested) { + break; + } + + if (game_dir.path == std::string("SDMC")) { + auto* const game_list_dir = new GameListDir(game_dir, GameListItemType::SdmcDir); + DirEntryReady(game_list_dir); + AddTitlesToGameList(game_list_dir); + } else if (game_dir.path == std::string("UserNAND")) { + auto* const game_list_dir = new GameListDir(game_dir, GameListItemType::UserNandDir); + DirEntryReady(game_list_dir); + AddTitlesToGameList(game_list_dir); + } else if (game_dir.path == std::string("SysNAND")) { + auto* const game_list_dir = new GameListDir(game_dir, GameListItemType::SysNandDir); + DirEntryReady(game_list_dir); + AddTitlesToGameList(game_list_dir); + } else { + const QString qpath = QString::fromStdString(game_dir.path); + if (QDir(qpath).exists()) { + watch_list.append(qpath); + } + auto* const game_list_dir = new GameListDir(game_dir); + DirEntryReady(game_list_dir); + ScanFileSystem(ScanTarget::FillManualContentProvider, game_dir.path, game_dir.deep_scan, + game_list_dir); + ScanFileSystem(ScanTarget::PopulateGameList, game_dir.path, game_dir.deep_scan, + game_list_dir); + } + } + + RecordEvent([this](GameListModel* model) { model->DonePopulating(watch_list); }); + processing_completed.Set(); +} -- cgit v1.2.3