aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsnowstormxd <214507868+snowstormxd@users.noreply.github.com>2026-11-01 18:37:51 +0000
committersnowstormxd <214507868+snowstormxd@users.noreply.github.com>2026-11-01 20:52:30 +0000
commit8e777214b02be0f254abdcc1adbc195e765e36a6 (patch)
treef205fffbad3c78bdbfff2d54356d43b421666942
parentac22c4087d9a6380c1937337530a5d4e54808ad1 (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)
-rw-r--r--src/core/loader/deconstructed_rom_directory.cpp41
-rw-r--r--src/core/loader/deconstructed_rom_directory.h3
-rw-r--r--src/qt_common/game_list/discovery.h113
-rw-r--r--src/qt_common/game_list/model.cpp21
-rw-r--r--src/qt_common/game_list/model.h2
-rw-r--r--src/qt_common/game_list/worker.cpp149
-rw-r--r--src/qt_common/game_list/worker.h4
-rw-r--r--src/yuzu/game/game_list.cpp4
-rw-r--r--src/yuzu/game/game_list.h1
-rw-r--r--tools/integration/game_discovery_test.cpp75
10 files changed, 315 insertions, 98 deletions
diff --git a/src/core/loader/deconstructed_rom_directory.cpp b/src/core/loader/deconstructed_rom_directory.cpp
index cda1645548..94dd3395ba 100644
--- a/src/core/loader/deconstructed_rom_directory.cpp
+++ b/src/core/loader/deconstructed_rom_directory.cpp
@@ -6,7 +6,9 @@
#include <algorithm>
#include <cctype>
+#include <chrono>
#include <cstring>
+#include <mutex>
#include <string_view>
#include "common/logging.h"
#include "common/random.h"
@@ -90,8 +92,31 @@ FileSys::VirtualFile FindLooseRomFS(const FileSys::VirtualDir& exefs_dir) {
}
if (const auto parent = exefs_dir->GetParentDirectory()) {
if (const auto romfs_dir = parent->GetSubdirectory("romfs")) {
- LOG_INFO(Loader, "Using RomFS directory next to the ExeFS");
- return FileSys::CreateRomFS(romfs_dir);
+ // Keep no ownership here: launch preparation hands its image to the real loader.
+ static std::mutex cache_mutex;
+ static std::string cached_path;
+ static std::weak_ptr<FileSys::VfsFile> cached_romfs;
+ const auto path = romfs_dir->GetFullPath();
+ {
+ std::scoped_lock lock{cache_mutex};
+ if (cached_path == path) {
+ if (auto cached = cached_romfs.lock()) {
+ return cached;
+ }
+ }
+ }
+ const auto started = std::chrono::steady_clock::now();
+ LOG_INFO(Loader, "Building loose RomFS directory image");
+ auto image = FileSys::CreateRomFS(romfs_dir);
+ LOG_INFO(Loader, "Loose RomFS image ready: elapsed_ms={}",
+ std::chrono::duration_cast<std::chrono::milliseconds>(
+ std::chrono::steady_clock::now() - started).count());
+ {
+ std::scoped_lock lock{cache_mutex};
+ cached_path = path;
+ cached_romfs = image;
+ }
+ return image;
}
}
return nullptr;
@@ -233,7 +258,6 @@ AppLoader_DeconstructedRomDirectory::AppLoader_DeconstructedRomDirectory(FileSys
name = control->GetApplicationName();
}
- romfs = FindLooseRomFS(file_dir);
if (const auto title_dir = file_dir->GetParentDirectory()) {
romfs_version = RomFSProductVersion(title_dir->GetSubdirectory("romfs"));
}
@@ -245,7 +269,13 @@ AppLoader_DeconstructedRomDirectory::AppLoader_DeconstructedRomDirectory(
, dir(std::move(directory))
, override_update(override_update_)
{
- romfs = FindLooseRomFS(dir);
+}
+
+void AppLoader_DeconstructedRomDirectory::EnsureRomFS() {
+ if (!romfs_initialized) {
+ romfs = FindLooseRomFS(dir ? dir : file->GetContainingDirectory());
+ romfs_initialized = true;
+ }
}
FileType AppLoader_DeconstructedRomDirectory::IdentifyType(const FileSys::VirtualFile& dir_file) {
@@ -284,6 +314,8 @@ AppLoader_DeconstructedRomDirectory::LoadResult AppLoader_DeconstructedRomDirect
return {result, {}};
}
+ EnsureRomFS();
+
if (override_update) {
const FileSys::PatchManager patch_manager(
metadata.GetTitleID(), system.GetFileSystemController(), system.GetContentProvider());
@@ -414,6 +446,7 @@ AppLoader_DeconstructedRomDirectory::LoadResult AppLoader_DeconstructedRomDirect
}
ResultStatus AppLoader_DeconstructedRomDirectory::ReadRomFS(FileSys::VirtualFile& out_dir) {
+ EnsureRomFS();
if (romfs == nullptr) {
return ResultStatus::ErrorNoRomFS;
}
diff --git a/src/core/loader/deconstructed_rom_directory.h b/src/core/loader/deconstructed_rom_directory.h
index b976b71ecb..477638defe 100644
--- a/src/core/loader/deconstructed_rom_directory.h
+++ b/src/core/loader/deconstructed_rom_directory.h
@@ -58,8 +58,11 @@ public:
ResultStatus ReadNSOModules(Modules& out_modules) override;
private:
+ void EnsureRomFS();
+
FileSys::ProgramMetadata metadata;
FileSys::VirtualFile romfs;
+ bool romfs_initialized{};
FileSys::VirtualDir dir;
std::vector<u8> icon_data;
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;
diff --git a/src/yuzu/game/game_list.cpp b/src/yuzu/game/game_list.cpp
index cd7fc19de4..17183c02ec 100644
--- a/src/yuzu/game/game_list.cpp
+++ b/src/yuzu/game/game_list.cpp
@@ -155,6 +155,10 @@ void GameList::PopulateAsync(QVector<UISettings::GameDir>& game_dirs) {
item_model->PopulateAsync(game_dirs);
}
+void GameList::SetScanEnabled(bool enabled) {
+ item_model->SetScanEnabled(enabled);
+}
+
void GameList::SaveInterfaceLayout() {
tree_view->SaveInterfaceLayout();
}
diff --git a/src/yuzu/game/game_list.h b/src/yuzu/game/game_list.h
index 1540dd9cf0..161e66195f 100644
--- a/src/yuzu/game/game_list.h
+++ b/src/yuzu/game/game_list.h
@@ -76,6 +76,7 @@ public:
bool IsEmpty() const;
void PopulateAsync(QVector<UISettings::GameDir>& game_dirs);
+ void SetScanEnabled(bool enabled);
void SaveInterfaceLayout();
void LoadInterfaceLayout();
diff --git a/tools/integration/game_discovery_test.cpp b/tools/integration/game_discovery_test.cpp
new file mode 100644
index 0000000000..a5cfe0fc5a
--- /dev/null
+++ b/tools/integration/game_discovery_test.cpp
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+#include "qt_common/game_list/discovery.h"
+#include <chrono>
+#include <cstdlib>
+#include <fstream>
+#include <iostream>
+#include <source_location>
+
+namespace fs = std::filesystem;
+
+void Check(bool condition,
+ std::source_location location = std::source_location::current()) {
+ if (!condition) {
+ std::cerr << "Game discovery regression failed at line " << location.line()
+ << '\n';
+ std::exit(1);
+ }
+}
+
+int main() {
+ const auto temp = fs::temp_directory_path();
+ const auto root =
+ temp / ("eglantine-discovery-" +
+ std::to_string(
+ std::chrono::steady_clock::now().time_since_epoch().count()));
+ Check(fs::equivalent(root.parent_path(), temp) && fs::create_directory(root));
+ struct Cleanup {
+ fs::path path;
+ ~Cleanup() { fs::remove_all(path); }
+ } cleanup{root};
+ const auto touch = [](const fs::path &path) {
+ fs::create_directories(path.parent_path());
+ std::ofstream{path}.put('x');
+ };
+ const auto dump = [&](const fs::path &path) {
+ touch(path / "exefs/main");
+ touch(path / "exefs/main.npdm");
+ };
+ const auto supported = [](const fs::path &path) {
+ return path.filename() == "main" || path.extension() == ".nsp";
+ };
+ const auto discover = [&](const fs::path &path, bool deep) {
+ return GameListDiscovery::Discover(path, deep, supported,
+ [] { return false; });
+ };
+
+ const auto game = root / fs::path{u8"MKW-églantine"};
+ dump(game / "base");
+ dump(game / "updates/0100000000000800");
+ for (unsigned i = 0; i < 400; ++i) {
+ touch(game / "base/romfs/assets" / std::to_string(i) / "false-game.nsp");
+ }
+ touch(root / "standalone.nsp");
+ touch(root / "unrelated/romfs/actual-game.nsp");
+
+ const auto deep = discover(root, true);
+ Check(deep.files.size() == 4 && deep.skipped_romfs == 1 && deep.errors == 0);
+ Check(deep.directories.size() < 20);
+ Check(discover(root, false).files.size() == 3);
+ Check(discover(game / "base", false).files.size() == 1);
+ Check(discover(game / "base/exefs", false).files.size() == 1);
+ Check(discover(root / "missing", true).files.empty());
+
+ touch(root / "incomplete/exefs/main");
+ touch(root / "incomplete/romfs/actual-game.nsp");
+ Check(discover(root, true).files.size() == 6);
+
+ unsigned calls{};
+ const auto cancelled = GameListDiscovery::Discover(
+ root, true, supported, [&] { return ++calls > 3; });
+ Check(calls < 10 && cancelled.directories.size() <= 1);
+ std::cout << "Discovery: loose layouts, payload pruning, Unicode, shallow "
+ "scans and cancellation passed\n";
+}