mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 01:23:24 -08:00
* [CardDB] Reload when the first run enables all sets checkUnknownSets() enabled every set when none was enabled, but the database had already been parsed under that empty enablement, so the parsers had stripped every printing. It only notified the models and never reloaded, leaving the deck editor's card database display empty until the next launch. Route the all-sets-enabled case through the same reload the new-sets path uses, and skip caching a snapshot whose sets are all disabled. * [CardDB] Own checkUnknownSets after startup and fix the cache skip The loader's initialLoadComplete guard skipped checkUnknownSets() on the first successful load of the session, so a download that landed after startupConfigCheck() and the onboarding wizard had already run (e.g. skipping the wizard download and fetching later from the menu) left every set disabled and the deck editor blank until the next launch. The guard also let the check run on the worker before the queued snapshot swap, and alongside reloadCardDatabasesAndNotify()'s own check, double-emitting cardDatabaseNewSetsFound on manual reloads. Move the check to the callers that load after startup: the post-update, custom-import, and spoiler paths run it via a QFutureWatcher once the finished snapshot has been swapped in, and the loader's synchronous path-change reload runs it directly. Judge the cache skip by the parsed sets' own enablement, what the parsers actually saw, instead of getEnabledSetNames(), which reports empty for the noop controller even though it enables every set. Wait for the first-run reparse before opening Manage Sets so the dialog cannot bind to the pre-reload set instances. * [CardDB] Queue post-reload check and unblock first-run Manage Sets reloadCardDatabasesAndNotify() no longer emits cardDatabaseEnabledSetsChanged after the swap's Reset already rebuilt the models (second 36k rebuild froze startup), and its follow-up checkUnknownSets() is queued to the GUI thread so it sees swapped-in data. cardDatabaseAllNewSetsEnabled() chains Welcome/Manage Sets off a QFutureWatcher instead of waitForFinished() blocking the event loop during the reparse. * [CardDB] Batch the first-run enable-all into a single settings flush checkUnknownSets() enabled every set with ~3 QSettings rewrite cycles per set (sort key, known, enabled), and each setValue re-opens and re-syncs the whole cardDatabase.ini. With a full database (874 sets) that froze the GUI thread for ~9s after the database download (LagMonitor: event loop stalled for 9129 ms, measured offscreen with a real 59MB cards.xml), reading as a total application hang that recovers on its own. Persist the enable-all through the controller's batched saveSets() in a single flush instead, mirroring what SetsModel::save() already does: CardSetList::enableAllBatched() assigns fresh sort keys and marks every set known+enabled in memory, then writes all records at once. SetSaveData gains the isKnown flag so the batch covers all three options, and SetsModel::save() passes the sets' known state through (unchanged behavior there). Also drop the cardDatabaseEnabledSetsChanged emission from the all-sets-enabled branch: the reload chained off cardDatabaseAllNewSetsEnabled rebuilds the models from the re-parsed snapshot via cardDatabaseReset, so emitting both rebuilt the 36k-card models twice on the GUI thread. Adds a cache-invalidation test driving the real checkUnknownSets() first-run path. * [CardDB] Batch the new-sets-found enable-all the same way The first-run batching fixed the 874-set case, but the new-sets-found dialog path (enableAllUnknownSets/markAllSetsAsKnown) still wrote per set and per option, each setValue re-opening and re-syncing the whole cardDatabase.ini. Users with hundreds of custom sets hit the same multi-second GUI stall when answering the dialog. Persist both through the controller's batched saveSets() in a single flush, writing only changed sets (so the common no-op check skips the flush entirely). Removes the now-unused write-through CardSetList methods (enableAll, enableAllUnknown, markAllAsKnown, guessSortKeys) so the batched flush is the only way to persist bulk set changes. Extends the test controller mock with per-write/batched-flush counters and adds tests asserting both dialog paths persist with exactly one flush and zero individual writes. --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
291 lines
11 KiB
C++
291 lines
11 KiB
C++
#include "card_database_loader.h"
|
|
|
|
#include "card_database.h"
|
|
#include "card_database_cache.h"
|
|
#include "libcockatrice/card/card_info.h"
|
|
#include "libcockatrice/card/database/card_database_data.h"
|
|
#include "libcockatrice/card/set/card_set.h"
|
|
#include "libcockatrice/interfaces/interface_card_database_path_provider.h"
|
|
#include "libcockatrice/interfaces/interface_card_set_priority_controller.h"
|
|
#include "parser/card_database_parser.h"
|
|
#include "parser/cockatrice_xml_3.h"
|
|
#include "parser/cockatrice_xml_4.h"
|
|
|
|
#include <QByteArray>
|
|
#include <QCoreApplication>
|
|
#include <QCryptographicHash>
|
|
#include <QDateTime>
|
|
#include <QDebug>
|
|
#include <QDir>
|
|
#include <QDirIterator>
|
|
#include <QFile>
|
|
#include <QFileInfo>
|
|
#include <QIODevice>
|
|
#include <QMessageLogger>
|
|
#include <QMutexLocker>
|
|
#include <QSharedPointer>
|
|
#include <QTime>
|
|
#include <qalgorithms.h>
|
|
#include <utility>
|
|
|
|
class ICardPreferenceProvider;
|
|
|
|
CardDatabaseLoader::CardDatabaseLoader(QObject *parent,
|
|
CardDatabase *db,
|
|
ICardDatabasePathProvider *_pathProvider,
|
|
ICardSetPriorityController *_priorityController)
|
|
: QObject(parent), database(db), pathProvider(_pathProvider), priorityController(_priorityController)
|
|
{
|
|
// instantiate available parsers here
|
|
availableParsers << new CockatriceXml4Parser(_priorityController);
|
|
availableParsers << new CockatriceXml3Parser(_priorityController);
|
|
|
|
// The load path parses into a snapshot and never emits per-card signals;
|
|
// the finished snapshot is swapped into the live database on the GUI thread.
|
|
|
|
// when SettingsCache's path changes, trigger reloads
|
|
connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, [this] {
|
|
loadCardDatabases();
|
|
// This reload runs synchronously on the emitting (GUI) thread, so the
|
|
// finished snapshot is already swapped in when it returns: enable any
|
|
// sets the newly pointed-at database introduced.
|
|
database->checkUnknownSets();
|
|
});
|
|
}
|
|
|
|
CardDatabaseLoader::~CardDatabaseLoader()
|
|
{
|
|
qDeleteAll(availableParsers);
|
|
availableParsers.clear();
|
|
}
|
|
|
|
LoadStatus CardDatabaseLoader::loadFromFile(const QString &fileName, CardDatabaseData &data)
|
|
{
|
|
QFile file(fileName);
|
|
if (!file.open(QIODevice::ReadOnly)) {
|
|
return FileError;
|
|
}
|
|
|
|
for (auto parser : availableParsers) {
|
|
file.reset();
|
|
if (parser->getCanParseFile(fileName, file)) {
|
|
file.reset();
|
|
parser->parseFileInto(file, data);
|
|
return Ok;
|
|
}
|
|
}
|
|
|
|
return Invalid;
|
|
}
|
|
|
|
LoadStatus CardDatabaseLoader::loadCardDatabase(const QString &path, CardDatabaseData &data)
|
|
{
|
|
auto startTime = QTime::currentTime();
|
|
LoadStatus tempLoadStatus = NotLoaded;
|
|
if (!path.isEmpty()) {
|
|
QMutexLocker locker(loadFromFileMutex);
|
|
tempLoadStatus = loadFromFile(path, data);
|
|
}
|
|
|
|
int msecs = startTime.msecsTo(QTime::currentTime());
|
|
qCInfo(CardDatabaseLoadingLog) << "Loaded card database: Path =" << path << "Status =" << tempLoadStatus
|
|
<< "Cards =" << data.cards.size() << "Sets =" << data.sets.size()
|
|
<< QString("%1ms").arg(msecs);
|
|
|
|
return tempLoadStatus;
|
|
}
|
|
|
|
LoadStatus CardDatabaseLoader::loadCardDatabases(bool ignoreCache)
|
|
{
|
|
return doLoadCardDatabases(ignoreCache);
|
|
}
|
|
|
|
LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache)
|
|
{
|
|
QMutexLocker locker(reloadDatabaseMutex);
|
|
|
|
if (!database) {
|
|
qCWarning(CardDatabaseLoadingLog) << "Loader has no database pointer";
|
|
emit loadingFailed();
|
|
return FileError;
|
|
}
|
|
emit loadingStarted();
|
|
qCInfo(CardDatabaseLoadingLog) << "Card Database Loading Started";
|
|
|
|
ICardDatabaseParser::clearSetlist();
|
|
|
|
CardDatabaseData data;
|
|
LoadStatus loadStatus = NotLoaded;
|
|
|
|
// Try the binary cache first: a cache hit avoids re-parsing the (large) XML.
|
|
// An explicit reload skips the lookup so the files on disk are always
|
|
// re-read, even when their size/mtime hash still matches the cache.
|
|
const QStringList customPaths = collectCustomDatabasePaths();
|
|
const QByteArray sourceHash = computeSourceHash(customPaths);
|
|
if (ignoreCache) {
|
|
qCInfo(CardDatabaseLoadingLog) << "Ignoring binary cache, re-parsing source files";
|
|
}
|
|
if (!ignoreCache && loadFromCache(data, sourceHash)) {
|
|
qCInfo(CardDatabaseLoadingLog) << "Loaded card database from binary cache";
|
|
loadStatus = Ok;
|
|
} else {
|
|
loadStatus = loadCardDatabase(pathProvider->getCardDatabasePath(), data); // main card database
|
|
if (loadStatus == Ok) {
|
|
loadCardDatabase(pathProvider->getTokenDatabasePath(), data); // tokens database
|
|
loadCardDatabase(pathProvider->getSpoilerCardDatabasePath(), data); // spoilers database
|
|
|
|
// find all custom card databases, recursively & following symlinks
|
|
// then load them alphabetically
|
|
for (int i = 0; i < customPaths.size(); ++i) {
|
|
const auto &p = customPaths.at(i);
|
|
qCInfo(CardDatabaseLoadingLog) << "Loading Custom Set" << i << "(" << p << ")";
|
|
loadCardDatabase(p, data);
|
|
}
|
|
|
|
// A snapshot parsed while no set is enabled has had every printing
|
|
// stripped by the parsers, and the cache reader re-derives enablement
|
|
// from the priority controller, so such an entry can never be reused
|
|
// meaningfully. Skip writing it so a first-run (blank config) load
|
|
// cannot poison the cache with an empty snapshot. The check mirrors
|
|
// what the parsers saw: any set still reporting enabled means the
|
|
// snapshot is complete for the current enablement.
|
|
bool anySetEnabled = false;
|
|
for (const CardSetPtr &set : data.sets) {
|
|
if (set && set->getEnabled()) {
|
|
anySetEnabled = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!data.sets.isEmpty() && !anySetEnabled) {
|
|
qCInfo(CardDatabaseLoadingLog) << "Skipping binary cache: no set is enabled yet, snapshot was stripped";
|
|
} else if (!saveToCache(data, sourceHash)) {
|
|
qCWarning(CardDatabaseLoadingLog) << "Failed to write binary cache to" << cachePath();
|
|
}
|
|
}
|
|
}
|
|
|
|
// AFTER all the cards have been loaded: resolve the reverse-related tags
|
|
// against the fully-built snapshot.
|
|
if (loadStatus == Ok) {
|
|
database->refreshCachedReverseRelatedCards(data.cards);
|
|
|
|
qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Success";
|
|
// NOTE: checkUnknownSets() is intentionally NOT called here. During the
|
|
// front-loaded parse in main() MainWindow does not exist yet, so
|
|
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would be
|
|
// emitted with no receivers, and enabling the sets up-front would
|
|
// suppress the first-run experience MainWindow::startupConfigCheck()
|
|
// and the onboarding wizard are designed around. Callers that load
|
|
// after startup own the check (and any reload it triggers).
|
|
emit databaseDataReady(std::move(data));
|
|
emit loadingFinished();
|
|
} else {
|
|
qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Failed";
|
|
emit loadingFailed(); // bring up the settings dialog
|
|
}
|
|
|
|
return loadStatus;
|
|
}
|
|
|
|
QString CardDatabaseLoader::cachePath() const
|
|
{
|
|
return pathProvider->getCardDatabasePath() + ".cache";
|
|
}
|
|
|
|
QByteArray CardDatabaseLoader::computeSourceHash(const QStringList &customPaths) const
|
|
{
|
|
// Hash over the paths, sizes and modification times of every input file so
|
|
// the cache invalidates when any source changes. Cheap (no content read).
|
|
QCryptographicHash hash(QCryptographicHash::Sha256);
|
|
|
|
// Include the application version so parser changes automatically invalidate
|
|
// old caches even when the XML files are byte-identical.
|
|
hash.addData(QCoreApplication::applicationVersion().toUtf8());
|
|
hash.addData(QByteArray(1, '\0'));
|
|
|
|
// Include parser schema version to invalidate caches when parser behavior changes.
|
|
// Increment this if the parser logic changes in a way that affects cached data.
|
|
static const char SCHEMA_VERSION[] = "carddb-parse-schema-v1";
|
|
hash.addData(QByteArray(SCHEMA_VERSION, sizeof(SCHEMA_VERSION) - 1));
|
|
hash.addData(QByteArray(1, '\0'));
|
|
|
|
const QStringList inputs = QStringList()
|
|
<< pathProvider->getCardDatabasePath() << pathProvider->getTokenDatabasePath()
|
|
<< pathProvider->getSpoilerCardDatabasePath() << customPaths;
|
|
for (const QString &path : inputs) {
|
|
QFileInfo info(path);
|
|
if (info.exists()) {
|
|
hash.addData(path.toUtf8());
|
|
hash.addData(QByteArray(1, '\0'));
|
|
hash.addData(QByteArray::number(info.size()));
|
|
hash.addData(QByteArray(1, '\0'));
|
|
hash.addData(QByteArray::number(info.lastModified().toSecsSinceEpoch()));
|
|
hash.addData(QByteArray(1, '\0'));
|
|
}
|
|
}
|
|
|
|
// The parsers drop printings of disabled sets at parse time, so the cached
|
|
// snapshot depends on enablement: fold the enabled set names in so that
|
|
// toggling a set invalidates the cache and forces a re-parse.
|
|
if (priorityController) {
|
|
const QStringList enabledSets = priorityController->getEnabledSetNames();
|
|
for (const QString &setName : enabledSets) {
|
|
hash.addData(setName.toUtf8());
|
|
hash.addData(QByteArray(1, '\0'));
|
|
}
|
|
}
|
|
return hash.result();
|
|
}
|
|
|
|
bool CardDatabaseLoader::loadFromCache(CardDatabaseData &data, const QByteArray &sourceHash)
|
|
{
|
|
if (sourceHash.isEmpty()) {
|
|
return false;
|
|
}
|
|
return CardDatabaseCache::read(cachePath(), data, sourceHash, priorityController);
|
|
}
|
|
|
|
bool CardDatabaseLoader::saveToCache(const CardDatabaseData &data, const QByteArray &sourceHash)
|
|
{
|
|
if (sourceHash.isEmpty()) {
|
|
return false;
|
|
}
|
|
return CardDatabaseCache::write(cachePath(), data, sourceHash);
|
|
}
|
|
|
|
QStringList CardDatabaseLoader::collectCustomDatabasePaths() const
|
|
{
|
|
QDirIterator it(pathProvider->getCustomCardDatabasePath(), {"*.xml"}, QDir::Files,
|
|
QDirIterator::Subdirectories | QDirIterator::FollowSymlinks);
|
|
|
|
QStringList paths;
|
|
while (it.hasNext()) {
|
|
paths << it.next();
|
|
}
|
|
paths.sort();
|
|
return paths;
|
|
}
|
|
|
|
bool CardDatabaseLoader::saveCustomTokensToFile()
|
|
{
|
|
if (!database) {
|
|
qCWarning(CardDatabaseLog) << "saveCustomTokensToFile: database pointer missing";
|
|
return false;
|
|
}
|
|
|
|
QString fileName = pathProvider->getCustomCardDatabasePath() + "/" + CardSet::TOKENS_SETNAME + ".xml";
|
|
|
|
SetNameMap tmpSets;
|
|
CardSetPtr customTokensSet = database->getSet(CardSet::TOKENS_SETNAME);
|
|
tmpSets.insert(CardSet::TOKENS_SETNAME, customTokensSet);
|
|
|
|
CardNameMap tmpCards;
|
|
for (const CardInfoPtr &card : database->cards) {
|
|
if (card->getSets().contains(CardSet::TOKENS_SETNAME)) {
|
|
tmpCards.insert(card->getName(), card);
|
|
}
|
|
}
|
|
|
|
availableParsers.first()->saveToFile(FormatRulesNameMap(), tmpSets, tmpCards, fileName);
|
|
return true;
|
|
}
|