citra_qt: cache game list to disk, rescan only on explicit request

Previously the game list rescanned game directories on every launch and
after any event that might have changed game files. This commit replaces
that with a persistent JSON cache so subsequent launches populate the
list instantly from disk.

Key changes:
- game_list_cache.h/cpp: new JSON cache at ConfigDir/game_list_cache.json.
  Keyed by a DirsSignature fingerprint (path + deep_scan for every dir);
  invalidated automatically when the directory list changes.
- GameListWorker: emits CacheEntryReady in parallel with EntryReady,
  carrying raw metadata so entries can be rebuilt without touching ROMs.
- GameList::PopulateAsync: checks cache first. On a hit it rebuilds the
  model synchronously from cached data (PopulateFromCache) with no worker
  thread. On a miss it runs the full scan and saves the new cache in
  DonePopulating.
- GameList::ForceRescan: invalidates the cache then calls PopulateAsync.
- GameList::RefreshPlayTimes: updates only the play-time column in-place;
  called after a game session ends instead of a full rescan.
- QFileSystemWatcher is kept for CIA-install plumbing but is no longer
  connected to any automatic rescan.
- File menu: new "Refresh Game List" action (F5) triggers ForceRescan.
- OnCIAInstallFinished: uses ForceRescan (new title needs a fresh scan).
- OnStopGame: uses RefreshPlayTimes (only play time changed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Masamune3210 2026-05-16 11:38:29 -05:00
parent 368681bb62
commit db11399785
10 changed files with 363 additions and 35 deletions

View file

@ -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

View file

@ -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()) {

View file

@ -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();

View file

@ -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<UISettings::GameDir>& game_dirs) {
emit ShouldCancelWorker();
// Try to load from the on-disk cache first. This avoids re-scanning all game
// directories on every launch.
QVector<GameListCacheEntry> 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<UISettings::GameDir>& 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<UISettings::GameDir>& game_dirs,
const QVector<GameListCacheEntry>& entries) {
// Build and add a GameListDir for every configured game directory.
QVector<GameListDir*> 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<const u8> smdh_span(
reinterpret_cast<const u8*>(e.smdh.constData()),
static_cast<std::size_t>(e.smdh.size()));
QList<QStandardItem*> 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<UISettings::GameDir>& 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<GameListItemType>(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();
}

View file

@ -10,6 +10,7 @@
#include <QVector>
#include <QWidget>
#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<UISettings::GameDir>& game_dirs);
/// Invalidates the cache and unconditionally runs a full directory scan.
void ForceRescan(QVector<UISettings::GameDir>& 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<QStandardItem*>& 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<UISettings::GameDir>& game_dirs,
const QVector<GameListCacheEntry>& 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<GameListCacheEntry> pending_cache_entries;
// The game_dirs in use for the current scan; needed when saving the cache.
QVector<UISettings::GameDir>* current_scan_dirs = nullptr;
std::chrono::time_point<std::chrono::steady_clock> time_last_refresh;
};

View file

@ -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 <QFile>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#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<UISettings::GameDir>& 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<UISettings::GameDir>& game_dirs,
QVector<GameListCacheEntry>& 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<Service::FS::MediaType>(
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<UISettings::GameDir>& game_dirs,
const QVector<GameListCacheEntry>& 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<int>(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());
}

View file

@ -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 <QByteArray>
#include <QString>
#include <QVector>
#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<UISettings::GameDir>& game_dirs,
QVector<GameListCacheEntry>& 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<UISettings::GameDir>& game_dirs,
const QVector<GameListCacheEntry>& entries);
/// Removes the cache file so the next PopulateAsync performs a full scan.
void InvalidateGameListCache();

View file

@ -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<const char*>(smdh.data()),
static_cast<qsizetype>(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);

View file

@ -17,6 +17,7 @@
#include <QString>
#include <QVector>
#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<QStandardItem*> 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.

View file

@ -88,6 +88,7 @@
<addaction name="menu_Amiibo"/>
<addaction name="separator"/>
<addaction name="action_Open_Citra_Folder"/>
<addaction name="action_Refresh_Game_List"/>
<addaction name="separator"/>
<addaction name="action_Exit"/>
</widget>
@ -747,6 +748,14 @@
<string>Open Azahar Folder</string>
</property>
</action>
<action name="action_Refresh_Game_List">
<property name="text">
<string>Refresh Game List</string>
</property>
<property name="shortcut">
<string>F5</string>
</property>
</action>
<action name="action_Configure_Current_Game">
<property name="enabled">
<bool>false</bool>