From f8813998e7ddc1b21b42dcf51c39943ceb83b987 Mon Sep 17 00:00:00 2001 From: Takoroka <137927095+Takoroka@users.noreply.github.com> Date: Sun, 1 Nov 2026 04:27:49 -0500 Subject: loader: build a loose RomFS once and only when it is read Every loader of an extracted title built the whole RomFS from its romfs/ directory in its constructor, though most are made only for metadata (the game list, title lookups), and each build keeps an object per file: title 1's 206k files left four copies alive, ~0.6 GB of the heap. The RomFS is now built on first read, and loaders of the same directory share one build while any of them holds it. (cherry picked from commit be47212cac130e6a895423644751c0525dc6f169) --- src/core/loader/deconstructed_rom_directory.cpp | 34 +++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) (limited to 'src/core/loader/deconstructed_rom_directory.cpp') diff --git a/src/core/loader/deconstructed_rom_directory.cpp b/src/core/loader/deconstructed_rom_directory.cpp index cda1645548..04b05d188f 100644 --- a/src/core/loader/deconstructed_rom_directory.cpp +++ b/src/core/loader/deconstructed_rom_directory.cpp @@ -7,6 +7,9 @@ #include #include #include +#include +#include +#include #include #include "common/logging.h" #include "common/random.h" @@ -90,8 +93,19 @@ FileSys::VirtualFile FindLooseRomFS(const FileSys::VirtualDir& exefs_dir) { } if (const auto parent = exefs_dir->GetParentDirectory()) { if (const auto romfs_dir = parent->GetSubdirectory("romfs")) { + // Building it walks every file and keeps an object for each (title 1: 206k files, ~150 + // MiB), so loaders of the same directory share one build while any of them holds it. + static std::mutex cache_mutex; + static std::map> cache; + std::scoped_lock lk{cache_mutex}; + auto& cached = cache[romfs_dir->GetFullPath()]; + if (auto built = cached.lock()) { + return built; + } LOG_INFO(Loader, "Using RomFS directory next to the ExeFS"); - return FileSys::CreateRomFS(romfs_dir); + auto built = FileSys::CreateRomFS(romfs_dir); + cached = built; + return built; } } return nullptr; @@ -233,7 +247,7 @@ AppLoader_DeconstructedRomDirectory::AppLoader_DeconstructedRomDirectory(FileSys name = control->GetApplicationName(); } - romfs = FindLooseRomFS(file_dir); + romfs_exefs_dir = file_dir; if (const auto title_dir = file_dir->GetParentDirectory()) { romfs_version = RomFSProductVersion(title_dir->GetSubdirectory("romfs")); } @@ -245,7 +259,17 @@ AppLoader_DeconstructedRomDirectory::AppLoader_DeconstructedRomDirectory( , dir(std::move(directory)) , override_update(override_update_) { - romfs = FindLooseRomFS(dir); + romfs_exefs_dir = dir; +} + +// Loaders made only for metadata (the game list, title lookups) never need the RomFS, and several +// of them were alive with a whole copy each. +const FileSys::VirtualFile& AppLoader_DeconstructedRomDirectory::LooseRomFS() { + if (!romfs_resolved) { + romfs_resolved = true; + romfs = FindLooseRomFS(romfs_exefs_dir); + } + return romfs; } FileType AppLoader_DeconstructedRomDirectory::IdentifyType(const FileSys::VirtualFile& dir_file) { @@ -304,7 +328,7 @@ AppLoader_DeconstructedRomDirectory::LoadResult AppLoader_DeconstructedRomDirect // Loose-ExeFS loads bypass the NCA/NSP loaders, which are the ones that normally apply // LayeredFS to the RomFS; apply it here so mods in user/load// work too. - if (romfs != nullptr) { + if (LooseRomFS() != nullptr) { const FileSys::PatchManager patch_manager(metadata.GetTitleID(), system.GetFileSystemController(), system.GetContentProvider()); @@ -414,7 +438,7 @@ AppLoader_DeconstructedRomDirectory::LoadResult AppLoader_DeconstructedRomDirect } ResultStatus AppLoader_DeconstructedRomDirectory::ReadRomFS(FileSys::VirtualFile& out_dir) { - if (romfs == nullptr) { + if (LooseRomFS() == nullptr) { return ResultStatus::ErrorNoRomFS; } -- cgit v1.2.3 From 8e777214b02be0f254abdcc1adbc195e765e36a6 Mon Sep 17 00:00:00 2001 From: snowstormxd <214507868+snowstormxd@users.noreply.github.com> Date: Sun, 1 Nov 2026 18:37:51 +0000 Subject: 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) --- src/core/loader/deconstructed_rom_directory.cpp | 41 ++++++- src/core/loader/deconstructed_rom_directory.h | 3 + src/qt_common/game_list/discovery.h | 113 ++++++++++++++++++ src/qt_common/game_list/model.cpp | 21 +++- src/qt_common/game_list/model.h | 2 + src/qt_common/game_list/worker.cpp | 149 ++++++++++-------------- src/qt_common/game_list/worker.h | 4 +- src/yuzu/game/game_list.cpp | 4 + src/yuzu/game/game_list.h | 1 + tools/integration/game_discovery_test.cpp | 75 ++++++++++++ 10 files changed, 315 insertions(+), 98 deletions(-) create mode 100644 src/qt_common/game_list/discovery.h create mode 100644 tools/integration/game_discovery_test.cpp (limited to 'src/core/loader/deconstructed_rom_directory.cpp') 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 #include +#include #include +#include #include #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 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::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 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 +#include +#include +#include +#include +#include + +namespace GameListDiscovery { + +struct Result { + std::vector files; + std::vector 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 +Result Discover(const std::filesystem::path& root, bool deep, Supported supported, + Stopped stopped) { + namespace fs = std::filesystem; + Result result; + std::unordered_set files; + std::unordered_set 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> 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(std::tolower(static_cast(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 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& game_dirs) { + if (!scan_enabled || system.IsPoweredOn()) { + return; + } emit PopulatingStarted(); StopWorker(); @@ -62,11 +74,14 @@ void GameListModel::PopulateAsync(QVector& 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& 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 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 +#include #include #include #include @@ -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 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") { @@ -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(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& 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(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: /exefs/main, /base/exefs/main and their updates in - // /updates//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"; +} -- cgit v1.2.3 From 39e1babb1c1a04b8d5fe7c0023654ad458c4ddd9 Mon Sep 17 00:00:00 2001 From: kraibse <33869366+kraibse@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:14:20 +0100 Subject: refactor: remove redundant EnsureRomFS call in AppLoader_DeconstructedRomDirectory --- src/core/loader/deconstructed_rom_directory.cpp | 2 -- 1 file changed, 2 deletions(-) (limited to 'src/core/loader/deconstructed_rom_directory.cpp') diff --git a/src/core/loader/deconstructed_rom_directory.cpp b/src/core/loader/deconstructed_rom_directory.cpp index 463a8c8930..60cf87db14 100644 --- a/src/core/loader/deconstructed_rom_directory.cpp +++ b/src/core/loader/deconstructed_rom_directory.cpp @@ -314,8 +314,6 @@ AppLoader_DeconstructedRomDirectory::LoadResult AppLoader_DeconstructedRomDirect return {result, {}}; } - EnsureRomFS(); - if (override_update) { const FileSys::PatchManager patch_manager( metadata.GetTitleID(), system.GetFileSystemController(), system.GetContentProvider()); -- cgit v1.2.3 From 8185d45d5d9d16bca860542f98d8a026e286dbf6 Mon Sep 17 00:00:00 2001 From: kraibse <33869366+kraibse@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:23:47 +0100 Subject: loader: read the display version from GameDataList.Product.NNN too Donkey Kong Bananza names its resource size table without a version, so no version was found for a loose dump. Its GameData/GameDataList.Product .320.* files carry it (3.2.0), so read them as well and use the newest tag from either source. Assisted-by: Claude Sonnet 5.5 (wrote the GameDataList version lookup and its test) --- src/core/loader/deconstructed_rom_directory.cpp | 47 ++++++++++++++++--------- src/tests/core/nup_writer.cpp | 22 ++++++++++++ 2 files changed, 52 insertions(+), 17 deletions(-) (limited to 'src/core/loader/deconstructed_rom_directory.cpp') diff --git a/src/core/loader/deconstructed_rom_directory.cpp b/src/core/loader/deconstructed_rom_directory.cpp index 60cf87db14..4b1168a59b 100644 --- a/src/core/loader/deconstructed_rom_directory.cpp +++ b/src/core/loader/deconstructed_rom_directory.cpp @@ -117,27 +117,19 @@ FileSys::VirtualFile FindLooseRomFS(const FileSys::VirtualDir& exefs_dir) { return nullptr; } -// The display version of a dump without control data, from its RomFS: Nintendo EPD titles name -// their resource size tables after it (System/Resource/ResourceSizeTable.Product.180.* in Mario -// Kart World 1.8.0). A hotfix can keep its minor version's tables (Product.110 in a 1.1.1). -std::string RomFSProductVersion(const FileSys::VirtualDir& romfs_dir) { - if (romfs_dir == nullptr) { - return {}; - } - const auto system_dir = romfs_dir->GetSubdirectory("System"); - const auto resource_dir = system_dir != nullptr ? system_dir->GetSubdirectory("Resource") : nullptr; - if (resource_dir == nullptr) { - return {}; - } - constexpr std::string_view Prefix = "ResourceSizeTable.Product."; +// The newest three-digit tag in a directory's file names of the form <prefix>NNN.<anything>. +std::string NewestProductTag(const FileSys::VirtualDir& dir, std::string_view prefix) { std::string best; - for (const auto& file : resource_dir->GetFiles()) { + if (dir == nullptr) { + return best; + } + for (const auto& file : dir->GetFiles()) { const std::string file_name = file->GetName(); - if (!file_name.starts_with(Prefix) || file_name.size() < Prefix.size() + 4) { + if (!file_name.starts_with(prefix) || file_name.size() < prefix.size() + 4) { continue; } - const std::string_view digits = std::string_view{file_name}.substr(Prefix.size(), 3); - if (file_name[Prefix.size() + 3] != '.' || + const std::string_view digits = std::string_view{file_name}.substr(prefix.size(), 3); + if (file_name[prefix.size() + 3] != '.' || !std::all_of(digits.begin(), digits.end(), [](char c) { return c >= '0' && c <= '9'; })) { continue; } @@ -145,6 +137,27 @@ std::string RomFSProductVersion(const FileSys::VirtualDir& romfs_dir) { best = version; } } + return best; +} + +// The display version of a dump without control data, from its RomFS: Nintendo EPD titles name +// their resource size tables after it (System/Resource/ResourceSizeTable.Product.180.* in Mario +// Kart World 1.8.0), and some name their game data list instead (GameData/GameDataList.Product.320.* +// in Donkey Kong Bananza 3.2.0). A hotfix can keep its minor version's tables (Product.110 in a +// 1.1.1). +std::string RomFSProductVersion(const FileSys::VirtualDir& romfs_dir) { + if (romfs_dir == nullptr) { + return {}; + } + const auto system_dir = romfs_dir->GetSubdirectory("System"); + std::string best = NewestProductTag( + system_dir != nullptr ? system_dir->GetSubdirectory("Resource") : nullptr, + "ResourceSizeTable.Product."); + if (const auto game_data = NewestProductTag(romfs_dir->GetSubdirectory("GameData"), + "GameDataList.Product."); + game_data > best) { + best = game_data; + } if (best.empty()) { return {}; } diff --git a/src/tests/core/nup_writer.cpp b/src/tests/core/nup_writer.cpp index bb0148139b..c0230d8694 100644 --- a/src/tests/core/nup_writer.cpp +++ b/src/tests/core/nup_writer.cpp @@ -421,3 +421,25 @@ TEST_CASE("NUP writes metadata overrides to the base and the newest update", "[n REQUIRE(packages[2].display_version == "9.8.7"); REQUIRE(packages[1].display_version == "1.1.0"); } + +TEST_CASE("NUP probes the version from the game data list tag", "[nup]") { + TempDir t; + const auto root = t.path / "Game"; + Put(root / "base/exefs/main", "main"); + Put(root / "base/exefs/main.npdm", Npdm(TitleId)); + Put(root / "base/romfs/GameData/GameDataList.Product.100.byml.zs", "x"); + Put(root / "base/romfs/System/Resource/ResourceSizeTable.Product.rsizetable.zs", "x"); + Put(root / "updates/458752/exefs/main", "main"); + Put(root / "updates/458752/exefs/main.npdm", Npdm(TitleId)); + Put(root / "updates/458752/romfs/GameData/GameDataList.Product.320.byml.zs", "x"); + Put(root / "updates/458752/romfs/GameData/GameDataList.Product.320.revision.txt", "1"); + + auto probed = FileSys::Nup::ProbeMetadata(root); + REQUIRE(probed.display_version == "3.2.0"); + REQUIRE(probed.version_source == "update 458752"); + + fs::remove_all(root / "updates"); + probed = FileSys::Nup::ProbeMetadata(root); + REQUIRE(probed.display_version == "1.0.0"); + REQUIRE(probed.version_source == "base"); +} -- cgit v1.2.3