diff --git a/.gitignore b/.gitignore index 33c1ae31d..54939bdcf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ mysql.cnf .DS_Store .idea/ *.aps +*.cache cmake-build* preferences compile_commands.json diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index d865c9438..21d847e63 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -541,6 +541,12 @@ MainWindow::MainWindow(QWidget *parent) void MainWindow::startupConfigCheck() { + // checkUnknownSets() is intentionally deferred from the card database load + // (which runs in main() before MainWindow exists) so that + // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live + // receivers when emitted. + CardDatabaseManager::getInstance()->checkUnknownSets(); + if (SettingsCache::instance().debug().getLocalGameOnStartup()) { LocalGameOptions options; options.numberPlayers = SettingsCache::instance().debug().getLocalGamePlayerCount(); @@ -571,8 +577,6 @@ void MainWindow::startupConfigCheck() << "differs, assuming first start after update"; if (SettingsCache::instance().updates().getNotifyAboutNewVersion()) { alertForcedOracleRun(VERSION_STRING, true); - } else { - const auto reloadOk0 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }); } qCInfo(WindowMainStartupShortcutsLog) << "Migrating shortcuts after update detected."; @@ -629,8 +633,6 @@ void MainWindow::startupConfigCheck() } } - const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }); - // Run the tips dialog only on subsequent startups. // On the first run after an install/update the startup is already crowded enough if (tip->successfulInit && SettingsCache::instance().personal().getShowTipsOnStartup() && diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index e9ac19097..dbfd2b6b7 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -258,6 +258,19 @@ int main(int argc, char *argv[]) qCInfo(MainLog) << "Starting main program"; + // Front-load the card database before constructing the main window. The + // binary-cache read is cheap when uncontended (~1s); doing it here -- while no + // GUI work yet competes for CPU -- avoids the multi-second, GUI-freezing + // contention that happens when the load runs alongside window construction. + // The CardDatabaseModel populates from the already-loaded data in its + // constructor, so the window appears fully populated with no startup lag. + // Note: checkUnknownSets() is deferred from the initial load to + // MainWindow::startupConfigCheck() so that + // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live + // receivers when emitted. Subsequent reloads (e.g. path changes) call + // checkUnknownSets() directly from the loader after the first load. + CardDatabaseManager::getInstance()->loadCardDatabases(); + MainWindow ui; if (parser.isSet("connect")) { ui.setConnectTo(parser.value("connect")); diff --git a/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt index dd3799e33..8cf976f92 100644 --- a/libcockatrice_card/CMakeLists.txt +++ b/libcockatrice_card/CMakeLists.txt @@ -31,6 +31,7 @@ add_library( libcockatrice/card/card_info.cpp libcockatrice/card/card_info_comparator.cpp libcockatrice/card/database/card_database.cpp + libcockatrice/card/database/card_database_cache.cpp libcockatrice/card/database/card_database_loader.cpp libcockatrice/card/database/card_database_manager.cpp libcockatrice/card/database/card_database_querier.cpp diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 1aaec85b8..d37408a58 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -5,7 +5,10 @@ #include "relation/card_relation.h" #include "set/card_set.h" +#include #include +#include +#include #include #include #include @@ -19,6 +22,57 @@ class CardInfo; using CardInfoPtr = QSharedPointer; +namespace +{ +QByteArray serializeProperties(const QVariantHash &props) +{ + QByteArray blob; + QDataStream out(&blob, QIODevice::WriteOnly); + out.setVersion(QDataStream::Qt_6_4); + out << props; + return blob; +} +} // namespace + +void CardInfo::ensurePropertiesLoaded() const +{ + QMutexLocker lock(&propertiesMutex); + if (propertiesLoaded) { + return; + } + if (!propertiesBlob.isEmpty()) { + QDataStream in(propertiesBlob); + in.setVersion(QDataStream::Qt_6_4); + in >> propertiesCache; + } + propertiesLoaded = true; +} + +const QVariantHash &CardInfo::getPropertiesHash() const +{ + ensurePropertiesLoaded(); + return propertiesCache; +} + +void CardInfo::setProperty(const QString &_name, const QString &_value) +{ + ensurePropertiesLoaded(); + if (propertiesCache.value(_name).toString() == _value) { + return; + } + propertiesCache.insert(_name, _value); + propertiesBlob = serializeProperties(propertiesCache); + emit cardInfoChanged(smartThis); +} + +void CardInfo::setProperties(const QVariantHash &_props) +{ + ensurePropertiesLoaded(); + propertiesCache = _props; + propertiesBlob = serializeProperties(propertiesCache); + emit cardInfoChanged(smartThis); +} + CardInfo::CardInfo(const QString &_name, const QString &_text, bool _isToken, @@ -27,14 +81,39 @@ CardInfo::CardInfo(const QString &_name, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, const UiAttributes _uiAttributes) - : name(_name), text(_text), isToken(_isToken), properties(std::move(_properties)), relatedCards(_relatedCards), + : name(_name), text(_text), isToken(_isToken), relatedCards(_relatedCards), reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes) { + propertiesCache = std::move(_properties); + propertiesBlob = serializeProperties(propertiesCache); + propertiesLoaded = true; + simpleName = CardInfo::simplifyName(name); refreshCachedSets(); } +CardInfo::CardInfo(const QString &_name, + const QString &_text, + bool _isToken, + QByteArray _propertiesBlob, + const QList &_relatedCards, + const QList &_reverseRelatedCards, + SetToPrintingsMap _sets, + const UiAttributes _uiAttributes, + QString _simpleName, + QSet _altNames) + : name(_name), simpleName(std::move(_simpleName)), text(_text), isToken(_isToken), + propertiesBlob(std::move(_propertiesBlob)), relatedCards(_relatedCards), + reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes), + altNames(std::move(_altNames)) +{ + // propertiesBlob is materialized lazily on first query; simpleName and + // altNames are supplied by the caller (binary cache). Only the set-name + // display list still depends on the current enabled-set state. + refreshCachedSetNames(); +} + CardInfoPtr CardInfo::newInstance(const QString &_name) { return newInstance(_name, "", false, {}, {}, {}, {}, {}); @@ -63,6 +142,35 @@ CardInfoPtr CardInfo::newInstance(const QString &_name, return ptr; } +CardInfoPtr CardInfo::newInstance(const QString &_name, + const QString &_text, + bool _isToken, + QByteArray _propertiesBlob, + const QList &_relatedCards, + const QList &_reverseRelatedCards, + SetToPrintingsMap _sets, + const UiAttributes _uiAttributes, + QString _simpleName, + QSet _altNames, + bool _appendToSets) +{ + CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, std::move(_propertiesBlob), _relatedCards, + _reverseRelatedCards, _sets, _uiAttributes, std::move(_simpleName), + std::move(_altNames))); + ptr->setSmartPointer(ptr); + + if (_appendToSets) { + for (const auto &printings : _sets) { + for (const PrintingInfo &printing : printings) { + printing.getSet()->append(ptr); + break; + } + } + } + + return ptr; +} + QString CardInfo::getCorrectedName() const { // remove all the characters reserved in windows file paths, @@ -104,13 +212,16 @@ void CardInfo::addToSet(const CardSetPtr &_set, const PrintingInfo &_info) void CardInfo::combineLegalities(const QVariantHash &props) { + ensurePropertiesLoaded(); QHashIterator it(props); while (it.hasNext()) { it.next(); if (it.key().startsWith("format-")) { - smartThis->setProperty(it.key(), it.value().toString()); + propertiesCache.insert(it.key(), it.value()); } } + propertiesBlob = serializeProperties(propertiesCache); + emit cardInfoChanged(smartThis); } void CardInfo::refreshCachedSets() diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 654ac1f63..ad99864c8 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -69,12 +70,25 @@ private: * @anchor PrivateCardProperties */ ///@{ - CardInfoPtr smartThis; ///< Smart pointer to self for safe cross-references. - QString name; ///< Full name of the card. - QString simpleName; ///< Simplified name for fuzzy matching. - QString text; ///< Text description or rules text of the card. - bool isToken; ///< Whether this card is a token or not. - QVariantHash properties; ///< Key-value store of dynamic card properties. + CardInfoPtr smartThis; ///< Smart pointer to self for safe cross-references. + QString name; ///< Full name of the card. + QString simpleName; ///< Simplified name for fuzzy matching. + QString text; ///< Text description or rules text of the card. + bool isToken; ///< Whether this card is a token or not. + // Properties are stored as a pre-serialized blob (cheap to load) and the + // QVariantHash is materialized on first query, so database load avoids + // constructing thousands of QVariants per card. + mutable QByteArray propertiesBlob; ///< Serialized properties (load form). + mutable QVariantHash propertiesCache; ///< Materialized properties (query form). + mutable bool propertiesLoaded = false; ///< Whether propertiesCache is valid. + mutable QMutex propertiesMutex; ///< Guards lazy materialization. + + /** + * @brief Materializes propertiesCache from propertiesBlob if not already done. + * Safe to call from const getters (members are mutable). + */ + void ensurePropertiesLoaded() const; + QList relatedCards; ///< Forward references to related cards. QList reverseRelatedCards; ///< Cards that refer back to this card. QList reverseRelatedCardsToMe; ///< Cards that consider this card as related. @@ -106,6 +120,38 @@ public: SetToPrintingsMap _sets, UiAttributes _uiAttributes); + /** + * @brief Constructs a CardInfo from a cache snapshot with precomputed derived + * state. + * + * Used by the binary cache reader to skip recomputing @p _simpleName and + * @p _altNames (which otherwise require a Unicode normalization and a full + * printing scan). Properties are supplied as a pre-serialized @p _propertiesBlob + * so the QVariantHash is not built at load time (it is materialized on first + * query). + * + * @param _name The card name. + * @param _text Rules text or description of the card. + * @param _isToken Token flag. + * @param _propertiesBlob Pre-serialized properties blob (as written by the cache). + * @param _relatedCards Forward relationships. + * @param _reverseRelatedCards Reverse relationships. + * @param _sets Printing information per set. + * @param _uiAttributes Attributes that affect display and game logic. + * @param _simpleName Precomputed simplified name. + * @param _altNames Precomputed alternate names. + */ + explicit CardInfo(const QString &_name, + const QString &_text, + bool _isToken, + QByteArray _propertiesBlob, + const QList &_relatedCards, + const QList &_reverseRelatedCards, + SetToPrintingsMap _sets, + UiAttributes _uiAttributes, + QString _simpleName, + QSet _altNames); + /** * @brief Copy constructor for CardInfo. * @@ -115,7 +161,7 @@ public: */ CardInfo(const CardInfo &other) : QObject(other.parent()), name(other.name), simpleName(other.simpleName), text(other.text), - isToken(other.isToken), properties(other.properties), relatedCards(other.relatedCards), + isToken(other.isToken), propertiesBlob(other.propertiesBlob), relatedCards(other.relatedCards), reverseRelatedCards(other.reverseRelatedCards), reverseRelatedCardsToMe(other.reverseRelatedCardsToMe), setsToPrintings(other.setsToPrintings), uiAttributes(other.uiAttributes), setsNames(other.setsNames), altNames(other.altNames) @@ -154,6 +200,38 @@ public: SetToPrintingsMap _sets, UiAttributes _uiAttributes); + /** + * @brief Creates a new instance from a cache snapshot with precomputed + * derived state. + * + * @param _name Name of the card. + * @param _text Rules text or description. + * @param _isToken Token flag. + * @param _propertiesBlob Pre-serialized properties blob (as written by the cache). + * @param _relatedCards Forward relationships. + * @param _reverseRelatedCards Reverse relationships. + * @param _sets Printing information per set. + * @param _uiAttributes Attributes that affect display and game logic. + * @param _simpleName Precomputed simplified name. + * @param _altNames Precomputed alternate names. + * @param _appendToSets When true (default), the card is appended to each of + * its CardSets. Pass false when building cards in parallel so the + * (non-thread-safe) set membership is populated in a later + * single-threaded pass. + * @return Shared pointer to the new CardInfo instance. + */ + static CardInfoPtr newInstance(const QString &_name, + const QString &_text, + bool _isToken, + QByteArray _propertiesBlob, + const QList &_relatedCards, + const QList &_reverseRelatedCards, + SetToPrintingsMap _sets, + UiAttributes _uiAttributes, + QString _simpleName, + QSet _altNames, + bool _appendToSets = true); + /** * @brief Clones the current CardInfo instance. * @@ -208,20 +286,32 @@ public: } [[nodiscard]] QStringList getProperties() const { - return properties.keys(); + return getPropertiesHash().keys(); + } + [[nodiscard]] const QVariantHash &getPropertiesHash() const; + + /** + * @brief Stores the pre-serialized properties blob and invalidates the + * materialized cache. Used by the binary cache reader so the + * QVariantHash is not built at load time. + * @param _blob The serialized properties (as written by the cache writer). + */ + void setPropertiesBlob(QByteArray _blob) const + { + QMutexLocker lock(&propertiesMutex); + propertiesBlob = std::move(_blob); + propertiesLoaded = false; + propertiesCache.clear(); } [[nodiscard]] QString getProperty(const QString &propertyName) const { - return properties.value(propertyName).toString(); - } - void setProperty(const QString &_name, const QString &_value) - { - properties.insert(_name, _value); - emit cardInfoChanged(smartThis); + return getPropertiesHash().value(propertyName).toString(); } + void setProperty(const QString &_name, const QString &_value); + void setProperties(const QVariantHash &_props); [[nodiscard]] bool hasProperty(const QString &propertyName) const { - return properties.contains(propertyName); + return getPropertiesHash().contains(propertyName); } [[nodiscard]] const SetToPrintingsMap &getSets() const { diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.cpp b/libcockatrice_card/libcockatrice/card/database/card_database.cpp index 261b36690..209b9d2c2 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database.cpp @@ -20,6 +20,7 @@ CardDatabase::CardDatabase(QObject *parent, { qRegisterMetaType("CardInfoPtr"); qRegisterMetaType("CardSetPtr"); + qRegisterMetaType("CardDatabaseData"); // create loader and wire it up loader = new CardDatabaseLoader(this, this, pathProvider, prefs, setPriorityController); @@ -28,6 +29,10 @@ CardDatabase::CardDatabase(QObject *parent, connect(loader, &CardDatabaseLoader::loadingFailed, this, &CardDatabase::cardDatabaseLoadingFailed); connect(loader, &CardDatabaseLoader::newSetsFound, this, &CardDatabase::cardDatabaseNewSetsFound); connect(loader, &CardDatabaseLoader::allNewSetsEnabled, this, &CardDatabase::cardDatabaseAllNewSetsEnabled); + // swap the finished snapshot into the live database. Uses AutoConnection so + // that cross-thread loads are delivered via the event loop while same-thread/ + // synchronous callers get the swap immediately. + connect(loader, &CardDatabaseLoader::databaseDataReady, this, &CardDatabase::swapInDatabaseData); querier = new CardDatabaseQuerier(this, this, prefs); } @@ -66,7 +71,12 @@ void CardDatabase::reloadCardDatabasesAndNotify() loadCardDatabases(); if (loadStatus == Ok) { - notifyEnabledSetsChanged(); + checkUnknownSets(); + // A reload reconstructs the exact same enabled-set state, so the cached + // set-name / alt-name data computed during construction is still valid. + // Skipping the per-card refresh avoids a full 36k-card recompute that + // would otherwise duplicate the work already done while building cards. + notifyEnabledSetsChanged(false); } } @@ -77,13 +87,18 @@ bool CardDatabase::saveCustomTokensToFile() void CardDatabase::refreshCachedReverseRelatedCards() { - for (const auto &card : cards) { + refreshCachedReverseRelatedCards(cards); +} + +void CardDatabase::refreshCachedReverseRelatedCards(CardNameMap &cardMap) +{ + for (const auto &card : cardMap) { card->resetReverseRelatedCards2Me(); } - for (const auto &card : cards) { + for (const auto &card : cardMap) { for (auto *rel : card->getReverseRelatedCards()) { - if (auto target = cards.value(rel->getName())) { + if (auto target = cardMap.value(rel->getName())) { auto *newRel = new CardRelation(card->getName(), rel->getAttachType(), rel->getIsCreateAllExclusion(), rel->getIsVariable(), rel->getDefaultCount(), rel->getIsPersistent(), rel->getIsFaceDown()); @@ -205,11 +220,15 @@ void CardDatabase::markAllSetsAsKnown() _sets.markAllAsKnown(); } -void CardDatabase::notifyEnabledSetsChanged() +void CardDatabase::notifyEnabledSetsChanged(bool recomputeCachedSets) { - // refresh the list of cached set names - for (const CardInfoPtr &card : cards) { - card->refreshCachedSets(); + if (recomputeCachedSets) { + // refresh the list of cached set names / alt names for every card; this is + // only needed when the enabled-set state actually changed (e.g. the user + // toggled a set in the settings UI). + for (const CardInfoPtr &card : cards) { + card->refreshCachedSets(); + } } // inform the carddatabasemodels that they need to re-check their list of cards @@ -220,3 +239,30 @@ void CardDatabase::addFormat(const FormatRulesPtr &format) { formats.insert(format->formatName.toLower(), format); } + +void CardDatabase::swapInDatabaseData(CardDatabaseData data) +{ + for (const auto &card : cards) { + for (auto *rel : card->getRelatedCards()) { + rel->deleteLater(); + } + for (auto *rel : card->getReverseRelatedCards()) { + rel->deleteLater(); + } + for (auto *rel : card->getReverseRelatedCards2Me()) { + rel->deleteLater(); + } + } + + cards = std::move(data.cards); + simpleNameCards = std::move(data.simpleNameCards); + sets = std::move(data.sets); + formats = std::move(data.formats); + + loadStatus = cards.isEmpty() ? NotLoaded : Ok; + + // inform listeners that the whole database was replaced; they should + // rebuild from the live containers in a single batch instead of reacting + // to individual card additions. + emit cardDatabaseReset(); +} diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.h b/libcockatrice_card/libcockatrice/card/database/card_database.h index 44838962d..b10e3c726 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database.h @@ -2,6 +2,7 @@ #define CARDDATABASE_H #include "../set/card_set_list.h" +#include "card_database_data.h" #include "card_database_loader.h" #include "card_database_querier.h" @@ -54,16 +55,17 @@ protected: CardDatabaseQuerier *querier; private: - /** - * @brief Check for sets that are unknown and emit signals if needed. - */ - void checkUnknownSets(); - /** * @brief Refreshes the cached reverse-related cards for all cards. */ void refreshCachedReverseRelatedCards(); + /** + * @brief Refreshes the cached reverse-related cards for the given card map. + * @param cardMap Map of cards to process. + */ + void refreshCachedReverseRelatedCards(CardNameMap &cardMap); + /** @brief Mutexes for thread safety. */ QBasicMutex *clearDatabaseMutex = new QBasicMutex(), *addCardMutex = new QBasicMutex(), *removeCardMutex = new QBasicMutex(); @@ -127,8 +129,25 @@ public: /** @brief Marks all sets as known. */ void markAllSetsAsKnown(); - /** @brief Notifies listeners that enabled sets changed. */ - void notifyEnabledSetsChanged(); + /** + * @brief Notifies listeners that enabled sets changed. + * @param recomputeCachedSets When true, every card's cached set-name / alt-name + * data is recomputed (needed only when enabled-set state actually + * changed). Pass false after a plain reload, where enabled-set state is + * unchanged and the cached data is still valid. + */ + void notifyEnabledSetsChanged(bool recomputeCachedSets = true); + + /** + * @brief Check for sets that are unknown and emit signals if needed. + * + * Called from MainWindow::startupConfigCheck() after signal connections are + * live (the front-loaded parse in main() runs before MainWindow exists, so + * signals emitted there would have no receivers). May also be called by + * the loader via the friend declaration. Has side effects: enables all + * sets when none are enabled (first-run), marks all sets as known. + */ + void checkUnknownSets(); ICardSetPriorityController *getPriorityController() { @@ -159,10 +178,26 @@ public slots: */ bool saveCustomTokensToFile(); + /** + * @brief Replaces the entire contents of the database with the provided + * snapshot in a single operation. Intended to be called once a + * background load has finished building a CardDatabaseData, so that + * observers never see a partially-populated database. + * @param data The fully-built snapshot to adopt. + */ + void swapInDatabaseData(CardDatabaseData data); + signals: /** @brief Emitted when the card database has finished loading successfully. */ void cardDatabaseLoadingFinished(); + /** + * @brief Emitted once after the live database contents have been replaced + * by a freshly loaded snapshot. Models should rebuild from the + * database in a single batch rather than reacting to per-card adds. + */ + void cardDatabaseReset(); + /** @brief Emitted when the card database fails to load. */ void cardDatabaseLoadingFailed(); diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp new file mode 100644 index 000000000..0b68a389a --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp @@ -0,0 +1,485 @@ +#include "card_database_cache.h" + +#include "../card_info.h" +#include "../format/format_legality_rules.h" +#include "../printing/printing_info.h" +#include "../relation/card_relation.h" +#include "../relation/card_relation_type.h" +#include "../set/card_set.h" +#include "card_database_loader.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr quint32 CACHE_MAGIC = 0x43445243; // "CDRC" +constexpr quint32 CACHE_VERSION = 1; + +// ---- Primitives ----------------------------------------------------------- + +void writeString(QDataStream &out, const QString &s) +{ + out << s; +} + +QString readString(QDataStream &in) +{ + QString s; + in >> s; + return s; +} + +// Stores a QVariantHash as a single pre-serialized blob. The reader keeps the +// blob as-is and materializes the QVariantHash lazily on first query, which is +// what removes the allocation storm from database load (see card_info.cpp / +// printing_info.cpp). +void writeHashBlob(QDataStream &out, const QVariantHash &h) +{ + QByteArray blob; + QDataStream blobOut(&blob, QIODevice::WriteOnly); + blobOut.setVersion(QDataStream::Qt_6_4); + blobOut << h; + out << static_cast(blob.size()); + out.writeRawData(blob.constData(), blob.size()); +} + +QByteArray readHashBlob(QDataStream &in) +{ + quint32 len = 0; + in >> len; + if (in.status() != QDataStream::Ok || static_cast(len) > in.device()->bytesAvailable()) { + return {}; + } + QByteArray blob(len, Qt::Uninitialized); + in.readRawData(blob.data(), static_cast(len)); + return blob; +} + +void writeDate(QDataStream &out, const QDate &d) +{ + out << d; +} + +QDate readDate(QDataStream &in) +{ + QDate d; + in >> d; + return d; +} + +// ---- CardRelation ---------------------------------------------------------- + +void writeRelation(QDataStream &out, const CardRelation *rel) +{ + writeString(out, rel->getName()); + out << static_cast(rel->getAttachType()); + out << rel->getIsCreateAllExclusion(); + out << rel->getIsVariable(); + out << rel->getDefaultCount(); + out << rel->getIsPersistent(); + out << rel->getIsFaceDown(); +} + +CardRelation *readRelation(QDataStream &in) +{ + QString name = readString(in); + quint32 attachType = 0; + bool isExclusion = false; + bool isVariable = false; + int defaultCount = 1; + bool isPersistent = false; + bool isFaceDown = false; + in >> attachType; + in >> isExclusion; + in >> isVariable; + in >> defaultCount; + in >> isPersistent; + in >> isFaceDown; + return new CardRelation(name, static_cast(attachType), isExclusion, isVariable, defaultCount, + isPersistent, isFaceDown); +} + +// ---- PrintingInfo ---------------------------------------------------------- + +// A printing references its set by short name; the CardSetPtr is resolved after +// all sets have been reconstructed on read. +void writePrinting(QDataStream &out, const PrintingInfo &p) +{ + writeString(out, p.getSet() ? p.getSet()->getShortName() : QString()); + writeHashBlob(out, p.getPropertiesHash()); +} + +PrintingInfo readPrinting(QDataStream &in, const SetNameMap &sets) +{ + QString setName = readString(in); + QByteArray propsBlob = readHashBlob(in); + PrintingInfo p; + if (auto set = sets.value(setName)) { + p = PrintingInfo(set); + } + p.setPropertiesBlob(propsBlob); + return p; +} + +// ---- CardSet --------------------------------------------------------------- + +void writeSet(QDataStream &out, const CardSetPtr &set) +{ + writeString(out, set->getShortName()); + writeString(out, set->getLongName()); + writeString(out, set->getSetType()); + writeDate(out, set->getReleaseDate()); + out << static_cast(set->getPriority()); +} + +CardSetPtr readSet(QDataStream &in, ICardSetPriorityController *priorityController) +{ + QString shortName = readString(in); + QString longName = readString(in); + QString setType = readString(in); + QDate releaseDate = readDate(in); + quint32 priority = 0; + in >> priority; + return CardSet::newInstance(priorityController, shortName, longName, setType, releaseDate, + static_cast(priority)); +} + +// ---- CardInfo -------------------------------------------------------------- + +void writeCard(QDataStream &out, const CardInfoPtr &card) +{ + writeString(out, card->getName()); + writeString(out, card->getText()); + out << card->getIsToken(); + writeHashBlob(out, card->getPropertiesHash()); + + CardInfo::UiAttributes ui = card->getUiAttributes(); + out << ui.cipt; + out << ui.landscapeOrientation; + out << ui.tableRow; + out << ui.upsideDownArt; + + // Precomputed derived state, so the reader can skip simplifyName() and the + // per-printing alt-name scan entirely. + writeString(out, card->getSimpleName()); + { + const QSet altNames = card->getAltNames(); + out << static_cast(altNames.size()); + for (const QString &alt : altNames) { + writeString(out, alt); + } + } + + // setsToPrintings + const SetToPrintingsMap sets = card->getSets(); + out << static_cast(sets.size()); + for (auto it = sets.constBegin(); it != sets.constEnd(); ++it) { + writeString(out, it.key()); + out << static_cast(it.value().size()); + for (const PrintingInfo &p : it.value()) { + writePrinting(out, p); + } + } + + // related cards + const QList related = card->getRelatedCards(); + out << static_cast(related.size()); + for (const CardRelation *rel : related) { + writeRelation(out, rel); + } + + // reverse-related cards (the reverseRelatedCards list, not the computed 2Me) + const QList reverse = card->getReverseRelatedCards(); + out << static_cast(reverse.size()); + for (const CardRelation *rel : reverse) { + writeRelation(out, rel); + } +} + +CardInfoPtr readCard(QDataStream &in, const SetNameMap &sets) +{ + QString name = readString(in); + QString text = readString(in); + bool isToken = false; + in >> isToken; + QByteArray propertiesBlob = readHashBlob(in); + + CardInfo::UiAttributes ui; + in >> ui.cipt; + in >> ui.landscapeOrientation; + in >> ui.tableRow; + in >> ui.upsideDownArt; + + // Precomputed derived state, restored directly to skip simplifyName() and the + // per-printing alt-name scan. + QString simpleName = readString(in); + QSet altNames; + quint32 altNameCount = 0; + in >> altNameCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + altNames.reserve(altNameCount); + for (quint32 i = 0; i < altNameCount; ++i) { + altNames.insert(readString(in)); + } + + SetToPrintingsMap cardSets; + quint32 setCount = 0; + in >> setCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + for (quint32 i = 0; i < setCount; ++i) { + QString setName = readString(in); + quint32 printingCount = 0; + in >> printingCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + QList printings; + printings.reserve(printingCount); + for (quint32 j = 0; j < printingCount; ++j) { + printings.append(readPrinting(in, sets)); + } + cardSets.insert(setName, printings); + } + + QList related; + quint32 relatedCount = 0; + in >> relatedCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + related.reserve(relatedCount); + for (quint32 i = 0; i < relatedCount; ++i) { + related.append(readRelation(in)); + } + + QList reverse; + quint32 reverseCount = 0; + in >> reverseCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + reverse.reserve(reverseCount); + for (quint32 i = 0; i < reverseCount; ++i) { + reverse.append(readRelation(in)); + } + + return CardInfo::newInstance(name, text, isToken, propertiesBlob, related, reverse, cardSets, ui, simpleName, + altNames, false); +} + +// ---- FormatRules ----------------------------------------------------------- + +void writeFormat(QDataStream &out, const FormatRulesPtr &format) +{ + writeString(out, format->formatName); + out << format->minDeckSize; + out << format->maxDeckSize; + out << format->maxSideboardSize; + + out << static_cast(format->allowedCounts.size()); + for (const AllowedCount &ac : format->allowedCounts) { + out << ac.max; + writeString(out, ac.label); + } + + out << static_cast(format->exceptions.size()); + for (const ExceptionRule &ex : format->exceptions) { + out << static_cast(ex.conditions.size()); + for (const CardCondition &cond : ex.conditions) { + writeString(out, cond.field); + writeString(out, cond.matchType); + writeString(out, cond.value); + } + out << ex.maxCopies; + } +} + +FormatRulesPtr readFormat(QDataStream &in) +{ + FormatRulesPtr format(new FormatRules()); + format->formatName = readString(in); + in >> format->minDeckSize; + in >> format->maxDeckSize; + in >> format->maxSideboardSize; + + quint32 allowedCount = 0; + in >> allowedCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + for (quint32 i = 0; i < allowedCount; ++i) { + AllowedCount ac; + in >> ac.max; + ac.label = readString(in); + format->allowedCounts.append(ac); + } + + quint32 exceptionCount = 0; + in >> exceptionCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + for (quint32 i = 0; i < exceptionCount; ++i) { + ExceptionRule ex; + quint32 condCount = 0; + in >> condCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + for (quint32 j = 0; j < condCount; ++j) { + CardCondition cond; + cond.field = readString(in); + cond.matchType = readString(in); + cond.value = readString(in); + ex.conditions.append(cond); + } + in >> ex.maxCopies; + format->exceptions.append(ex); + } + return format; +} + +} // namespace + +bool CardDatabaseCache::write(const QString &cachePath, const CardDatabaseData &data, const QByteArray &sourceHash) +{ + QSaveFile file(cachePath); + if (!file.open(QIODevice::WriteOnly)) { + return false; + } + + QDataStream out(&file); + out.setVersion(QDataStream::Qt_6_4); + + // Header + out << CACHE_MAGIC; + out << CACHE_VERSION; + out << sourceHash; + + // Sets + out << static_cast(data.sets.size()); + for (const CardSetPtr &set : data.sets) { + writeSet(out, set); + } + + // Cards + const quint32 cardCount = static_cast(data.cards.size()); + out << cardCount; + for (const CardInfoPtr &card : data.cards) { + writeCard(out, card); + } + + // Formats + out << static_cast(data.formats.size()); + for (const FormatRulesPtr &format : data.formats) { + writeFormat(out, format); + } + + return file.commit(); +} + +bool CardDatabaseCache::read(const QString &cachePath, + CardDatabaseData &data, + const QByteArray &sourceHash, + ICardSetPriorityController *priorityController) +{ + QFile file(cachePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + // Read the whole cache into memory up front; deserialization then works on an + // in-memory buffer with no further disk I/O. + QByteArray raw = file.readAll(); + if (raw.isEmpty()) { + return false; + } + + QBuffer buffer(&raw); + buffer.open(QIODevice::ReadOnly); + QDataStream in(&buffer); + in.setVersion(QDataStream::Qt_6_4); + + quint32 magic = 0; + quint32 version = 0; + QByteArray storedHash; + in >> magic >> version >> storedHash; + if (magic != CACHE_MAGIC || version != CACHE_VERSION || storedHash != sourceHash) { + return false; + } + + QElapsedTimer deserializeTimer; + deserializeTimer.start(); + + // Sets + quint32 setCount = 0; + in >> setCount; + if (in.status() != QDataStream::Ok) { + return false; + } + for (quint32 i = 0; i < setCount; ++i) { + CardSetPtr set = readSet(in, priorityController); + data.sets.insert(set->getShortName(), set); + } + + // Cards + quint32 cardCount = 0; + in >> cardCount; + if (in.status() != QDataStream::Ok) { + return false; + } + if (cardCount > 0) { + data.cards.reserve(static_cast(cardCount)); + for (quint32 i = 0; i < cardCount; ++i) { + CardInfoPtr card = readCard(in, data.sets); + if (card == nullptr) { + return false; + } + data.cards.insert(card->getName(), card); + data.simpleNameCards.insert(card->getSimpleName(), card); + } + + for (const CardInfoPtr &card : data.cards) { + for (const auto &printings : card->getSets()) { + for (const PrintingInfo &printing : printings) { + if (auto set = printing.getSet()) { + set->append(card); + break; + } + } + } + } + } + + // Formats + quint32 formatCount = 0; + in >> formatCount; + if (in.status() != QDataStream::Ok) { + return false; + } + for (quint32 i = 0; i < formatCount; ++i) { + FormatRulesPtr format = readFormat(in); + if (format == nullptr) { + return false; + } + data.formats.insert(format->formatName.toLower(), format); + } + + qCInfo(CardDatabaseLoadingLog) << "[cache] read + deserialize" << deserializeTimer.elapsed() << "ms for" + << cardCount << "cards"; + + if (in.status() != QDataStream::Ok) { + return false; + } + + return file.error() == QFile::NoError; +} diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.h b/libcockatrice_card/libcockatrice/card/database/card_database_cache.h new file mode 100644 index 000000000..40ff96541 --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.h @@ -0,0 +1,34 @@ +#ifndef CARDDATABASE_CACHE_H +#define CARDDATABASE_CACHE_H + +#include "card_database_data.h" + +#include +#include + +namespace CardDatabaseCache +{ +/** + * @brief Writes a fully-built database snapshot to a binary cache file. + * @param cachePath Path of the cache file to write. + * @param data The snapshot to serialize. + * @param sourceHash Hash of the source XML used to validate the cache later. + * @return True if the cache was written successfully. + */ +bool write(const QString &cachePath, const CardDatabaseData &data, const QByteArray &sourceHash); + +/** + * @brief Reads a database snapshot from a binary cache file. + * @param cachePath Path of the cache file to read. + * @param data Snapshot to populate. + * @param sourceHash Expected hash of the source XML; read fails if it differs. + * @param priorityController Controller used to reconstruct CardSet state. + * @return True if a valid, up-to-date cache was read. + */ +bool read(const QString &cachePath, + CardDatabaseData &data, + const QByteArray &sourceHash, + ICardSetPriorityController *priorityController); +} // namespace CardDatabaseCache + +#endif // CARDDATABASE_CACHE_H diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_data.h b/libcockatrice_card/libcockatrice/card/database/card_database_data.h new file mode 100644 index 000000000..2ca087b7e --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/database/card_database_data.h @@ -0,0 +1,24 @@ +#ifndef CARDDATABASE_DATA_H +#define CARDDATABASE_DATA_H + +#include + +/** + * @struct CardDatabaseData + * @ingroup CardDatabase + * @brief Value type holding the full parsed state of a card database. + * + * Parsers fill a CardDatabaseData off the UI thread; the finished snapshot is + * then swapped into the live CardDatabase atomically. Keeping this separate from + * the live container avoids emitting per-card change notifications and prevents + * readers from observing a half-loaded database. + */ +struct CardDatabaseData +{ + CardNameMap cards; + CardNameMap simpleNameCards; + SetNameMap sets; + FormatRulesNameMap formats; +}; + +#endif // CARDDATABASE_DATA_H diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp index 76abf87ce..47f7eee6d 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp @@ -1,12 +1,18 @@ #include "card_database_loader.h" #include "card_database.h" +#include "card_database_cache.h" +#include "parser/card_database_parser.h" #include "parser/cockatrice_xml_3.h" #include "parser/cockatrice_xml_4.h" +#include +#include +#include #include #include #include +#include #include CardDatabaseLoader::CardDatabaseLoader(QObject *parent, @@ -14,18 +20,14 @@ CardDatabaseLoader::CardDatabaseLoader(QObject *parent, ICardDatabasePathProvider *_pathProvider, ICardPreferenceProvider *_preferenceProvider, ICardSetPriorityController *_priorityController) - : QObject(parent), database(db), pathProvider(_pathProvider) + : QObject(parent), database(db), pathProvider(_pathProvider), priorityController(_priorityController) { - // instantiate available parsers here and connect them to the database + // instantiate available parsers here availableParsers << new CockatriceXml4Parser(_preferenceProvider, _priorityController); availableParsers << new CockatriceXml3Parser(_priorityController); - for (auto *p : availableParsers) { - // connect parser outputs to the database adders - connect(p, &ICardDatabaseParser::addCard, database, &CardDatabase::addCard, Qt::DirectConnection); - connect(p, &ICardDatabaseParser::addSet, database, &CardDatabase::addSet, Qt::DirectConnection); - connect(p, &ICardDatabaseParser::addFormat, database, &CardDatabase::addFormat, Qt::DirectConnection); - } + // 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, @@ -38,7 +40,7 @@ CardDatabaseLoader::~CardDatabaseLoader() availableParsers.clear(); } -LoadStatus CardDatabaseLoader::loadFromFile(const QString &fileName) +LoadStatus CardDatabaseLoader::loadFromFile(const QString &fileName, CardDatabaseData &data) { QFile file(fileName); if (!file.open(QIODevice::ReadOnly)) { @@ -49,7 +51,7 @@ LoadStatus CardDatabaseLoader::loadFromFile(const QString &fileName) file.reset(); if (parser->getCanParseFile(fileName, file)) { file.reset(); - parser->parseFile(file); + parser->parseFileInto(file, data); return Ok; } } @@ -57,24 +59,29 @@ LoadStatus CardDatabaseLoader::loadFromFile(const QString &fileName) return Invalid; } -LoadStatus CardDatabaseLoader::loadCardDatabase(const QString &path) +LoadStatus CardDatabaseLoader::loadCardDatabase(const QString &path, CardDatabaseData &data) { auto startTime = QTime::currentTime(); LoadStatus tempLoadStatus = NotLoaded; if (!path.isEmpty()) { QMutexLocker locker(loadFromFileMutex); - tempLoadStatus = loadFromFile(path); + tempLoadStatus = loadFromFile(path, data); } int msecs = startTime.msecsTo(QTime::currentTime()); qCInfo(CardDatabaseLoadingLog) << "Loaded card database: Path =" << path << "Status =" << tempLoadStatus - << "Cards =" << (database ? database->cards.size() : 0) - << "Sets =" << (database ? database->sets.size() : 0) << QString("%1ms").arg(msecs); + << "Cards =" << data.cards.size() << "Sets =" << data.sets.size() + << QString("%1ms").arg(msecs); return tempLoadStatus; } LoadStatus CardDatabaseLoader::loadCardDatabases() +{ + return doLoadCardDatabases(); +} + +LoadStatus CardDatabaseLoader::doLoadCardDatabases() { QMutexLocker locker(reloadDatabaseMutex); @@ -86,31 +93,55 @@ LoadStatus CardDatabaseLoader::loadCardDatabases() emit loadingStarted(); qCInfo(CardDatabaseLoadingLog) << "Card Database Loading Started"; - database->clear(); // remove old db + ICardDatabaseParser::clearSetlist(); - LoadStatus loadStatus = loadCardDatabase(pathProvider->getCardDatabasePath()); // load main card database - loadCardDatabase(pathProvider->getTokenDatabasePath()); // load tokens database - loadCardDatabase(pathProvider->getSpoilerCardDatabasePath()); // load spoilers database + CardDatabaseData data; + LoadStatus loadStatus = NotLoaded; - // find all custom card databases, recursively & following symlinks - // then load them alphabetically + // Try the binary cache first: a cache hit avoids re-parsing the (large) XML. const QStringList customPaths = collectCustomDatabasePaths(); - for (int i = 0; i < customPaths.size(); ++i) { - const auto &p = customPaths.at(i); - qCInfo(CardDatabaseLoadingLog) << "Loading Custom Set" << i << "(" << p << ")"; - loadCardDatabase(p); + const QByteArray sourceHash = computeSourceHash(customPaths); + if (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); + } + + 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 - - database->refreshCachedReverseRelatedCards(); - + // AFTER all the cards have been loaded: resolve the reverse-related tags + // against the fully-built snapshot. if (loadStatus == Ok) { - database->checkUnknownSets(); // update deck editors, etc + database->refreshCachedReverseRelatedCards(data.cards); + qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Success"; + emit databaseDataReady(std::move(data)); emit loadingFinished(); + // During the front-loaded parse in main() this runs before MainWindow + // exists, so cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled + // would be emitted with no receivers. Skip the check on the first load; + // MainWindow::startupConfigCheck() calls checkUnknownSets() once its + // signal connections are live. On subsequent reloads (e.g. path change) + // MainWindow exists and signals have live receivers. + if (initialLoadComplete) { + database->checkUnknownSets(); + } + initialLoadComplete = true; } else { qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Failed"; emit loadingFailed(); // bring up the settings dialog @@ -119,6 +150,55 @@ LoadStatus CardDatabaseLoader::loadCardDatabases() 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')); + + 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')); + } + } + 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, diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h index 861cc95b0..6dcb06bf2 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -62,16 +63,20 @@ public: public slots: /** * @brief Loads all configured card databases. + * + * Runs synchronously on the calling thread. The caller should ensure + * that any signal receivers are already connected before invoking this. * @return Status of the main database load. */ LoadStatus loadCardDatabases(); /** - * @brief Loads a single card database file. + * @brief Loads a single card database file into the given snapshot. * @param path Path to the database file. + * @param data Snapshot to populate. * @return LoadStatus indicating success or failure. */ - LoadStatus loadCardDatabase(const QString &path); + LoadStatus loadCardDatabase(const QString &path, CardDatabaseData &data); /** * @brief Saves custom tokens to the user-defined custom database path. @@ -89,6 +94,12 @@ signals: /** @brief Emitted when loading fails. */ void loadingFailed(); + /** + * @brief Emitted with the fully-built snapshot once background loading + * completes. The receiver swaps it into the live database. + */ + void databaseDataReady(CardDatabaseData data); + /** * @brief Emitted when new sets are discovered during loading. * @param numSets Number of new sets. @@ -101,11 +112,18 @@ signals: private: /** - * @brief Loads a database from a single file using the available parsers. + * @brief Parses a single file into the given snapshot using the available parsers. * @param fileName Path to the database file. + * @param data Snapshot to populate. * @return LoadStatus indicating success or failure. */ - LoadStatus loadFromFile(const QString &fileName); + LoadStatus loadFromFile(const QString &fileName, CardDatabaseData &data); + + /** + * @brief Performs the actual load work synchronously on the calling thread. + * @return Status of the main database load. + */ + LoadStatus doLoadCardDatabases(); /** * @brief Collects custom card database paths recursively. @@ -113,13 +131,43 @@ private: */ [[nodiscard]] QStringList collectCustomDatabasePaths() const; + /** + * @brief Computes a hash identifying the current set of input database files. + * @return Hash over the paths, sizes and modification times of all inputs. + */ + [[nodiscard]] QByteArray computeSourceHash(const QStringList &customPaths) const; + + /** + * @brief Path of the binary cache file derived from the main card database path. + * @return Cache file path. + */ + [[nodiscard]] QString cachePath() const; + + /** + * @brief Attempts to populate the snapshot from the binary cache. + * @param data Snapshot to populate. + * @param sourceHash Pre-computed hash identifying the current input files. + * @return True if a valid, up-to-date cache was read. + */ + bool loadFromCache(CardDatabaseData &data, const QByteArray &sourceHash); + + /** + * @brief Writes the snapshot to the binary cache for the current inputs. + * @param data Snapshot to serialize. + * @param sourceHash Pre-computed hash identifying the current input files. + * @return True if the cache was written successfully. + */ + bool saveToCache(const CardDatabaseData &data, const QByteArray &sourceHash); + private: - CardDatabase *database; /**< Non-owning pointer to the target CardDatabase. */ - ICardDatabasePathProvider *pathProvider; /**< Pointer to the path provider. */ - QList availableParsers; /**< List of available parsers for different formats. */ + CardDatabase *database; /**< Non-owning pointer to the target CardDatabase. */ + ICardDatabasePathProvider *pathProvider; /**< Pointer to the path provider. */ + ICardSetPriorityController *priorityController; /**< Controller for reconstructing set state. */ + QList availableParsers; /**< List of available parsers for different formats. */ QBasicMutex *loadFromFileMutex = new QBasicMutex(); /**< Mutex for single-file loading. */ QBasicMutex *reloadDatabaseMutex = new QBasicMutex(); /**< Mutex for reloading entire database. */ + bool initialLoadComplete = false; /**< Set after the first successful load. */ }; #endif // COCKATRICE_CARD_DATABASE_LOADER_H diff --git a/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.cpp b/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.cpp index f7e6bbfcf..00ed9377d 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.cpp @@ -30,6 +30,10 @@ CardSetPtr ICardDatabaseParser::internalAddSet(const QString &setName, newSet->setPriority(priority); sets.insert(setName, newSet); - emit addSet(newSet); + if (targetData) { + targetData->sets.insert(setName, newSet); + } else { + emit addSet(newSet); + } return newSet; } diff --git a/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.h b/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.h index a8eceab5a..6d9c91260 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.h +++ b/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.h @@ -2,6 +2,7 @@ #define CARDDATABASE_PARSER_H #include "../../card_info.h" +#include "../card_database_data.h" #include #include @@ -37,6 +38,15 @@ public: */ virtual void parseFile(QIODevice &device) = 0; + /** + * @brief Parses a database file into the given snapshot, without emitting + * any signals. Used for background loads that swap the snapshot into + * the live database once complete. + * @param device QIODevice representing the file content. + * @param data Target snapshot to populate. + */ + virtual void parseFileInto(QIODevice &device, CardDatabaseData &data) = 0; + /** * @brief Saves card and set data to a file. * @param _formats @@ -62,6 +72,21 @@ protected: static SetNameMap sets; ICardSetPriorityController *cardSetPriorityController; + /** + * @brief Snapshot the current parse is filling, or nullptr when emitting signals. + * + * This is an implicit-inheritance-via-member-variable pattern: parseFileInto() + * sets this before delegating to parseFile(), and loadCardsFromXml() / + * loadFormats() check it to decide between direct insertion and signal + * emission. Safe under the current model because loadFromFileMutex serialises + * all parser access and parseFile() is never re-entered. If a future change + * adds parallel parsing or a second parseFileInto() call within parseFile(), + * this pointer would race -- at that point refactor to pass CardDatabaseData* + * through the call chain instead. However, we are mostly RAM, not CPU bound on + * DB startup at this point so there's not much point to parallel parsing. + */ + CardDatabaseData *targetData = nullptr; + /** * @brief Internal helper to add a set to the global set cache. * @param setName Short set name. diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp index b6c3afc57..2dd5e91e9 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -79,6 +79,13 @@ void CockatriceXml3Parser::parseFile(QIODevice &device) } } +void CockatriceXml3Parser::parseFileInto(QIODevice &device, CardDatabaseData &data) +{ + targetData = &data; + parseFile(device); + targetData = nullptr; +} + void CockatriceXml3Parser::loadSetsFromXml(QXmlStreamReader &xml) { while (!xml.atEnd()) { @@ -222,25 +229,27 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) // behaviour. Without this check, disabling a set has no effect on v3 databases. if (set->getEnabled()) { PrintingInfo setInfo(set); + QVariantHash printingProps; if (attrs.hasAttribute("muId")) { - setInfo.setProperty("muid", attrs.value("muId").toString()); + printingProps.insert("muid", attrs.value("muId").toString()); } if (attrs.hasAttribute("uuId")) { - setInfo.setProperty("uuid", attrs.value("uuId").toString()); + printingProps.insert("uuid", attrs.value("uuId").toString()); } if (attrs.hasAttribute("picURL")) { - setInfo.setProperty("picurl", attrs.value("picURL").toString()); + printingProps.insert("picurl", attrs.value("picURL").toString()); } if (attrs.hasAttribute("num")) { - setInfo.setProperty("num", attrs.value("num").toString()); + printingProps.insert("num", attrs.value("num").toString()); } if (attrs.hasAttribute("rarity")) { - setInfo.setProperty("rarity", attrs.value("rarity").toString()); + printingProps.insert("rarity", attrs.value("rarity").toString()); } + setInfo.setProperties(printingProps); _sets[setName].append(setInfo); } // related cards @@ -299,7 +308,20 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) .upsideDownArt = upsideDown}; CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards, reverseRelatedCards, _sets, attributes); - emit addCard(newCard); + if (targetData) { + if (auto existing = targetData->cards.value(name)) { + for (const auto &printings : newCard->getSets()) { + for (const auto &printing : printings) { + existing->addToSet(printing.getSet(), printing); + } + } + } else { + targetData->cards.insert(name, newCard); + targetData->simpleNameCards.insert(newCard->getSimpleName(), newCard); + } + } else { + emit addCard(newCard); + } } } } diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.h b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.h index a01b705aa..83b736257 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.h +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.h @@ -43,6 +43,13 @@ public: */ void parseFile(QIODevice &device) override; + /** + * @brief Parse the XML database into the given snapshot. + * @param device Open QIODevice positioned at start of file. + * @param data Target snapshot to populate. + */ + void parseFileInto(QIODevice &device, CardDatabaseData &data) override; + /** * @brief Save sets and cards back to an XML3 file. */ diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp index c242425ab..129bae9bc 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -82,6 +82,13 @@ void CockatriceXml4Parser::parseFile(QIODevice &device) } } +void CockatriceXml4Parser::parseFileInto(QIODevice &device, CardDatabaseData &data) +{ + targetData = &data; + parseFile(device); + targetData = nullptr; +} + static QSharedPointer parseFormat(QXmlStreamReader &xml) { auto rulesPtr = FormatRulesPtr(new FormatRules()); @@ -187,7 +194,11 @@ void CockatriceXml4Parser::loadFormats(QXmlStreamReader &xml) if (xml.name().toString() == "format") { auto rulesPtr = parseFormat(xml); - emit addFormat(rulesPtr); + if (targetData) { + targetData->formats.insert(rulesPtr->formatName.toLower(), rulesPtr); + } else { + emit addFormat(rulesPtr); + } } } } @@ -304,13 +315,15 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) auto set = internalAddSet(setName); if (set->getEnabled()) { PrintingInfo printingInfo(set); + QVariantHash printingProps; for (QXmlStreamAttribute attr : attrs) { QString attrName = attr.name().toString(); if (attrName == "picURL") { attrName = "picurl"; } - printingInfo.setProperty(attrName, attr.value().toString()); + printingProps.insert(attrName, attr.value().toString()); } + printingInfo.setProperties(printingProps); // This is very much a hack and not the right place to // put this check, as it requires a reload of Cockatrice @@ -389,7 +402,22 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) .upsideDownArt = upsideDown}; CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards, reverseRelatedCards, _sets, attributes); - emit addCard(newCard); + if (targetData) { + // Mirror CardDatabase::addCard: if a card with this name already + // exists, merge the new printings into it instead of replacing. + if (auto existing = targetData->cards.value(name)) { + for (const auto &printings : newCard->getSets()) { + for (const auto &printing : printings) { + existing->addToSet(printing.getSet(), printing); + } + } + } else { + targetData->cards.insert(name, newCard); + targetData->simpleNameCards.insert(newCard->getSimpleName(), newCard); + } + } else { + emit addCard(newCard); + } } } } diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.h b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.h index 189e79535..92c967a0c 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.h +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.h @@ -47,6 +47,13 @@ public: */ void parseFile(QIODevice &device) override; + /** + * @brief Parse the XML database into the given snapshot. + * @param device Open QIODevice positioned at start of file. + * @param data Target snapshot to populate. + */ + void parseFileInto(QIODevice &device, CardDatabaseData &data) override; + /** * @brief Save sets and cards back to an XML4 file. */ diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp index 340998565..5df3680a0 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp @@ -2,19 +2,56 @@ #include "../set/card_set.h" +#include +#include + PrintingInfo::PrintingInfo(const CardSetPtr &_set) : set(_set) { } +void PrintingInfo::ensurePropertiesLoaded() const +{ + QMutexLocker lock(propertiesMutex.data()); + if (propertiesLoaded) { + return; + } + propertiesCache.clear(); + if (!propertiesBlob.isEmpty()) { + QDataStream in(propertiesBlob); + in.setVersion(QDataStream::Qt_6_4); + in >> propertiesCache; + } + propertiesLoaded = true; +} + +void PrintingInfo::setProperty(const QString &_name, const QString &_value) +{ + ensurePropertiesLoaded(); + if (propertiesCache.value(_name).toString() == _value) { + return; + } + propertiesCache.insert(_name, _value); + setProperties(propertiesCache); +} + +void PrintingInfo::setProperties(const QVariantHash &_props) +{ + ensurePropertiesLoaded(); + propertiesCache = _props; + QDataStream out(&propertiesBlob, QIODevice::WriteOnly); + out.setVersion(QDataStream::Qt_6_4); + out << propertiesCache; +} + /** * Gets the uuid property of the printing, or an empty string if the property isn't present */ QString PrintingInfo::getUuid() const { - return properties.value("uuid").toString(); + return getPropertiesHash().value("uuid").toString(); } QString PrintingInfo::getFlavorName() const { - return properties.value("flavorName").toString(); + return getPropertiesHash().value("flavorName").toString(); } \ No newline at end of file diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.h b/libcockatrice_card/libcockatrice/card/printing/printing_info.h index ad7b33654..adec2010c 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.h +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.h @@ -5,6 +5,8 @@ #include #include +#include +#include #include class PrintingInfo; @@ -51,7 +53,7 @@ public: */ bool operator==(const PrintingInfo &other) const { - return this->set == other.set && this->properties == other.properties; + return this->set == other.set && this->getPropertiesHash() == other.getPropertiesHash(); } /** @@ -61,12 +63,22 @@ public: */ bool isEmpty() const { - return set == nullptr && properties.isEmpty(); + return set == nullptr && getPropertiesHash().isEmpty(); } private: - CardSetPtr set; ///< The set this variation belongs to. - QVariantHash properties; ///< Key-value store for variation-specific attributes. + CardSetPtr set; ///< The set this variation belongs to. + + // Properties are stored as a pre-serialized blob (cheap to load) and the + // QVariantHash is materialized on first query. This avoids constructing + // thousands of QVariants per card at database-load time. + mutable QByteArray propertiesBlob; ///< Serialized properties (load form). + mutable QVariantHash propertiesCache; ///< Materialized properties (query form). + mutable bool propertiesLoaded = false; ///< Whether propertiesCache is valid. + mutable QSharedPointer propertiesMutex = + QSharedPointer::create(); ///< Guards lazy materialization. + + void ensurePropertiesLoaded() const; public: /** @@ -86,7 +98,13 @@ public: */ [[nodiscard]] QStringList getProperties() const { - return properties.keys(); + return getPropertiesHash().keys(); + } + + [[nodiscard]] const QVariantHash &getPropertiesHash() const + { + ensurePropertiesLoaded(); + return propertiesCache; } /** @@ -97,7 +115,7 @@ public: */ [[nodiscard]] QString getProperty(const QString &propertyName) const { - return properties.value(propertyName).toString(); + return getPropertiesHash().value(propertyName).toString(); } /** @@ -108,9 +126,21 @@ public: * @param _name The name of the property. * @param _value The string value to assign. */ - void setProperty(const QString &_name, const QString &_value) + void setProperty(const QString &_name, const QString &_value); + void setProperties(const QVariantHash &_props); + + /** + * @brief Stores the pre-serialized properties blob and marks the materialized + * cache as invalid. Used by the binary cache reader to avoid building a + * QVariantHash at load time. + * @param _blob The serialized properties (as written by the cache writer). + */ + void setPropertiesBlob(QByteArray _blob) const { - properties.insert(_name, _value); + QMutexLocker lock(propertiesMutex.data()); + propertiesBlob = std::move(_blob); + propertiesLoaded = false; + propertiesCache.clear(); } /** diff --git a/libcockatrice_card/libcockatrice/card/set/card_set.cpp b/libcockatrice_card/libcockatrice/card/set/card_set.cpp index 6eea220bb..92060f6c4 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.cpp +++ b/libcockatrice_card/libcockatrice/card/set/card_set.cpp @@ -42,9 +42,10 @@ QString CardSet::getCorrectedShortName() const void CardSet::loadSetOptions() { - sortKey = priorityController->getSortKey(shortName); - enabled = priorityController->isEnabled(shortName); - isknown = priorityController->isKnown(shortName); + const ICardSetPriorityController::SetOptions options = priorityController->getSetOptions(shortName); + sortKey = options.sortKey; + enabled = options.enabled; + isknown = options.isKnown; } void CardSet::setSortKey(unsigned int _sortKey) diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h index 9559967af..36e748006 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h @@ -13,6 +13,22 @@ public: bool enabled; }; + /** + * @brief Bundled per-set priority options, returned in a single call. + * + * Reading these individually (getSortKey/isEnabled/isKnown) is cheap for the + * noop controller but extremely expensive when backed by QSettings, which + * re-opens and re-parses the whole .ini on every access. Callers that need + * more than one option for the same set (notably CardSet construction during + * database load) should use getSetOptions() to avoid that cost. + */ + struct SetOptions + { + unsigned int sortKey = 0; + bool enabled = false; + bool isKnown = false; + }; + virtual ~ICardSetPriorityController() = default; virtual void setSortKey(QString shortName, unsigned int sortKey) = 0; @@ -23,6 +39,13 @@ public: virtual bool isEnabled(QString shortName) const = 0; virtual bool isKnown(QString shortName) const = 0; + /** + * @brief Returns the bundled priority options for a set in a single call. + * @param shortName The set's short name. + * @return The sort key, enabled and known flags for the set. + */ + virtual SetOptions getSetOptions(QString shortName) const = 0; + virtual void saveSets(const QVector &data) = 0; }; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_set_priority_controller.h b/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_set_priority_controller.h index 188e2ced9..8cc2011bd 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_set_priority_controller.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_set_priority_controller.h @@ -29,6 +29,11 @@ public: return true; } + SetOptions getSetOptions(QString /* shortName */) const override + { + return {0, true, true}; + } + void saveSets(const QVector & /* data */) override { } diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp index 253dcd134..7b248a982 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp @@ -12,6 +12,7 @@ CardDatabaseModel::CardDatabaseModel(CardDatabase *_db, bool _showOnlyCardsFromE connect(db, &CardDatabase::cardRemoved, this, &CardDatabaseModel::cardRemoved); connect(db, &CardDatabase::cardDatabaseEnabledSetsChanged, this, &CardDatabaseModel::cardDatabaseEnabledSetsChanged); + connect(db, &CardDatabase::cardDatabaseReset, this, &CardDatabaseModel::resetCardList); cardDatabaseEnabledSetsChanged(); } @@ -151,3 +152,18 @@ void CardDatabaseModel::cardRemoved(CardInfoPtr card) cardList.removeAt(row); endRemoveRows(); } + +void CardDatabaseModel::resetCardList() +{ + beginResetModel(); + cardList.clear(); + cardListSet.clear(); + for (const CardInfoPtr &card : db->getCardList()) { + if (checkCardHasAtLeastOneEnabledSet(card)) { + cardList.append(card); + cardListSet.insert(card); + connect(card.data(), &CardInfo::cardInfoChanged, this, &CardDatabaseModel::cardInfoChanged); + } + } + endResetModel(); +} diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_model.h index 8655389d7..ad84de6f4 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_model.h @@ -57,6 +57,7 @@ private slots: void cardRemoved(CardInfoPtr card); void cardInfoChanged(const CardInfoPtr &card); void cardDatabaseEnabledSetsChanged(); + void resetCardList(); }; #endif diff --git a/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp b/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp index 4887afd2f..219c79c34 100644 --- a/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp @@ -1,5 +1,7 @@ #include "card_database_settings.h" +#include + CardDatabaseSettings::CardDatabaseSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "cardDatabase.ini", QString(), QString(), parent) { @@ -7,32 +9,73 @@ CardDatabaseSettings::CardDatabaseSettings(const QString &settingPath, QObject * void CardDatabaseSettings::setSortKey(QString shortName, unsigned int sortKey) { - setValue(sortKey, "sortkey", "sets", std::move(shortName)); + setValue(sortKey, "sortkey", "sets", shortName); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + setOptionsCache[shortName].sortKey = sortKey; } void CardDatabaseSettings::setEnabled(QString shortName, bool enabled) { - setValue(enabled, "enabled", "sets", std::move(shortName)); + setValue(enabled, "enabled", "sets", shortName); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + setOptionsCache[shortName].enabled = enabled; } void CardDatabaseSettings::setIsKnown(QString shortName, bool isknown) { - setValue(isknown, "isknown", "sets", std::move(shortName)); + setValue(isknown, "isknown", "sets", shortName); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + setOptionsCache[shortName].isKnown = isknown; +} + +void CardDatabaseSettings::ensureSetOptionsLoaded() const +{ + if (setOptionsLoaded) { + return; + } + QSettings settings(getSettings()); + settings.beginGroup("sets"); + const QStringList groups = settings.childGroups(); + for (const QString &group : groups) { + settings.beginGroup(group); + SetOptions &o = setOptionsCache[group]; + o.sortKey = settings.value("sortkey", 0).toUInt(); + o.enabled = settings.value("enabled", true).toBool(); + o.isKnown = settings.value("isknown", true).toBool(); + settings.endGroup(); + } + setOptionsLoaded = true; } unsigned int CardDatabaseSettings::getSortKey(QString shortName) const { - return getValue("sortkey", "sets", std::move(shortName)).toUInt(); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + return setOptionsCache.value(shortName).sortKey; } bool CardDatabaseSettings::isEnabled(QString shortName) const { - return getValue("enabled", "sets", std::move(shortName)).toBool(); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + return setOptionsCache.value(shortName).enabled; } bool CardDatabaseSettings::isKnown(QString shortName) const { - return getValue("isknown", "sets", std::move(shortName)).toBool(); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + return setOptionsCache.value(shortName).isKnown; +} + +ICardSetPriorityController::SetOptions CardDatabaseSettings::getSetOptions(QString shortName) const +{ + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + return setOptionsCache.value(shortName); } void CardDatabaseSettings::saveSets(const QVector &data) @@ -47,4 +90,12 @@ void CardDatabaseSettings::saveSets(const QVector +#include #include class CardDatabaseSettings : public SettingsManager, public ICardSetPriorityController @@ -26,10 +28,23 @@ public: bool isEnabled(QString shortName) const override; bool isKnown(QString shortName) const override; + SetOptions getSetOptions(QString shortName) const override; + void saveSets(const QVector &data) override; private: explicit CardDatabaseSettings(const QString &settingPath, QObject *parent = nullptr); + + // In-memory cache of set priority options. QSettings re-opens and re-parses + // the whole .ini on every access, which is catastrophic when CardSet + // construction calls getSortKey/isEnabled/isKnown once per set during the + // database load (thousands of file parses). The cache is populated from a + // single QSettings session and kept consistent with the writes below. + mutable QMutex setOptionsMutex; + mutable QHash setOptionsCache; + mutable bool setOptionsLoaded = false; + + void ensureSetOptionsLoaded() const; }; #endif // CARDDATABASESETTINGS_H diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index bace63508..4eeb6ecc4 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -291,12 +291,13 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList // per-set properties PrintingInfo printingInfo = PrintingInfo(currentSet); + QVariantHash printingProps; for (auto i = setInfoProperties.cbegin(), end = setInfoProperties.cend(); i != end; ++i) { QString mtgjsonProperty = i.key(); QString xmlPropertyName = i.value(); QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); if (!propertyValue.isEmpty()) { - printingInfo.setProperty(xmlPropertyName, propertyValue); + printingProps.insert(xmlPropertyName, propertyValue); } } @@ -304,7 +305,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList QString faceFlavorName = getStringPropertyFromMap(card, "faceFlavorName"); QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getStringPropertyFromMap(card, "flavorName"); if (!flavorName.isEmpty()) { - printingInfo.setProperty("flavorName", flavorName); + printingProps.insert("flavorName", flavorName); } // Identifiers @@ -313,10 +314,12 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList QString xmlPropertyName = i.value(); QString propertyValue = getStringPropertyFromMap(card.value("identifiers").toMap(), mtgjsonProperty); if (!propertyValue.isEmpty()) { - printingInfo.setProperty(xmlPropertyName, propertyValue); + printingProps.insert(xmlPropertyName, propertyValue); } } + printingInfo.setProperties(printingProps); + QString numComponent; const QString numProperty = printingInfo.getProperty("num"); const QChar lastChar = numProperty.isEmpty() ? QChar() : numProperty.back(); diff --git a/tests/carddatabase/CMakeLists.txt b/tests/carddatabase/CMakeLists.txt index 987e23cd5..811cebc1d 100644 --- a/tests/carddatabase/CMakeLists.txt +++ b/tests/carddatabase/CMakeLists.txt @@ -21,6 +21,24 @@ target_link_libraries( add_test(NAME carddatabase_test COMMAND carddatabase_test) +# ------------------------ +# Load Benchmark (manual, not run in CI) +# ------------------------ +add_executable(load_benchmark ${MOCKS_SOURCES} ${VERSION_STRING_CPP} load_benchmark.cpp mocks.cpp) + +target_link_libraries( + load_benchmark + PRIVATE libcockatrice_card + PRIVATE libcockatrice_models + PRIVATE Threads::Threads + PRIVATE ${GTEST_BOTH_LIBRARIES} + PRIVATE ${TEST_QT_MODULES} +) + +if(NOT GTEST_FOUND) + add_dependencies(load_benchmark gtest) +endif() + # ------------------------ # Filter String Test # (guard must match the condition for libcockatrice_filters in the root CMakeLists.txt) diff --git a/tests/carddatabase/load_benchmark.cpp b/tests/carddatabase/load_benchmark.cpp new file mode 100644 index 000000000..8e9e80e4d --- /dev/null +++ b/tests/carddatabase/load_benchmark.cpp @@ -0,0 +1,235 @@ +/* + * Standalone benchmark for card database loading. + * + * Measures wall-clock cost and model churn: + * - Cold load (XML -> write cache) + * - Warm load (read cache) + * + * Run: + * load_benchmark [--carddb PATH] [--tokens PATH] [--spoilers PATH] [--custom DIR] + * + * All arguments are optional; they default to the XDG Cockatrice data location + * (~/.local/share/Cockatrice/Cockatrice/...). + */ + +#include "mocks.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static QString defaultXdgPath(const QString &file) +{ + QString base = QDir::homePath() + "/.local/share/Cockatrice/Cockatrice/"; + return base + file; +} + +class CliCardDatabasePathProvider : public ICardDatabasePathProvider +{ +public: + QString cardDb; + QString tokens; + QString spoilers; + QString custom; + + QString getCardDatabasePath() const override + { + return cardDb; + } + QString getTokenDatabasePath() const override + { + return tokens; + } + QString getSpoilerCardDatabasePath() const override + { + return spoilers; + } + QString getCustomCardDatabasePath() const override + { + return custom; + } +}; + +namespace +{ +struct ChurnCounters +{ + qint64 cardAddedEmissions = 0; + qint64 cardRemovedEmissions = 0; + qint64 modelRowsInserted = 0; + qint64 modelRowsRemoved = 0; + qint64 loadingFinishedSignals = 0; + qint64 databaseResetSignals = 0; + qint64 modelResets = 0; +}; + +struct RunResult +{ + qint64 elapsedMs = 0; + qint64 cardCount = 0; + qint64 setCount = 0; + LoadStatus status = NotLoaded; + ChurnCounters churn; + qint64 propsQueried = 0; + qint64 propTotal = 0; +}; + +void deleteCache(const QString &cardDbPath) +{ + const QString cachePath = cardDbPath + ".cache"; + if (QFile::exists(cachePath)) { + QFile::remove(cachePath); + } +} + +RunResult runLoad(CardDatabase *db, ChurnCounters &counters) +{ + const ChurnCounters before = counters; + + QElapsedTimer timer; + timer.start(); + + db->loadCardDatabases(); + + RunResult result; + result.elapsedMs = timer.elapsed(); + result.cardCount = db->getCardList().size(); + result.setCount = db->getSetList().size(); + result.status = db->getLoadStatus(); + + result.churn.cardAddedEmissions = counters.cardAddedEmissions - before.cardAddedEmissions; + result.churn.cardRemovedEmissions = counters.cardRemovedEmissions - before.cardRemovedEmissions; + result.churn.modelRowsInserted = counters.modelRowsInserted - before.modelRowsInserted; + result.churn.modelRowsRemoved = counters.modelRowsRemoved - before.modelRowsRemoved; + result.churn.loadingFinishedSignals = counters.loadingFinishedSignals - before.loadingFinishedSignals; + result.churn.databaseResetSignals = counters.databaseResetSignals - before.databaseResetSignals; + result.churn.modelResets = counters.modelResets - before.modelResets; + + for (const CardInfoPtr &card : db->getCardList()) { + result.propTotal += card->getPropertiesHash().size(); + if (!card->getProperty("colors").isEmpty()) { + ++result.propsQueried; + } + } + + return result; +} + +void printResult(const QString &label, const RunResult &r) +{ + qInfo() << "----------------------------------------"; + qInfo() << label; + qInfo() << " Load wall-clock time :" << r.elapsedMs << "ms"; + qInfo() << " Cards loaded :" << r.cardCount; + qInfo() << " Sets loaded :" << r.setCount; + qInfo() << " Load status :" << r.status; + qInfo() << " Properties queried :" << r.propsQueried << "/" << r.cardCount + << " cards, total props:" << r.propTotal; + qInfo() << " CHURN during load:"; + qInfo() << " cardAdded emissions :" << r.churn.cardAddedEmissions; + qInfo() << " cardRemoved emissions :" << r.churn.cardRemovedEmissions; + qInfo() << " model rowsInserted :" << r.churn.modelRowsInserted; + qInfo() << " model rowsRemoved :" << r.churn.modelRowsRemoved; + qInfo() << " loadingFinished signals :" << r.churn.loadingFinishedSignals; + qInfo() << " databaseReset signals :" << r.churn.databaseResetSignals; + qInfo() << " model resets (batch) :" << r.churn.modelResets; +} +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + + QStringList args = app.arguments(); + CliCardDatabasePathProvider pathProvider; + pathProvider.cardDb = defaultXdgPath("cards.xml"); + pathProvider.tokens = defaultXdgPath("tokens.xml"); + pathProvider.spoilers = defaultXdgPath("spoiler.xml"); + pathProvider.custom = defaultXdgPath("customsets/"); + + for (int i = 1; i < args.size(); ++i) { + const QString &a = args.at(i); + if (a == "--carddb" && i + 1 < args.size()) { + pathProvider.cardDb = args.at(++i); + } else if (a == "--tokens" && i + 1 < args.size()) { + pathProvider.tokens = args.at(++i); + } else if (a == "--spoilers" && i + 1 < args.size()) { + pathProvider.spoilers = args.at(++i); + } else if (a == "--custom" && i + 1 < args.size()) { + pathProvider.custom = args.at(++i); + } else if (a == "--help" || a == "-h") { + qInfo() << "Usage: load_benchmark [--carddb PATH] [--tokens PATH] [--spoilers PATH] [--custom DIR]"; + return 0; + } + } + + QLoggingCategory::setFilterRules("card_database.loading=true\n" + "card_database.loading.success=true\n"); + + qInfo() << "=== Card Database Load Benchmark ==="; + qInfo() << "carddb :" << pathProvider.cardDb; + qInfo() << "tokens :" << pathProvider.tokens; + qInfo() << "spoilers :" << pathProvider.spoilers; + qInfo() << "custom :" << pathProvider.custom; + qInfo() << ""; + + ChurnCounters counters; + + auto runScenario = [&](bool deleteCacheBefore) -> RunResult { + if (deleteCacheBefore) { + deleteCache(pathProvider.cardDb); + } + + // Fresh database each scenario so signals don't accumulate across scenarios + // for the churn counters, but we keep the same counters object to get a + // running total if desired. + CardDatabase db(nullptr, new NoopCardPreferenceProvider(), &pathProvider, new NoopCardSetPriorityController()); + + // Re-wire churn counters for this db instance + QObject::connect(&db, &CardDatabase::cardAdded, &app, + [&counters](const CardInfoPtr &) { ++counters.cardAddedEmissions; }); + QObject::connect(&db, &CardDatabase::cardRemoved, &app, + [&counters](CardInfoPtr) { ++counters.cardRemovedEmissions; }); + QObject::connect(&db, &CardDatabase::cardDatabaseLoadingFinished, &app, + [&counters]() { ++counters.loadingFinishedSignals; }); + QObject::connect(&db, &CardDatabase::cardDatabaseReset, &app, + [&counters]() { ++counters.databaseResetSignals; }); + + CardDatabaseModel model(&db, false); + QObject::connect(&model, &QAbstractItemModel::rowsInserted, &app, + [&counters](const QModelIndex &, int, int) { ++counters.modelRowsInserted; }); + QObject::connect(&model, &QAbstractItemModel::rowsRemoved, &app, + [&counters](const QModelIndex &, int, int) { ++counters.modelRowsRemoved; }); + QObject::connect(&model, &QAbstractItemModel::modelReset, &app, [&counters]() { ++counters.modelResets; }); + + return runLoad(&db, counters); + }; + + // ---- Scenario 1: Cold load (XML -> write cache) ---- + printResult("Scenario 1: Cold load (XML -> cache)", runScenario(true)); + + // ---- Scenario 2: Warm load (read cache) ---- + printResult("Scenario 2: Warm load (read cache)", runScenario(false)); + + // Teardown + deleteCache(pathProvider.cardDb); + + qInfo() << "----------------------------------------"; + qInfo() << "Done."; + + return 0; +}