diff --git a/src/citra_qt/CMakeLists.txt b/src/citra_qt/CMakeLists.txt index 4c20b510b..6de23df2e 100644 --- a/src/citra_qt/CMakeLists.txt +++ b/src/citra_qt/CMakeLists.txt @@ -135,6 +135,8 @@ add_library(citra_qt STATIC EXCLUDE_FROM_ALL dumping/options_dialog.ui game_list.cpp game_list.h + game_list_cache.cpp + game_list_cache.h game_list_p.h game_list_worker.cpp game_list_worker.h diff --git a/src/citra_qt/citra_qt.cpp b/src/citra_qt/citra_qt.cpp index 68fb8e8da..f62117a75 100644 --- a/src/citra_qt/citra_qt.cpp +++ b/src/citra_qt/citra_qt.cpp @@ -1227,6 +1227,7 @@ void GMainWindow::ConnectMenuEvents() { // Help connect_menu(ui->action_Open_Citra_Folder, &GMainWindow::OnOpenCitraFolder); + connect_menu(ui->action_Refresh_Game_List, &GMainWindow::OnRefreshGameList); connect_menu(ui->action_Open_Log_Folder, []() { QString path = QString::fromStdString(FileUtil::GetUserPath(FileUtil::UserPath::LogDir)); QDesktopServices::openUrl(QUrl::fromLocalFile(path)); @@ -2644,7 +2645,8 @@ void GMainWindow::OnCIAInstallFinished() { progress_bar->setValue(0); game_list->SetDirectoryWatcherEnabled(true); ui->action_Install_CIA->setEnabled(true); - game_list->PopulateAsync(UISettings::values.game_dirs); + // A new title was installed; invalidate the cache so the new entry appears. + game_list->ForceRescan(UISettings::values.game_dirs); } void GMainWindow::UninstallTitles( @@ -2782,8 +2784,8 @@ void GMainWindow::OnStopGame() { SetTurboEnabled(false); play_time_manager->Stop(); - // Update game list to show new play time - game_list->PopulateAsync(UISettings::values.game_dirs); + // Only the play-time column changed; a full rescan is unnecessary. + game_list->RefreshPlayTimes(); ShutdownGame(); graphics_api_button->setEnabled(true); @@ -3193,6 +3195,10 @@ void GMainWindow::OnOpenCitraFolder() { QString::fromStdString(FileUtil::GetUserPath(FileUtil::UserPath::UserDir)))); } +void GMainWindow::OnRefreshGameList() { + game_list->ForceRescan(UISettings::values.game_dirs); +} + void GMainWindow::OnToggleFilterBar() { game_list->SetFilterVisible(ui->action_Show_Filter_Bar->isChecked()); if (ui->action_Show_Filter_Bar->isChecked()) { diff --git a/src/citra_qt/citra_qt.h b/src/citra_qt/citra_qt.h index 3ad25e5ae..f5009c860 100644 --- a/src/citra_qt/citra_qt.h +++ b/src/citra_qt/citra_qt.h @@ -248,6 +248,7 @@ private slots: void OnGameListDumpRomFS(QString game_path, u64 program_id); void OnGameListOpenDirectory(const QString& directory); void OnGameListAddDirectory(); + void OnRefreshGameList(); void OnGameListShowList(bool show); void OnGameListOpenPerGameProperties(const QString& file); void OnConfigurePerGame(); diff --git a/src/citra_qt/game_list.cpp b/src/citra_qt/game_list.cpp index 5579e61ce..c0ce6c9bc 100644 --- a/src/citra_qt/game_list.cpp +++ b/src/citra_qt/game_list.cpp @@ -30,6 +30,7 @@ #include "citra_qt/citra_qt.h" #include "citra_qt/compatibility_list.h" #include "citra_qt/game_list.h" +#include "citra_qt/game_list_cache.h" #include "citra_qt/game_list_p.h" #include "citra_qt/game_list_worker.h" #include "citra_qt/uisettings.h" @@ -350,8 +351,8 @@ public: GameList::GameList(PlayTime::PlayTimeManager& play_time_manager_, GMainWindow* parent) : QWidget{parent}, play_time_manager{play_time_manager_} { watcher = new QFileSystemWatcher(this); - connect(watcher, &QFileSystemWatcher::directoryChanged, this, &GameList::RefreshGameDirectory, - Qt::UniqueConnection); + // The watcher is kept for CIA-install disable/re-enable plumbing but no longer drives + // automatic rescans. The game list is populated from cache or on explicit user request. this->main_window = parent; layout = new QVBoxLayout; @@ -412,14 +413,9 @@ void GameList::SetFilterVisible(bool visibility) { search_field->setVisible(visibility); } -void GameList::SetDirectoryWatcherEnabled(bool enabled) { - if (enabled) { - connect(watcher, &QFileSystemWatcher::directoryChanged, this, - &GameList::RefreshGameDirectory, Qt::UniqueConnection); - } else { - disconnect(watcher, &QFileSystemWatcher::directoryChanged, this, - &GameList::RefreshGameDirectory); - } +void GameList::SetDirectoryWatcherEnabled([[maybe_unused]] bool enabled) { + // Automatic filesystem-watcher rescans have been removed; the game list now uses an + // explicit cache. This function is intentionally a no-op. } void GameList::ClearFilter() { @@ -477,6 +473,17 @@ bool GameList::IsEmpty() const { } void GameList::DonePopulating(const QStringList& watch_list) { + // Save accumulated cache entries from this scan, then clear them. + if (current_scan_dirs && !pending_cache_entries.isEmpty()) { + SaveGameListCache(*current_scan_dirs, pending_cache_entries); + } + pending_cache_entries.clear(); + current_scan_dirs = nullptr; + + FinalizePopulate(); +} + +void GameList::FinalizePopulate() { emit ShowList(!IsEmpty()); item_model->invisibleRootItem()->appendRow(new GameListAddDir()); @@ -490,21 +497,6 @@ void GameList::DonePopulating(const QStringList& watch_list) { AddFavorite(id); } - // Clear out the old directories to watch for changes and add the new ones - auto watch_dirs = watcher->directories(); - if (!watch_dirs.isEmpty()) { - watcher->removePaths(watch_dirs); - } - // Workaround: Add the watch paths in chunks to allow the gui to refresh - // This prevents the UI from stalling when a large number of watch paths are added - // Also artificially caps the watcher to a certain number of directories - constexpr qsizetype LIMIT_WATCH_DIRECTORIES = 5000; - constexpr int SLICE_SIZE = 25; - const qsizetype len = std::min(watch_list.length(), LIMIT_WATCH_DIRECTORIES); - for (qsizetype i = 0; i < len; i += SLICE_SIZE) { - watcher->addPaths(watch_list.mid(i, i + SLICE_SIZE)); - QCoreApplication::processEvents(); - } tree_view->setEnabled(true); const int folderCount = tree_view->model()->rowCount(); int children_total = 0; @@ -518,7 +510,7 @@ void GameList::DonePopulating(const QStringList& watch_list) { item_model->sort(tree_view->header()->sortIndicatorSection(), tree_view->header()->sortIndicatorOrder()); - // resize all columns except for Name to fit their contents + // Resize all columns except Name to fit their contents. for (int i = 1; i < COLUMN_COUNT; i++) { tree_view->resizeColumnToContents(i); } @@ -1115,11 +1107,26 @@ void GameList::PopulateAsync(QVector& game_dirs) { emit ShouldCancelWorker(); + // Try to load from the on-disk cache first. This avoids re-scanning all game + // directories on every launch. + QVector cached_entries; + if (LoadGameListCache(game_dirs, cached_entries)) { + PopulateFromCache(game_dirs, cached_entries); + FinalizePopulate(); + return; + } + + // Cache miss (first run, dirs changed, or cache invalidated): + // run the full worker scan and collect entries for the new cache. + current_scan_dirs = &game_dirs; + GameListWorker* worker = new GameListWorker(game_dirs, compatibility_list, play_time_manager); connect(worker, &GameListWorker::EntryReady, this, &GameList::AddEntry, Qt::QueuedConnection); connect(worker, &GameListWorker::DirEntryReady, this, &GameList::AddDirEntry, Qt::QueuedConnection); + connect(worker, &GameListWorker::CacheEntryReady, this, &GameList::OnCacheEntryReady, + Qt::QueuedConnection); connect(worker, &GameListWorker::Finished, this, &GameList::DonePopulating, Qt::QueuedConnection); // Use DirectConnection here because worker->Cancel() is thread-safe and we want it to @@ -1131,6 +1138,87 @@ void GameList::PopulateAsync(QVector& game_dirs) { current_worker = std::move(worker); } +void GameList::OnCacheEntryReady(GameListCacheEntry cache_entry) { + // Assign the dir index by counting how many directory rows are in the model so far. + // DirEntryReady for a dir is always enqueued before any CacheEntryReady for its children, + // so rowCount()-1 reliably identifies the current directory. + cache_entry.game_dir_index = item_model->rowCount() - 1; + pending_cache_entries.append(std::move(cache_entry)); +} + +void GameList::PopulateFromCache(QVector& game_dirs, + const QVector& entries) { + // Build and add a GameListDir for every configured game directory. + QVector dir_items; + dir_items.reserve(game_dirs.size()); + for (UISettings::GameDir& game_dir : game_dirs) { + GameListDir* dir_item; + if (game_dir.path == QStringLiteral("INSTALLED")) { + dir_item = new GameListDir(game_dir, GameListItemType::InstalledDir); + } else if (game_dir.path == QStringLiteral("SYSTEM")) { + dir_item = new GameListDir(game_dir, GameListItemType::SystemDir); + } else { + dir_item = new GameListDir(game_dir); + } + AddDirEntry(dir_item); + dir_items.append(dir_item); + } + + // Reconstruct each game entry from the cached data. + for (const GameListCacheEntry& e : entries) { + if (e.game_dir_index < 0 || e.game_dir_index >= dir_items.size()) { + continue; + } + + // Wrap the SMDH bytes as a span so the item constructors can read them. + const std::span smdh_span( + reinterpret_cast(e.smdh.constData()), + static_cast(e.smdh.size())); + + QList items = { + new GameListItemPath(e.path, smdh_span, e.program_id, e.extdata_id, + e.media_type, e.is_encrypted, e.can_insert), + new GameListItemCompat(e.compatibility), + new GameListItemRegion(smdh_span), + new GameListItem(e.file_type), + new GameListItemSize(e.file_size), + new GameListItemPlayTime(play_time_manager.GetPlayTime(e.program_id)), + }; + + AddEntry(items, dir_items[e.game_dir_index]); + } +} + +void GameList::ForceRescan(QVector& game_dirs) { + InvalidateGameListCache(); + PopulateAsync(game_dirs); +} + +void GameList::RefreshPlayTimes() { + const int folder_count = item_model->rowCount(); + for (int i = 0; i < folder_count; ++i) { + auto* folder = item_model->item(i); + if (!folder) { + continue; + } + for (int j = 0; j < folder->rowCount(); ++j) { + auto* name_item = folder->child(j, 0); + if (!name_item || + static_cast(name_item->type()) != GameListItemType::Game) { + continue; + } + const u64 program_id = + name_item->data(GameListItemPath::ProgramIdRole).toULongLong(); + auto* time_item = folder->child(j, COLUMN_PLAY_TIME); + if (!time_item) { + continue; + } + time_item->setData(play_time_manager.GetPlayTime(program_id), + GameListItemPlayTime::PlayTimeRole); + } + } +} + void GameList::SaveInterfaceLayout() { UISettings::values.gamelist_header_state = tree_view->header()->saveState(); } diff --git a/src/citra_qt/game_list.h b/src/citra_qt/game_list.h index a1ee941db..690f5050c 100644 --- a/src/citra_qt/game_list.h +++ b/src/citra_qt/game_list.h @@ -10,6 +10,7 @@ #include #include #include "citra_qt/compatibility_list.h" +#include "citra_qt/game_list_cache.h" #include "common/common_types.h" #include "common/play_time_manager.h" #include "uisettings.h" @@ -77,8 +78,18 @@ public: bool IsEmpty() const; void LoadCompatibilityList(); + + /// Populates the list, loading from cache when it is valid and up to date. + /// Falls back to a full directory scan if the cache is absent or the directory list changed. void PopulateAsync(QVector& game_dirs); + /// Invalidates the cache and unconditionally runs a full directory scan. + void ForceRescan(QVector& game_dirs); + + /// Updates only the play-time column in the current list from the play-time manager. + /// Cheaper than a full rescan when only play times have changed (e.g. after a game stops). + void RefreshPlayTimes(); + void SaveInterfaceLayout(); void LoadInterfaceLayout(); @@ -123,9 +134,17 @@ private slots: private: void AddDirEntry(GameListDir* entry_items); void AddEntry(const QList& entry_items, GameListDir* parent); + void OnCacheEntryReady(GameListCacheEntry cache_entry); void ValidateEntry(const QModelIndex& item); void DonePopulating(const QStringList& watch_list); + /// Populates the list from a pre-loaded cache without spawning a worker thread. + void PopulateFromCache(QVector& game_dirs, + const QVector& entries); + + /// Common finalization shared by both the scan path and the cache-load path. + void FinalizePopulate(); + void PopupContextMenu(const QPoint& menu_location); void PopupHeaderContextMenu(const QPoint& menu_location); void AddGamePopup(QMenu& context_menu, const QString& path, const QString& name, u64 program_id, @@ -153,6 +172,12 @@ private: const PlayTime::PlayTimeManager& play_time_manager; + // Accumulates raw cache entries while a scan worker is running. + // Saved to disk in DonePopulating, then cleared. + QVector pending_cache_entries; + // The game_dirs in use for the current scan; needed when saving the cache. + QVector* current_scan_dirs = nullptr; + std::chrono::time_point time_last_refresh; }; diff --git a/src/citra_qt/game_list_cache.cpp b/src/citra_qt/game_list_cache.cpp new file mode 100644 index 000000000..1cb2b4565 --- /dev/null +++ b/src/citra_qt/game_list_cache.cpp @@ -0,0 +1,122 @@ +// Copyright Citra Emulator Project / Azahar Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#include +#include +#include +#include +#include "citra_qt/game_list_cache.h" +#include "common/file_util.h" +#include "common/logging/log.h" +#include "core/hle/service/fs/archive.h" + +namespace { + +constexpr int CACHE_VERSION = 1; + +QString GetCachePath() { + return QString::fromStdString(FileUtil::GetUserPath(FileUtil::UserPath::ConfigDir)) + + QStringLiteral("game_list_cache.json"); +} + +/// Returns a compact string fingerprint of @p game_dirs. +/// Any change to the list (paths, deep_scan flags, or order) yields a different string. +QString DirsSignature(const QVector& game_dirs) { + QStringList parts; + parts.reserve(game_dirs.size()); + for (const auto& dir : game_dirs) { + parts += dir.path + QLatin1Char('|') + (dir.deep_scan ? QLatin1Char('1') : QLatin1Char('0')); + } + return parts.join(QLatin1Char(';')); +} + +} // namespace + +bool LoadGameListCache(const QVector& game_dirs, + QVector& out_entries) { + const QString path = GetCachePath(); + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + QJsonParseError err; + const QJsonDocument doc = QJsonDocument::fromJson(file.readAll(), &err); + if (err.error != QJsonParseError::NoError || !doc.isObject()) { + LOG_WARNING(Frontend, "Game list cache parse error: {}", err.errorString().toStdString()); + return false; + } + + const QJsonObject root = doc.object(); + if (root[QStringLiteral("version")].toInt() != CACHE_VERSION) { + return false; + } + if (root[QStringLiteral("dirs_signature")].toString() != DirsSignature(game_dirs)) { + return false; + } + + const QJsonArray arr = root[QStringLiteral("entries")].toArray(); + out_entries.clear(); + out_entries.reserve(arr.size()); + for (const QJsonValue& val : arr) { + if (!val.isObject()) { + out_entries.clear(); + return false; + } + const QJsonObject obj = val.toObject(); + GameListCacheEntry e; + e.path = obj[QStringLiteral("path")].toString(); + e.program_id = obj[QStringLiteral("program_id")].toString().toULongLong(nullptr, 16); + e.extdata_id = obj[QStringLiteral("extdata_id")].toString().toULongLong(nullptr, 16); + e.media_type = static_cast( + obj[QStringLiteral("media_type")].toInt()); + e.is_encrypted = obj[QStringLiteral("encrypted")].toBool(); + e.can_insert = obj[QStringLiteral("can_insert")].toBool(); + e.smdh = QByteArray::fromBase64( + obj[QStringLiteral("smdh")].toString().toLatin1()); + e.compatibility = obj[QStringLiteral("compat")].toString(); + e.file_type = obj[QStringLiteral("file_type")].toString(); + // file_size is stored as a string to avoid JSON double precision limits. + e.file_size = obj[QStringLiteral("size")].toString().toULongLong(); + e.game_dir_index = obj[QStringLiteral("dir_idx")].toInt(); + out_entries.append(std::move(e)); + } + return true; +} + +void SaveGameListCache(const QVector& game_dirs, + const QVector& entries) { + QJsonArray arr; + for (const auto& e : entries) { + QJsonObject obj; + obj[QStringLiteral("path")] = e.path; + obj[QStringLiteral("program_id")] = QString::number(e.program_id, 16); + obj[QStringLiteral("extdata_id")] = QString::number(e.extdata_id, 16); + obj[QStringLiteral("media_type")] = static_cast(e.media_type); + obj[QStringLiteral("encrypted")] = e.is_encrypted; + obj[QStringLiteral("can_insert")] = e.can_insert; + obj[QStringLiteral("smdh")] = QString::fromLatin1(e.smdh.toBase64()); + obj[QStringLiteral("compat")] = e.compatibility; + obj[QStringLiteral("file_type")] = e.file_type; + obj[QStringLiteral("size")] = QString::number(e.file_size); + obj[QStringLiteral("dir_idx")] = e.game_dir_index; + arr.append(obj); + } + + QJsonObject root; + root[QStringLiteral("version")] = CACHE_VERSION; + root[QStringLiteral("dirs_signature")] = DirsSignature(game_dirs); + root[QStringLiteral("entries")] = arr; + + QFile file(GetCachePath()); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + LOG_ERROR(Frontend, "Failed to write game list cache: {}", GetCachePath().toStdString()); + return; + } + file.write(QJsonDocument(root).toJson(QJsonDocument::Compact)); +} + +void InvalidateGameListCache() { + QFile::remove(GetCachePath()); +} diff --git a/src/citra_qt/game_list_cache.h b/src/citra_qt/game_list_cache.h new file mode 100644 index 000000000..689234a4e --- /dev/null +++ b/src/citra_qt/game_list_cache.h @@ -0,0 +1,50 @@ +// Copyright Citra Emulator Project / Azahar Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +#pragma once + +#include +#include +#include +#include "citra_qt/uisettings.h" +#include "common/common_types.h" + +namespace Service::FS { +enum class MediaType : u32; +} + +/// Cached metadata for a single game entry, sufficient to rebuild all model columns. +struct GameListCacheEntry { + QString path; + QByteArray smdh; ///< Raw SMDH bytes; empty for games that have no icon data. + u64 program_id = 0; + u64 extdata_id = 0; + Service::FS::MediaType media_type{}; + bool is_encrypted = false; + bool can_insert = false; + QString compatibility; ///< Tier string: "0"–"5" or "99" for not tested. + QString file_type; ///< Human-readable file-type string from the loader. + quint64 file_size = 0; + int game_dir_index = 0; ///< Index into UISettings::values.game_dirs for the parent dir. +}; + +/** + * Attempts to load the game list cache and validates it against the current game_dirs. + * + * Returns true and populates @p out_entries when the cache exists, parses without error, + * and its stored directory list matches the caller's game_dirs exactly. + * Returns false in all other cases (missing file, parse error, stale dirs). + */ +bool LoadGameListCache(const QVector& game_dirs, + QVector& out_entries); + +/** + * Serialises @p entries to the game list cache file, storing @p game_dirs alongside them + * so that future loads can detect when the directory list has changed. + */ +void SaveGameListCache(const QVector& game_dirs, + const QVector& entries); + +/// Removes the cache file so the next PopulateAsync performs a full scan. +void InvalidateGameListCache(); diff --git a/src/citra_qt/game_list_worker.cpp b/src/citra_qt/game_list_worker.cpp index cc65b5082..54bffcb21 100644 --- a/src/citra_qt/game_list_worker.cpp +++ b/src/citra_qt/game_list_worker.cpp @@ -109,21 +109,41 @@ void GameListWorker::AddFstEntriesToGameList(const std::string& dir_path, unsign if (it != compatibility_list.end()) compatibility = it->second.first; + const bool is_encrypted = res == Loader::ResultStatus::ErrorEncrypted; + const bool can_insert = loader->GetFileType() == Loader::FileType::CCI; + const QString file_type_str = QString::fromStdString( + Loader::GetFileTypeString(loader->GetFileType(), loader->IsFileCompressed())); + const quint64 file_size = FileUtil::GetSize(physical_name); + emit EntryReady( { new GameListItemPath(QString::fromStdString(physical_name), smdh, program_id, - extdata_id, media_type, - res == Loader::ResultStatus::ErrorEncrypted, - loader->GetFileType() == Loader::FileType::CCI), + extdata_id, media_type, is_encrypted, can_insert), new GameListItemCompat(compatibility), new GameListItemRegion(smdh), - new GameListItem(QString::fromStdString(Loader::GetFileTypeString( - loader->GetFileType(), loader->IsFileCompressed()))), - new GameListItemSize(FileUtil::GetSize(physical_name)), + new GameListItem(file_type_str), + new GameListItemSize(file_size), new GameListItemPlayTime(play_time_manager.GetPlayTime(program_id)), }, parent_dir); + { + GameListCacheEntry cache_entry; + cache_entry.path = QString::fromStdString(physical_name); + cache_entry.smdh = QByteArray(reinterpret_cast(smdh.data()), + static_cast(smdh.size())); + cache_entry.program_id = program_id; + cache_entry.extdata_id = extdata_id; + cache_entry.media_type = media_type; + cache_entry.is_encrypted = is_encrypted; + cache_entry.can_insert = can_insert; + cache_entry.compatibility = compatibility; + cache_entry.file_type = file_type_str; + cache_entry.file_size = file_size; + // game_dir_index is filled in by GameList when it receives this signal. + emit CacheEntryReady(std::move(cache_entry)); + } + } else if (is_dir && recursion > 0) { watch_list.append(QString::fromStdString(physical_name)); AddFstEntriesToGameList(physical_name, recursion - 1, parent_dir, media_type); diff --git a/src/citra_qt/game_list_worker.h b/src/citra_qt/game_list_worker.h index f149a7924..a0cc0c8e9 100644 --- a/src/citra_qt/game_list_worker.h +++ b/src/citra_qt/game_list_worker.h @@ -17,6 +17,7 @@ #include #include #include "citra_qt/compatibility_list.h" +#include "citra_qt/game_list_cache.h" #include "common/common_types.h" #include "common/play_time_manager.h" @@ -54,6 +55,10 @@ signals: void DirEntryReady(GameListDir* entry_items); void EntryReady(QList entry_items, GameListDir* parent_dir); + /// Emitted in parallel with EntryReady, carrying the raw data needed to rebuild this entry + /// from the cache without touching the ROM file again. + void CacheEntryReady(GameListCacheEntry cache_entry); + /** * After the worker has traversed the game directory looking for entries, this signal is emitted * with a list of folders that should be watched for changes as well. diff --git a/src/citra_qt/main.ui b/src/citra_qt/main.ui index 9ed0fafbc..8b53d8093 100644 --- a/src/citra_qt/main.ui +++ b/src/citra_qt/main.ui @@ -88,6 +88,7 @@ + @@ -747,6 +748,14 @@ Open Azahar Folder + + + Refresh Game List + + + F5 + + false