diff options
| author | snowstormxd <214507868+snowstormxd@users.noreply.github.com> | 2026-11-01 18:37:51 +0000 |
|---|---|---|
| committer | snowstormxd <214507868+snowstormxd@users.noreply.github.com> | 2026-11-01 20:52:30 +0000 |
| commit | 8e777214b02be0f254abdcc1adbc195e765e36a6 (patch) | |
| tree | f205fffbad3c78bdbfff2d54356d43b421666942 /src/qt_common/game_list | |
| parent | ac22c4087d9a6380c1937337530a5d4e54808ad1 (diff) | |
game_list: discover loose dumps without walking their RomFS payloads
Defer RomFS construction, reuse one discovery result, omit recursive folder sizing, and release rows discarded by cancellation. Add scan suspension and regression coverage for extracted layouts and Unicode paths.
Assisted-by: Codex (implementation, selective integration and automated validation)
Diffstat (limited to 'src/qt_common/game_list')
| -rw-r--r-- | src/qt_common/game_list/discovery.h | 113 | ||||
| -rw-r--r-- | src/qt_common/game_list/model.cpp | 21 | ||||
| -rw-r--r-- | src/qt_common/game_list/model.h | 2 | ||||
| -rw-r--r-- | src/qt_common/game_list/worker.cpp | 149 | ||||
| -rw-r--r-- | src/qt_common/game_list/worker.h | 4 |
5 files changed, 195 insertions, 94 deletions
diff --git a/src/qt_common/game_list/discovery.h b/src/qt_common/game_list/discovery.h new file mode 100644 index 0000000000..8d2054d308 --- /dev/null +++ b/src/qt_common/game_list/discovery.h @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include <algorithm> +#include <cctype> +#include <filesystem> +#include <unordered_set> +#include <utility> +#include <vector> + +namespace GameListDiscovery { + +struct Result { + std::vector<std::filesystem::path> files; + std::vector<std::filesystem::path> directories; + std::size_t skipped_romfs{}; + std::size_t errors{}; +}; + +inline bool IsExeFS(const std::filesystem::path& path) { + std::error_code ec; + return std::filesystem::is_regular_file(path / "main", ec) && + std::filesystem::is_regular_file(path / "main.npdm", ec); +} + +template <typename Supported, typename Stopped> +Result Discover(const std::filesystem::path& root, bool deep, Supported supported, + Stopped stopped) { + namespace fs = std::filesystem; + Result result; + std::unordered_set<fs::path> files; + std::unordered_set<fs::path> directories; + const auto add_file = [&](const fs::path& path) { + if (files.insert(path).second) { + result.files.push_back(path); + } + }; + const auto watch = [&](const fs::path& path) { + if (directories.insert(path).second) { + result.directories.push_back(path); + } + }; + const auto probe = [&](const fs::path& path) { + if (IsExeFS(path)) { + watch(path); + add_file(path / "main"); + } + }; + const auto probe_layouts = [&](const fs::path& path) { + for (const auto& content : {path, path / "base"}) { + probe(content / "exefs"); + const auto updates = content / "updates"; + std::error_code ec; + if (!fs::is_directory(updates, ec)) { + continue; + } + watch(updates); + for (fs::directory_iterator it{updates, fs::directory_options::skip_permission_denied, + ec}; + !ec && it != fs::directory_iterator{} && !stopped(); it.increment(ec)) { + probe(it->path() / "exefs"); + } + } + }; + + std::vector<std::pair<fs::path, unsigned>> pending{{root, 0}}; + while (!pending.empty() && !stopped()) { + auto [directory, depth] = std::move(pending.back()); + pending.pop_back(); + watch(directory); + if (IsExeFS(directory)) { + add_file(directory / "main"); + continue; + } + if (!deep) { + probe_layouts(directory); + } + std::error_code ec; + for (fs::directory_iterator it{directory, fs::directory_options::skip_permission_denied, + ec}; + !ec && it != fs::directory_iterator{} && !stopped(); it.increment(ec)) { + const auto& entry = *it; + std::error_code entry_ec; + if (entry.is_directory(entry_ec)) { + if (entry.is_symlink(entry_ec)) { + continue; + } + auto name = entry.path().filename().u8string(); + std::transform(name.begin(), name.end(), name.begin(), [](char8_t c) { + return static_cast<char8_t>(std::tolower(static_cast<unsigned char>(c))); + }); + if (name == u8"romfs" && IsExeFS(directory / "exefs")) { + ++result.skipped_romfs; + continue; + } + if (deep || depth == 0) { + pending.emplace_back(entry.path(), depth + 1); + } + } else if (entry.is_regular_file(entry_ec) && depth == 0 && !deep) { + if (supported(entry.path())) { + add_file(entry.path()); + } + } else if (entry.is_regular_file(entry_ec) && deep && supported(entry.path())) { + add_file(entry.path()); + } + } + result.errors += ec ? 1 : 0; + } + return result; +} + +} // namespace GameListDiscovery diff --git a/src/qt_common/game_list/model.cpp b/src/qt_common/game_list/model.cpp index 4f3916b04d..23d8ec1f43 100644 --- a/src/qt_common/game_list/model.cpp +++ b/src/qt_common/game_list/model.cpp @@ -44,9 +44,21 @@ GameListModel::GameListModel(std::shared_ptr<FileSys::VfsFilesystem> vfs_, setSortRole(GameListItemPath::SortRole); } -GameListModel::~GameListModel() = default; +GameListModel::~GameListModel() { + StopWorker(); +} + +void GameListModel::SetScanEnabled(bool enabled) { + scan_enabled = enabled; + if (!enabled) { + StopWorker(); + } +} void GameListModel::PopulateAsync(QVector<UISettings::GameDir>& game_dirs) { + if (!scan_enabled || system.IsPoweredOn()) { + return; + } emit PopulatingStarted(); StopWorker(); @@ -62,11 +74,14 @@ void GameListModel::PopulateAsync(QVector<UISettings::GameDir>& game_dirs) { } void GameListModel::WorkerEvent() { - current_worker->ProcessEvents(this); + if (current_worker) { + current_worker->ProcessEvents(this); + } } void GameListModel::AddDirEntry(GameListDir* entry_items) { if (m_flat) { + delete entry_items; return; } invisibleRootItem()->appendRow(entry_items); @@ -172,7 +187,7 @@ void GameListModel::StopWorker() { void GameListModel::Repopulate() { // CreateFactories replaces the content providers a running game reads from. Stopping // emulation repopulates the list anyway, so skip watcher-driven reloads until then. - if (QtCommon::system->IsPoweredOn()) { + if (!scan_enabled || QtCommon::system->IsPoweredOn()) { LOG_INFO(Frontend, "Emulation is running, deferring game list reload."); return; } diff --git a/src/qt_common/game_list/model.h b/src/qt_common/game_list/model.h index b850895d2f..e664623640 100644 --- a/src/qt_common/game_list/model.h +++ b/src/qt_common/game_list/model.h @@ -51,6 +51,7 @@ public: void PopulateAsync(QVector<UISettings::GameDir>& game_dirs); void WorkerEvent(); + void SetScanEnabled(bool enabled); bool IsEmpty() const; @@ -81,6 +82,7 @@ private: void StopWorker(); bool m_flat = false; + bool scan_enabled = true; std::shared_ptr<FileSys::VfsFilesystem> vfs; FileSys::ManualContentProvider* provider; diff --git a/src/qt_common/game_list/worker.cpp b/src/qt_common/game_list/worker.cpp index d18f84089a..371f0e8e2d 100644 --- a/src/qt_common/game_list/worker.cpp +++ b/src/qt_common/game_list/worker.cpp @@ -5,6 +5,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later #include <algorithm> +#include <chrono> #include <filesystem> #include <memory> #include <string> @@ -36,10 +37,23 @@ #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<QStandardItem*> row) : items{std::move(row)} {} + PendingRow(const PendingRow&) = delete; + QList<QStandardItem*> items; + ~PendingRow() { + qDeleteAll(items); + } + QList<QStandardItem*> Release() { + return std::exchange(items, {}); + } +}; + QString GetGameListCachedObject(const std::string& filename, const std::string& ext, const std::function<QString()>& generator) { if (!UISettings::values.cache_game_list || filename == "0000000000000000") { @@ -212,36 +226,6 @@ QString LooseExeFSName(const std::string& physical_name) { return name; } -// A loose dump's game list entry is its small main executable, so the size column would only -// show a few dozen MB. Sum the dump's real contents instead: the ExeFS directory plus the -// RomFS directory next to it, matching the loader's loose RomFS discovery. -u64 LooseDumpSize(const std::filesystem::path& main_path) { - const auto exefs_dir = main_path.parent_path(); - - u64 total{}; - std::error_code ec; - const auto accumulate = [&total, &ec](const std::filesystem::path& dir) { - for (std::filesystem::recursive_directory_iterator it{ - dir, std::filesystem::directory_options::skip_permission_denied, ec}; - !ec && it != std::filesystem::recursive_directory_iterator(); it.increment(ec)) { - std::error_code entry_ec; - if (it->is_regular_file(entry_ec)) { - const auto size = it->file_size(entry_ec); - if (!entry_ec) { - total += size; - } - } - } - }; - - accumulate(exefs_dir); - const auto romfs_dir = exefs_dir.parent_path() / "romfs"; - if (std::filesystem::is_directory(romfs_dir, ec)) { - accumulate(romfs_dir); - } - return total; -} - QString FormatPatchNameVersions(const FileSys::PatchManager& patch_manager, Loader::AppLoader& loader, bool updatable = true) { QString out; @@ -408,11 +392,12 @@ void GameListWorker::AddTitlesToGameList(GameListDir* parent_dir) { auto entry = MakeGameListEntry(file->GetFullPath(), name, file->GetSize(), icon, *loader, program_id, play_time_manager, patch); - RecordEvent([=](GameListModel* model) { model->AddEntry(entry, parent_dir); }); + 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::string& dir_path, bool deep_scan, +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) { @@ -476,14 +461,21 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa const FileSys::PatchManager patch{id, system.GetFileSystemController(), system.GetContentProvider()}; - const u64 size = IsExtractedNCAMain(physical_name) - ? LooseDumpSize(path) - : Common::FS::GetSize(physical_name); + 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); - RecordEvent([=](GameListModel* model) { model->AddEntry(entry, parent_dir); }); + 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 && @@ -508,67 +500,27 @@ void GameListWorker::ScanFileSystem(ScanTarget target, const std::string& dir_pa } }; - const auto callback = [this, target, deep_scan, - &process_candidate](const std::filesystem::path& path) -> bool { + for (const auto& path : files) { if (stop_requested) { - // Breaks the callback loop. - return false; - } - - if (Common::FS::IsDir(path)) { - watch_list.append(QString::fromStdString(Common::FS::PathToUTF8String(path))); - - // Shallow scans stop at the first level, so probe the standard layouts of loose - // ExeFS dumps: <game>/exefs/main, <game>/base/exefs/main and their updates in - // <root>/updates/<title id>/exefs/main. - if (!deep_scan && target == ScanTarget::PopulateGameList) { - for (const auto& content_root : {path, path / "base"}) { - const auto exefs_main = content_root / "exefs" / "main"; - if (Common::FS::IsFile(exefs_main)) { - process_candidate(exefs_main); - } - - const auto updates = content_root / "updates"; - if (Common::FS::IsDir(updates)) { - Common::FS::IterateDirEntries( - updates, - [&process_candidate](const std::filesystem::path& entry) { - const auto update_main = entry / "exefs" / "main"; - if (Common::FS::IsFile(update_main)) { - process_candidate(update_main); - } - return true; - }, - Common::FS::DirEntryFilter::Directory); - } - } - } - - return true; + break; } - - const auto physical_name = Common::FS::PathToUTF8String(path); - if (HasSupportedFileExtension(physical_name) || IsExtractedNCAMain(physical_name)) { - process_candidate(path); + // Loose executables cannot contribute NCA content to the manual provider. + if (target == ScanTarget::FillManualContentProvider && + IsExtractedNCAMain(Common::FS::PathToUTF8String(path))) { + continue; } - - 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::All); + 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) { - RecordEvent([=](GameListModel* model) { model->AddDirEntry(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) { @@ -597,15 +549,32 @@ void GameListWorker::run() { DirEntryReady(game_list_dir); if (scan) { - 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); + const auto discovery = GameListDiscovery::Discover( + std::filesystem::u8path(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); } } + 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(); } diff --git a/src/qt_common/game_list/worker.h b/src/qt_common/game_list/worker.h index cbc1fa7021..d5bb5fbd7b 100644 --- a/src/qt_common/game_list/worker.h +++ b/src/qt_common/game_list/worker.h @@ -8,8 +8,10 @@ #include <atomic> #include <deque> +#include <filesystem> #include <memory> #include <string> +#include <vector> #include <QList> #include <QObject> @@ -80,7 +82,7 @@ private: PopulateGameList, }; - void ScanFileSystem(ScanTarget target, const std::string& dir_path, bool deep_scan, + void ScanFileSystem(ScanTarget target, const std::vector<std::filesystem::path>& files, GameListDir* parent_dir); std::shared_ptr<FileSys::VfsFilesystem> vfs; |
