// 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 #include #include "common/fs/fs.h" #include "common/fs/fs_util.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 "qt_common/game_list/game_list_p.h" #include "qt_common/game_list/model.h" #include "qt_common/game_list/discovery.h" #include "qt_common/game_list/worker.h" namespace { struct PendingRow { explicit PendingRow(QList row) : items{std::move(row)} {} PendingRow(const PendingRow&) = delete; QList items; ~PendingRow() { qDeleteAll(items); } QList Release() { return std::exchange(items, {}); } }; 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; } // Loose ExeFS dumps (/exefs/main, /base/exefs/main, updates in // /updates//exefs/main) have no control data, so they are named after their // game folder. QString LooseExeFSName(const std::string& physical_name) { QDir dir = QFileInfo(QString::fromStdString(physical_name)).dir(); if (dir.dirName().compare(QStringLiteral("exefs"), Qt::CaseInsensitive) != 0 || !dir.cdUp()) { return {}; } // Storage levels sit between the game folder and its content directories. const auto is_storage_level = [](const QString& name) { if (name.compare(QStringLiteral("base"), Qt::CaseInsensitive) == 0 || name.compare(QStringLiteral("update"), Qt::CaseInsensitive) == 0 || name.compare(QStringLiteral("updates"), Qt::CaseInsensitive) == 0) { return true; } return !name.isEmpty() && std::all_of(name.cbegin(), name.cend(), [](QChar c) { return c.isDigit() || (c >= QLatin1Char('a') && c <= QLatin1Char('f')) || (c >= QLatin1Char('A') && c <= QLatin1Char('F')); }); }; bool is_update = false; while (is_storage_level(dir.dirName())) { if (dir.dirName().compare(QStringLiteral("update"), Qt::CaseInsensitive) == 0 || dir.dirName().compare(QStringLiteral("updates"), Qt::CaseInsensitive) == 0) { is_update = true; } if (!dir.cdUp()) { return {}; } } const QString name = dir.dirName(); if (name.isEmpty()) { return {}; } if (is_update && !name.contains(QStringLiteral("update"), Qt::CaseInsensitive)) { return QObject::tr("%1 (update)").arg(name); } return name; } 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<QStandardItem*> MakeGameListEntry(const std::string& path, const std::string& name, const std::size_t size, const std::vector<u8>& icon, Loader::AppLoader& loader, u64 program_id, const PlayTime::PlayTimeManager& play_time_manager, const FileSys::PatchManager& patch) { 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<QStandardItem*>{ 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), }; } } // Anonymous namespace GameListWorker::GameListWorker(FileSys::VirtualFilesystem vfs_, FileSys::ManualContentProvider* provider_, QVector<UISettings::GameDir>& game_dirs_, const PlayTime::PlayTimeManager& play_time_manager_, Core::System& system_) : vfs{std::move(vfs_)}, provider{provider_}, game_dirs{game_dirs_}, play_time_manager{play_time_manager_}, system{system_} { // We want the game list to manage our lifetime. setAutoDelete(false); } GameListWorker::~GameListWorker() { this->disconnect(); Stop(); } void GameListWorker::Stop() { if (!stop_requested.exchange(true)) { processing_completed.Wait(); } } void GameListWorker::ProcessEvents(GameListModel* model) { // An event may spin a nested event loop that replaces this worker, in which case stop. while (model->current_worker.get() == this) { std::function<void(GameListModel*)> 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 <typename F> 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, GameListItemType parent_type) { using namespace FileSys; const auto& cache = system.GetContentProviderUnion(); auto installed_games = cache.ListEntriesFilterOrigin(std::nullopt, TitleType::Application, ContentRecordType::Program); if (parent_type == GameListItemType::SdmcDir) { installed_games = cache.ListEntriesFilterOrigin( ContentProviderUnionSlot::SDMC, TitleType::Application, ContentRecordType::Program); } else if (parent_type == GameListItemType::UserNandDir) { installed_games = cache.ListEntriesFilterOrigin( ContentProviderUnionSlot::UserNAND, TitleType::Application, ContentRecordType::Program); } else if (parent_type == 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::AppLoader> loader = Loader::GetLoader(system, file); if (!loader) { continue; } std::vector<u8> 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, play_time_manager, patch); auto pending = std::make_shared<PendingRow>(std::move(entry)); RecordEvent([=](GameListModel* model) { model->AddEntry(pending->Release(), parent_dir); }); } } void GameListWorker::ScanFileSystem(ScanTarget target, const std::vector<std::filesystem::path>& files, GameListDir* parent_dir) { const auto process_candidate = [this, target, parent_dir](const std::filesystem::path& path) { const auto physical_name = Common::FS::PathToUTF8String(path); const auto file = vfs->OpenFile(physical_name, FileSys::OpenMode::Read); if (!file) { return; } auto loader = Loader::GetLoader(system, file); if (!loader) { return; } const auto file_type = loader->GetFileType(); if (file_type == Loader::FileType::Unknown || file_type == Loader::FileType::Error) { return; } if (target == ScanTarget::PopulateGameList && (file_type == Loader::FileType::XCI || file_type == Loader::FileType::XCZ || file_type == Loader::FileType::NSP || file_type == Loader::FileType::NSZ || file_type == Loader::FileType::NUP) && !Loader::IsBootableGameContainer(file, file_type)) { return; } 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::XCZ || file_type == Loader::FileType::NSP || file_type == Loader::FileType::NSZ)) { void(provider->AddEntriesFromContainer(file)); } } else { std::vector<u64> program_ids; loader->ReadProgramIds(program_ids); const auto addEntry = [this, physical_name, parent_dir](std::unique_ptr<Loader::AppLoader>& app_loader, const u64 id) { std::vector<u8> icon; [[maybe_unused]] const auto res1 = app_loader->ReadIcon(icon); std::string name = " "; const auto res3 = app_loader->ReadTitle(name); if ((res3 != Loader::ResultStatus::Success || name.empty() || name == " ") && IsExtractedNCAMain(physical_name)) { if (const QString loose = LooseExeFSName(physical_name); !loose.isEmpty()) { name = loose.toStdString(); } } const FileSys::PatchManager patch{id, system.GetFileSystemController(), system.GetContentProvider()}; const bool loose_dump = IsExtractedNCAMain(physical_name); const u64 size = loose_dump ? 0 : Common::FS::GetSize(physical_name); auto entry = MakeGameListEntry(physical_name, name, size, icon, *app_loader, id, play_time_manager, patch); if (loose_dump) { entry[GameListModel::COLUMN_SIZE]->setText(tr("Not scanned")); entry[GameListModel::COLUMN_SIZE]->setToolTip( tr("Folder size is not calculated during game discovery.")); } auto pending = std::make_shared<PendingRow>(std::move(entry)); RecordEvent([=](GameListModel* model) { model->AddEntry(pending->Release(), parent_dir); }); }; if (res2 == Loader::ResultStatus::Success && program_ids.size() > 1 && (file_type == Loader::FileType::XCI || file_type == Loader::FileType::XCZ || file_type == Loader::FileType::NSP || file_type == Loader::FileType::NSZ)) { 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; } addEntry(loader, id); } } else { addEntry(loader, program_id); } } }; for (const auto& path : files) { if (stop_requested) { break; } // Loose executables cannot contribute NCA content to the manual provider. if (target == ScanTarget::FillManualContentProvider && IsExtractedNCAMain(Common::FS::PathToUTF8String(path))) { continue; } process_candidate(path); } } void GameListWorker::run() { const auto started = std::chrono::steady_clock::now(); watch_list.clear(); provider->ClearAllEntries(); const auto DirEntryReady = [&](GameListDir* game_list_dir) { auto pending = std::make_shared<std::unique_ptr<GameListDir>>(game_list_dir); RecordEvent([=](GameListModel* model) { model->AddDirEntry(pending->release()); }); }; for (UISettings::GameDir& game_dir : game_dirs) { if (stop_requested) { break; } GameListDir* game_list_dir; bool scan = false; if (game_dir.path == std::string("SDMC")) { game_list_dir = new GameListDir(game_dir, GameListItemType::SdmcDir); } else if (game_dir.path == std::string("UserNAND")) { game_list_dir = new GameListDir(game_dir, GameListItemType::UserNandDir); } else if (game_dir.path == std::string("SysNAND")) { game_list_dir = new GameListDir(game_dir, GameListItemType::SysNandDir); } else { const QString qpath = QString::fromStdString(game_dir.path); if (QDir(qpath).exists()) { watch_list.append(qpath); } game_list_dir = new GameListDir(game_dir); scan = true; } const auto parent_type = static_cast<GameListItemType>(game_list_dir->type()); DirEntryReady(game_list_dir); if (scan) { const auto discovery = GameListDiscovery::Discover( std::filesystem::path(Common::FS::ToU8String(game_dir.path)), game_dir.deep_scan, [](const auto& path) { const auto name = Common::FS::PathToUTF8String(path); return HasSupportedFileExtension(name) || IsExtractedNCAMain(name); }, [this] { return stop_requested.load(); }); for (const auto& directory : discovery.directories) { watch_list.append(QString::fromStdString(Common::FS::PathToUTF8String(directory))); } LOG_INFO(Frontend, "Discovery: candidates={} directories={} skipped_romfs={} errors={} cancelled={}", discovery.files.size(), discovery.directories.size(), discovery.skipped_romfs, discovery.errors, stop_requested.load()); ScanFileSystem(ScanTarget::FillManualContentProvider, discovery.files, game_list_dir); ScanFileSystem(ScanTarget::PopulateGameList, discovery.files, game_list_dir); } else { AddTitlesToGameList(game_list_dir, parent_type); } } watch_list.removeDuplicates(); LOG_INFO(Frontend, "Game discovery finished: elapsed_ms={} watched={} cancelled={}", std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::steady_clock::now() - started).count(), watch_list.size(), stop_requested.load()); RecordEvent([this](GameListModel* model) { model->DonePopulating(watch_list); }); processing_completed.Set(); }