Compare commits

...

8 commits

Author SHA1 Message Date
Lukas Brübach
8052676b29 tests: add manual picture loader benchmark against the real card hosts
(cherry picked from commit 588d71d3b9a8278ab98b847802908f322c1035b7)
2026-09-13 03:36:33 +02:00
Lukas Brübach
77ded1da97 [PictureLoader] Fix worker thread shutdown and cross-thread cache clearing
clearNetworkCache() ran directly on the UI thread while the worker thread
owned the disk cache and redirect cache, racing cache reads/writes. Make it
a worker-thread slot invoked via a blocking queued call when the thread is
running, so the 'Cached card pictures have been reset.' message is truthful.

The worker thread was also never quit()/wait()ed: both destructors only
deleteLater'd their objects, so Qt warned 'QThread: Destroyed while thread
is still running' and leaked a running loop at exit. Wire the worker's
finished() signal to its own deleteLater() (canonical worker-object
pattern), add shutdownThread() to stop the loop, and let CardPictureLoader
destroy the QThread only after wait() has returned.
2026-09-13 03:36:32 +02:00
Lukas Brübach
7b82ca08da [PictureLoader] Let unlocked hosts skip dispatch pacing; adjust limits per URL
Two refinements to the per-host request caps:

- Unlocked hosts (UNLIMITED_HOST_QUOTA, e.g. cards.scryfall.io) no longer
  wait on the 100ms dispatch pacing or consume the global per-second quota.
  dispatchQueuedRequest fires their queued requests back-to-back, bounded
  only by their 429 backoff window and Qt's per-host connection pool, so
  an unthrottled CDN is not artificially slowed.
- The deck editor download settings page replaces the static grid of one
  spinbox per known host with an "Adjust Rate Limit" toolbar action on the
  URL list. It picks the host out of the selected URL and clamps the entry
  against the developer cap table (including for user-added URLs).

Also fixes a review finding: resetRequestQuota could write the
UNLIMITED_HOST_QUOTA sentinel (-1) into the sustained per-host quota when a
host became unlocked mid-run, permanently poisoning its allowance. Stale
entries for unlocked hosts are now dropped, and the per-second seed is
clamped against the effective ceiling so a lowered limit applies immediately.
2026-09-13 03:36:32 +02:00
Lukas Brübach
da307a82b3 [PictureLoader] Add user-configurable per-host request caps
Picture downloads were throttled to a uniform 10 requests/second per host
with no way to tune a specific server. A rate-limited API host (Scryfall
caps at 10 req/s) can trip 429s during bursts, and CDN hosts with no rate
limit were throttled needlessly.

Introduce developer-owned per-host caps that users can only ever lower,
never raise, exposed in the download settings page:
- DownloadSettings::DEVELOPER_HOST_CAPS sets the ceiling per host
  (api.scryfall.com 9, cards.scryfall.io unlimited, others 10).
- A new hostRequestLimits setting stores user overrides in downloads.ini;
  clampHostRequestLimit() bounds them to [1, devCap] so a user can reduce
  api.scryfall.com to 5 but never raise it above 9.
- The picture worker seeds, halves on 429, and recovers its sustained
  per-host allowance against the effective ceiling instead of the global
  maximum, and skips per-host accounting entirely for unlocked hosts
  (cards.scryfall.io) while global pacing and 429 backoff still apply.
- The deck editor settings page gains one spinbox per known host, each
  clamped to its developer cap.
2026-09-13 03:36:32 +02:00
Lukas Brübach
e3820c3f34 [PictureLoader] Seed per-host allowances on demand and skip hosts in 429 backoff
The quota reset re-filled every host's remaining allowance to a full
MAX_REQUESTS_PER_SEC as soon as the queue had a request for it. A server
that was just rate limited could therefore be hammered again at full speed
immediately after (or even during) recovery.

Only seed a host's allowance the first time it is dispatched in the
current second, seeded from its reduced sustained quota, and skip hosts
still inside their 429 backoff window entirely. This makes the pacing
commit's burst-free behavior hold per host too, instead of just smoothing
the global aggregate.
2026-09-13 03:36:32 +02:00
Lukas Brübach
1c6ee62393 [PictureLoader] Pace requests and run the throttle timers on the worker thread
Previously the whole backed-up queue was drained in a burst as soon as a
request was enqueued, sending up to 10 requests back-to-back and then
immediately re-filling the quota one second later. That hard-bursts a
rate-limited API like Scryfall's (10 requests/second) into a 30 second
lockout.

Introduce a pacing timer that dispatches a single queue entry every
100 ms, so the per-second allowance is used smoothly instead of in spikes,
and keep the quota timer at 1 second. Also fix both timers' thread
affinity: they are QTimer value members and so are not QObject children,
meaning moveToThread() on the worker left them on the main thread while
the slot code started them from the picture thread, which was a no-op that
also warned. They are moved to the worker thread explicitly and started
lazily from there.
2026-09-13 03:36:31 +02:00
BruebachL
fd82b140a8
[PictureLoader] Serve cached pictures from the disk cache instead of re-fetching them (#7284)
Some checks failed
CodeQL / Analyze (cpp) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
Build Desktop / Configure (push) Has been cancelled
Build Docker / Servatrice (arm) (push) Has been cancelled
Build Docker / Servatrice (x86) (push) Has been cancelled
Build Desktop / Debian 13 (push) Has been cancelled
Build Desktop / Debian 12 (push) Has been cancelled
Build Desktop / Fedora 44 (push) Has been cancelled
Build Desktop / Fedora 43 (push) Has been cancelled
Build Desktop / Servatrice_Debian 12 (push) Has been cancelled
Build Desktop / Ubuntu 26.04 (push) Has been cancelled
Build Desktop / Ubuntu 24.04 (push) Has been cancelled
Build Desktop / Arch (push) Has been cancelled
Build Desktop / macOS 13 Intel (push) Has been cancelled
Build Desktop / macOS 14 (push) Has been cancelled
Build Desktop / macOS 15 (push) Has been cancelled
Build Desktop / macOS 26 Debug (push) Has been cancelled
Build Desktop / Windows 10 (push) Has been cancelled
Build Docker / Publish multi-platform Servatrice image (push) Has been cancelled
With picture downloads enabled, requests were issued with AlwaysNetwork
cache control, which per Qt never consults the disk cache. A picture that
had already been downloaded was therefore fetched from the network again
on every session start, with the queue bypass letting those re-fetches
skip the rate limit entirely.

Treat the network cache as the intent of the 'Network Cache' storage
method suggests: if the URL is already cached, serve it with AlwaysCache
(no network, no quota); only a genuine miss goes to the network, and only
when downloads are enabled. Cache hits skip the queue for free since they
never consume the per-second request allowance.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-13 03:36:29 +02:00
tooomm
5d025ca0bd
Use capitalized app names (#7255)
Some checks are pending
CodeQL / Analyze (cpp) (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
Build Desktop / Configure (push) Waiting to run
Build Desktop / Debian 13 (push) Blocked by required conditions
Build Desktop / Debian 12 (push) Blocked by required conditions
Build Desktop / Fedora 44 (push) Blocked by required conditions
Build Desktop / Fedora 43 (push) Blocked by required conditions
Build Desktop / Servatrice_Debian 12 (push) Blocked by required conditions
Build Desktop / Ubuntu 26.04 (push) Blocked by required conditions
Build Desktop / Ubuntu 24.04 (push) Blocked by required conditions
Build Desktop / Arch (push) Blocked by required conditions
Build Desktop / macOS 13 Intel (push) Blocked by required conditions
Build Desktop / macOS 14 (push) Blocked by required conditions
Build Desktop / macOS 15 (push) Blocked by required conditions
Build Desktop / macOS 26 Debug (push) Blocked by required conditions
Build Desktop / Windows 10 (push) Blocked by required conditions
Build Docker / Servatrice (arm) (push) Waiting to run
Build Docker / Servatrice (x86) (push) Waiting to run
Build Docker / Publish multi-platform Servatrice image (push) Blocked by required conditions
* use capitalized app name

* update urls

* app description

* Update main.cpp
2026-09-12 17:30:46 +02:00
27 changed files with 1117 additions and 55 deletions

View file

@ -94,7 +94,7 @@ QStringMap &SoundEngine::getAvailableThemes()
QDir dir;
availableThemes.clear();
// load themes from user profile dir
// Load themes from user profile dir
dir.setPath(SettingsCache::instance().getDataPath() + "/sounds");
@ -104,7 +104,7 @@ QStringMap &SoundEngine::getAvailableThemes()
}
}
// load themes from cockatrice system dir
// Load themes from Cockatrice system dir
dir.setPath(qApp->applicationDirPath() +
#ifdef Q_OS_MAC
"/../Resources/sounds"

View file

@ -11,6 +11,7 @@
#include <QDirIterator>
#include <QFileInfo>
#include <QMainWindow>
#include <QMetaObject>
#include <QMovie>
#include <QNetworkRequest>
#include <QPainter>
@ -55,7 +56,14 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr)
CardPictureLoader::~CardPictureLoader()
{
worker->deleteLater();
if (worker) {
// Capture the thread first: shutdownThread() blocks until the worker has been freed by the
// finished() -> deleteLater chain, after which the worker pointer must not be dereferenced.
QThread *pictureLoaderThread = worker->workerThread();
worker->shutdownThread();
worker = nullptr;
delete pictureLoaderThread;
}
}
void CardPictureLoader::getCardBackPixmap(QPixmap &pixmap, QSize size)
@ -295,7 +303,15 @@ void CardPictureLoader::clearPixmapCache()
void CardPictureLoader::clearNetworkCache()
{
getInstance().worker->clearNetworkCache();
auto &worker = *getInstance().worker;
// The disk cache and redirect cache are owned by the worker thread; clearing them from the
// UI thread would race with the worker's cache reads/writes. Block until the worker thread
// has executed the clear so the "Cached card pictures have been reset." message is truthful.
if (worker.isRunning()) {
QMetaObject::invokeMethod(&worker, "clearNetworkCache", Qt::BlockingQueuedConnection);
} else {
worker.clearNetworkCache();
}
}
void CardPictureLoader::cacheCardPixmaps(const QList<ExactCard> &cards)

View file

@ -16,13 +16,16 @@
#include <utility>
#include <version_string.h>
static constexpr int MAX_REQUESTS_PER_SEC = 10;
static constexpr int MIN_HOST_QUOTA = 1; ///< Floor for the per-host request allowance
static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering
static constexpr int MAX_REQUESTS_PER_SEC = DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT;
static constexpr int MIN_HOST_QUOTA = DownloadSettings::MIN_HOST_REQUEST_LIMIT;
static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering
static constexpr int DISPATCH_INTERVAL_MS = 100; ///< Pacing between individual network requests
static constexpr qint64 QUOTA_RESET_INTERVAL_MS = 1000; ///< Interval at which the request quota resets
CardPictureLoaderWorker::CardPictureLoaderWorker()
: QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()),
requestQuota(MAX_REQUESTS_PER_SEC)
requestQuota(MAX_REQUESTS_PER_SEC),
hostRequestLimits(SettingsCache::instance().downloads().getHostRequestLimits())
{
networkManager = new QNetworkAccessManager(this);
// We need a timeout to ensure requests don't hang indefinitely in case of
@ -57,20 +60,54 @@ CardPictureLoaderWorker::CardPictureLoaderWorker()
localLoader = new CardPictureLoaderLocal(this);
pictureLoaderThread = new QThread;
// The worker object frees itself once its thread finishes, so no event loop is left
// running and the QThread is never destroyed while still executing.
connect(pictureLoaderThread, &QThread::finished, this, &QObject::deleteLater);
pictureLoaderThread->start(QThread::LowPriority);
moveToThread(pictureLoaderThread);
// QTimer value members are not QObject children, so moveToThread on the worker doesn't move
// them. They must live in the worker's thread to be started from the slot code that runs there.
requestTimer.moveToThread(pictureLoaderThread);
dispatchTimer.moveToThread(pictureLoaderThread);
connect(this, &CardPictureLoaderWorker::imageLoadEnqueued, this, &CardPictureLoaderWorker::handleImageLoadEnqueued);
connect(&requestTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::resetRequestQuota);
requestTimer.setInterval(1000);
requestTimer.start();
requestTimer.setInterval(static_cast<int>(QUOTA_RESET_INTERVAL_MS));
connect(&dispatchTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::dispatchQueuedRequest);
dispatchTimer.setInterval(DISPATCH_INTERVAL_MS);
connect(&SettingsCache::instance().downloads(), &DownloadSettings::hostRequestLimitsChanged, this,
[this] { hostRequestLimits = SettingsCache::instance().downloads().getHostRequestLimits(); });
}
CardPictureLoaderWorker::~CardPictureLoaderWorker()
{
saveRedirectCache();
pictureLoaderThread->deleteLater();
}
void CardPictureLoaderWorker::shutdownThread()
{
// The finished() -> deleteLater chain (wired in the constructor) frees this worker as soon as
// its event loop exits, so nothing - not even a member read - may run once wait() returns.
// QThread::quit() and QThread::wait() are thread-safe and may be called from the owning thread.
QThread *thread = pictureLoaderThread;
if (thread) {
thread->quit();
thread->wait();
}
}
QThread *CardPictureLoaderWorker::workerThread() const
{
return pictureLoaderThread;
}
bool CardPictureLoaderWorker::isRunning() const
{
return pictureLoaderThread != nullptr && pictureLoaderThread->isRunning();
}
void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker)
@ -84,8 +121,8 @@ void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWor
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE &&
cache->metaData(url).isValid()) {
// If we hit a cached url, we get to make the request for free, since it won't contribute towards the
// rate-limit
// A request that will be served from the disk cache never touches the network and therefore
// doesn't use up any of the rate limit, so it gets to skip the queue.
makeRequest(url, worker);
return;
}
@ -107,10 +144,13 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/,/*;q=0.8");
bool useNetworkCache =
!picDownload && static_cast<CardPictureLoaderCacheMethod::CacheMethod>(
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE;
// Cached entries are served straight from the disk cache even when picture downloads are
// enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a
// genuine cache miss goes to the network, and only when downloads are enabled.
bool useNetworkCache = static_cast<CardPictureLoaderCacheMethod::CacheMethod>(
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE &&
(cache->metaData(url).isValid() || !picDownload);
req.setAttribute(QNetworkRequest::CacheLoadControlAttribute,
useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork);
@ -126,17 +166,24 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
void CardPictureLoaderWorker::resetRequestQuota()
{
requestQuota = MAX_REQUESTS_PER_SEC;
// Allowances are seeded per host on demand in processSingleRequest(), so a
// rate-limited host never gets a fresh full quota mid-second.
hostQuotaRemaining.clear();
QDateTime now = QDateTime::currentDateTime();
for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end(); ++it) {
if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) {
it.value() = qMin(MAX_REQUESTS_PER_SEC, it.value() + 1);
for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end();) {
// An unlocked host has no per-host allowance; drop any stale entry instead of
// recovering it towards the UNLIMITED_HOST_QUOTA sentinel, which would poison it.
if (hostAllowanceCeiling(it.key()) == DownloadSettings::UNLIMITED_HOST_QUOTA) {
it = hostRequestQuota.erase(it);
continue;
}
}
for (const auto &request : requestLoadQueue) {
const QString host = request.first.host();
hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC));
if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) {
// Recover towards the host's effective allowance ceiling, which may be
// lowered by the user's per-host request limits.
it.value() = qMin(hostAllowanceCeiling(it.key()), it.value() + 1);
}
++it;
}
processQueuedRequests();
@ -144,17 +191,71 @@ void CardPictureLoaderWorker::resetRequestQuota()
void CardPictureLoaderWorker::processQueuedRequests()
{
while (requestQuota > 0 && processSingleRequest()) {
if (requestLoadQueue.isEmpty()) {
dispatchTimer.stop();
return;
}
// Start lazily from the worker's own thread: QTimer must be started in the thread it lives in.
if (!requestTimer.isActive()) {
requestTimer.start();
}
dispatchTimer.start();
}
void CardPictureLoaderWorker::dispatchQueuedRequest()
{
if (requestLoadQueue.isEmpty()) {
dispatchTimer.stop();
return;
}
// Unlocked hosts (developer cap UNLIMITED_HOST_QUOTA) skip the dispatch pacing and the
// global per-second quota: dispatch every queued request for them back-to-back, bounded
// only by their 429 backoff window and Qt's per-host connection pool.
QDateTime now = QDateTime::currentDateTime();
for (int i = 0; i < requestLoadQueue.size();) {
const auto &request = requestLoadQueue.at(i);
const QString host = request.first.host();
if (hostAllowanceCeiling(host) == DownloadSettings::UNLIMITED_HOST_QUOTA &&
!CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) {
makeRequest(request.first, request.second);
requestLoadQueue.removeAt(i);
} else {
++i;
}
}
if (requestLoadQueue.isEmpty() || requestQuota <= 0) {
dispatchTimer.stop();
return;
}
if (processSingleRequest()) {
--requestQuota;
} else {
// No queued host currently has allowance left in this second; wait for the quota reset.
dispatchTimer.stop();
}
}
bool CardPictureLoaderWorker::processSingleRequest()
{
QDateTime now = QDateTime::currentDateTime();
for (int i = 0; i < requestLoadQueue.size(); ++i) {
const auto &request = requestLoadQueue.at(i);
QString host = request.first.host();
int allowance = hostQuotaRemaining.value(host, MAX_REQUESTS_PER_SEC);
const QString host = request.first.host();
// Don't dispatch requests to a host that is currently in its 429 backoff.
if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) {
continue;
}
const int ceiling = hostAllowanceCeiling(host);
// Seed the allowance only now, so a host that was rate limited last second
// doesn't get a fresh full quota the moment it is queried mid-second. Clamp
// against the ceiling so a lowered user cap applies from this second onward.
if (!hostQuotaRemaining.contains(host)) {
hostQuotaRemaining.insert(host, qMin(ceiling, hostRequestQuota.value(host, ceiling)));
}
int allowance = hostQuotaRemaining.value(host);
if (allowance > 0) {
hostQuotaRemaining.insert(host, allowance - 1);
makeRequest(request.first, request.second);
@ -165,9 +266,25 @@ bool CardPictureLoaderWorker::processSingleRequest()
return false;
}
int CardPictureLoaderWorker::hostAllowanceCeiling(const QString &host) const
{
const int devCap = DownloadSettings::getDeveloperHostCaps().value(host, MAX_REQUESTS_PER_SEC);
if (devCap == DownloadSettings::UNLIMITED_HOST_QUOTA && !hostRequestLimits.contains(host)) {
return DownloadSettings::UNLIMITED_HOST_QUOTA;
}
const int requested = hostRequestLimits.value(host, devCap);
return SettingsCache::instance().downloads().clampHostRequestLimit(host, requested);
}
void CardPictureLoaderWorker::onHostRateLimited(const QString &host)
{
hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC) / 2));
const int ceiling = hostAllowanceCeiling(host);
if (ceiling == DownloadSettings::UNLIMITED_HOST_QUOTA) {
// Unlocked hosts have no per-host allowance to halve; the shared backoff
// window tracked by the rate limiter still paces them.
return;
}
hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, ceiling) / 2));
hostLast429.insert(host, QDateTime::currentDateTime());
}

View file

@ -74,10 +74,37 @@ public:
*/
void onHostRateLimited(const QString &host);
/** @brief Clears the network cache and redirect cache. */
void clearNetworkCache();
/**
* @brief Stops the worker thread and releases it.
*
* Called from the owning thread (CardPictureLoader) on its way out. QThread::quit() posts an
* exit request to the worker's event loop and QThread::wait() blocks until the loop has
* returned and the thread finished. Only QThread members are touched here, so this method is
* safe to call from the owning thread. The worker object itself is freed by the finished() ->
* deleteLater chain (see the constructor); the QThread object is deleted afterwards by the
* owner (CardPictureLoader::~CardPictureLoader), not by this method.
*/
void shutdownThread();
/** @return Whether the worker's thread is currently running. */
bool isRunning() const;
/**
* @brief Returns the worker's QThread.
* @return The worker thread
*
* Only meaningful while the worker object is alive; capture it before calling shutdownThread().
*/
QThread *workerThread() const;
public slots:
/**
* @brief Clears the network cache and redirect cache.
*
* Runs on the worker thread; invoke it via a queued call when coming from another thread,
* since both caches are owned by the worker thread.
*/
void clearNetworkCache();
/**
* @brief Makes a network request for the given URL using the specified worker.
* @param url URL to load
@ -89,6 +116,9 @@ public slots:
/** @brief Processes all queued requests respecting the request quota. */
void processQueuedRequests();
/** @brief Chooses a request from the queue and starts it, respecting the quota and pacing. */
void dispatchQueuedRequest();
/**
* @brief Processes a single queued request.
* @return true if a request was processed, false if queue is empty.
@ -120,13 +150,23 @@ private:
int requestQuota; ///< Remaining requests allowed per second
QTimer requestTimer; ///< Timer to reset the request quota
QTimer dispatchTimer; ///< Timer pacing individual network requests
QHash<QString, int> hostRequestQuota; ///< Sustained per-host request allowance
QHash<QString, int> hostRequestLimits; ///< User-set per-host request allowances
QHash<QString, int> hostQuotaRemaining; ///< Per-host allowance left in the current second
QHash<QString, QDateTime> hostLast429; ///< When each host was last rate limited
CardPictureLoaderLocal *localLoader; ///< Loader for local images
QSet<QString> currentlyLoading; ///< Deduplication: contains pixmapCacheKey currently being loaded
/**
* @brief Effective per-host allowance ceiling for a host.
* @param host The host to look up
* @return The allowance ceiling in requests/second, or DownloadSettings::UNLIMITED_HOST_QUOTA
* when the developer unlocked the host and no user limit is set for it.
*/
[[nodiscard]] int hostAllowanceCeiling(const QString &host) const;
/** @brief Returns cached redirect URL for the given original URL, if available. */
[[nodiscard]] QUrl getCachedRedirect(const QUrl &originalUrl) const;

View file

@ -22,6 +22,11 @@ static const QStringList MD5_BLACKLIST = {
"fbc7d763c08771c260b39e2115414eeb" // Current card back hash
};
ServerRateLimiter &CardPictureLoaderWorkerWork::rateLimiter()
{
return s_rateLimiter;
}
CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad)
: QObject(nullptr), cardToDownload(CardPictureToLoad(toLoad)),
picDownload(SettingsCache::instance().downloads().getPicDownload())

View file

@ -43,6 +43,9 @@ public:
CardPictureToLoad cardToDownload; ///< The card and associated URLs to try downloading
/** @brief Shared per-server 429 backoff state. */
static ServerRateLimiter &rateLimiter();
public slots:
/**
* @brief Handles a finished network reply for the card image.

View file

@ -17,7 +17,7 @@ enum Format
PlainText,
/**
* This is cockatrice's native deck file format, and supports deck metadata such as banner cards and tags.
* This is Cockatrice's native deck file format, and supports deck metadata such as banner cards and tags.
* Stored as .cod files.
*/
Cockatrice

View file

@ -50,7 +50,7 @@ DeckLoader::loadFromFile(const QString &fileName, DeckFileFormat::Format fmt, bo
result = deckList.loadFromFile_Native(&file);
if (!result) {
qCInfo(DeckLoaderLog) << "Failed to load " << fileName
<< "as cockatrice format; retrying as plain format";
<< "as Cockatrice format; retrying as plain format";
file.seek(0);
result = deckList.loadFromFile_Plain(&file, CardNameNormalizer());
fmt = DeckFileFormat::PlainText;

View file

@ -131,7 +131,7 @@ public:
static void printDeckList(QPrinter *printer, const DeckList &deckList);
/**
* Converts the given deck's file to the cockatrice file format.
* Converts the given deck's file to the Cockatrice file format.
* Uses the lastLoadInfo in the LoadedDeck to determine the current name of the file and where to save to.
* @param deck The deck to convert. Should have valid lastLoadInfo. Will update the lastLoadInfo.
* @return Whether the conversion succeeded.

View file

@ -224,7 +224,7 @@ QStringMap &ThemeManager::getAvailableThemes()
}
}
// load themes from cockatrice system dir
// Load themes from Cockatrice system dir
dir.setPath(systemThemesBasePath());
for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) {

View file

@ -478,13 +478,13 @@ void DlgSettings::closeEvent(QCloseEvent *event)
case Invalid:
loadErrorMessage = tr("Your card database is invalid.\n\n"
"Cockatrice may not function correctly with an invalid database\n\n"
"You may need to rerun oracle to update your card database.\n\n"
"You may need to rerun Oracle to update your card database.\n\n"
"Would you like to change your database location setting?");
break;
case VersionTooOld:
loadErrorMessage = tr("Your card database version is too old.\n\n"
"This can cause problems loading card information or images\n\n"
"Usually this can be fixed by rerunning oracle to to update your card database.\n\n"
"Usually this can be fixed by rerunning Oracle to to update your card database.\n\n"
"Would you like to change your database location setting?");
break;
case NotLoaded:

View file

@ -11,11 +11,14 @@
#include <QLineEdit>
#include <QMessageBox>
#include <QToolBar>
#include <QUrl>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/settings/personal_settings.h>
#include <libcockatrice/utility/macros.h>
static constexpr int UNLOCKED_HOST_LIMIT_MAX = 50; ///< Upper bound for rate limits on hosts unlocked by the developer
DeckEditorSettingsPage::DeckEditorSettingsPage()
{
picDownloadCheckBox.setChecked(SettingsCache::instance().downloads().getPicDownload());
@ -65,11 +68,16 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement")));
connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL);
aRateLimit = new QAction(this);
aRateLimit->setIcon(themePixmap(QStringLiteral("icons/cogwheel")));
connect(aRateLimit, &QAction::triggered, this, &DeckEditorSettingsPage::actAdjustRateLimit);
auto *urlToolBar = new QToolBar;
urlToolBar->setOrientation(Qt::Vertical);
urlToolBar->addAction(aAdd);
urlToolBar->addAction(aRemove);
urlToolBar->addAction(aEdit);
urlToolBar->addAction(aRateLimit);
urlToolBar->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding);
auto *urlListLayout = new QHBoxLayout;
@ -164,6 +172,54 @@ void DeckEditorSettingsPage::storeSettings()
SettingsCache::instance().downloads().setDownloadUrls(downloadUrls);
}
void DeckEditorSettingsPage::actAdjustRateLimit()
{
if (urlList->currentItem() == nullptr) {
QMessageBox::information(this, tr("Adjust Rate Limit"), tr("Select a URL in the list first."));
return;
}
const QString host = QUrl(urlList->currentItem()->text()).host();
if (host.isEmpty()) {
QMessageBox::information(this, tr("Adjust Rate Limit"), tr("The selected URL does not have a valid host."));
return;
}
const QHash<QString, int> &devCaps = DownloadSettings::getDeveloperHostCaps();
const QHash<QString, int> currentLimits = SettingsCache::instance().downloads().getHostRequestLimits();
const int devCap = devCaps.value(host, DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT);
const bool unlocked = devCap == DownloadSettings::UNLIMITED_HOST_QUOTA;
bool ok = false;
int minimum;
int maximum;
int defaultValue;
if (unlocked) {
minimum = 0; // 0 means "unlimited"
maximum = UNLOCKED_HOST_LIMIT_MAX;
defaultValue = currentLimits.value(host, 0);
} else {
minimum = DownloadSettings::MIN_HOST_REQUEST_LIMIT;
maximum = devCap;
defaultValue = currentLimits.value(host, devCap);
}
const int value = QInputDialog::getInt(this, tr("Adjust Rate Limit for %1").arg(host),
tr("Requests per second (developer maximum is %1):").arg(maximum),
defaultValue, minimum, maximum, 1, &ok);
if (!ok) {
return;
}
QHash<QString, int> limits = currentLimits;
if (unlocked ? value == 0 : value == devCap) {
limits.remove(host);
} else {
limits.insert(host, value);
}
SettingsCache::instance().downloads().setHostRequestLimits(limits);
}
void DeckEditorSettingsPage::urlListChanged(const QModelIndex &, int, int, const QModelIndex &, int)
{
storeSettings();
@ -244,4 +300,5 @@ void DeckEditorSettingsPage::retranslateUi()
aAdd->setText(tr("Add New URL"));
aEdit->setText(tr("Edit URL"));
aRemove->setText(tr("Remove URL"));
}
aRateLimit->setText(tr("Adjust Rate Limit"));
}

View file

@ -27,6 +27,7 @@ private slots:
void actAddURL();
void actRemoveURL();
void actEditURL();
void actAdjustRateLimit();
void resetDownloadedURLsButtonClicked();
private:
@ -34,7 +35,7 @@ private:
QLabel urlLinkLabel;
QCheckBox picDownloadCheckBox;
QListWidget *urlList;
QAction *aAdd, *aEdit, *aRemove;
QAction *aAdd, *aEdit, *aRemove, *aRateLimit;
QCheckBox mcDownloadSpoilersCheckBox;
QLabel msDownloadSpoilersLabel;
QGroupBox *mpGeneralGroupBox;

View file

@ -92,8 +92,8 @@
#include <libcockatrice/settings/updates_settings.h>
#define GITHUB_PAGES_URL "https://cockatrice.github.io"
#define GITHUB_CONTRIBUTORS_URL "https://github.com/Cockatrice/Cockatrice/graphs/contributors?type=c"
#define GITHUB_CONTRIBUTE_URL "https://github.com/Cockatrice/Cockatrice#cockatrice"
#define GITHUB_CONTRIBUTORS_URL "https://github.com/Cockatrice/Cockatrice/graphs/contributors"
#define GITHUB_CONTRIBUTE_URL "https://github.com/Cockatrice/Cockatrice#"
#define GITHUB_TRANSIFEX_TRANSLATORS_URL "https://github.com/Cockatrice/Cockatrice/wiki/Translator-Hall-of-Fame"
#define GITHUB_TRANSLATOR_FAQ_URL "https://github.com/Cockatrice/Cockatrice/wiki/Translation-FAQ"
#define GITHUB_ISSUES_URL "https://github.com/Cockatrice/Cockatrice/issues"
@ -1050,7 +1050,7 @@ void MainWindow::createCardUpdateProcess(bool background)
if (dir.exists(binaryName)) {
updaterCmd = dir.absoluteFilePath(binaryName);
} else { // try and find the directory oracle is stored in the build directory
} else { // try and find the directory Oracle is stored in the build directory
QDir findLocalDir(dir);
findLocalDir.cdUp();
findLocalDir.cd(getCardUpdaterBinaryName());

View file

@ -231,7 +231,7 @@ int main(int argc, char *argv[])
// These values are only used by the settings loader/saver
// Wrong or outdated values are kept to not break things
QCoreApplication::setOrganizationName("Cockatrice");
QCoreApplication::setOrganizationDomain("cockatrice.de");
QCoreApplication::setOrganizationDomain("cockatrice.github.io");
QCoreApplication::setApplicationName("Cockatrice");
QCoreApplication::setApplicationVersion(VERSION_STRING);
@ -250,7 +250,7 @@ int main(int argc, char *argv[])
// Command-line parser
QCommandLineParser parser;
parser.setApplicationDescription("Cockatrice");
parser.setApplicationDescription("Cockatrice Client");
parser.addHelpOption();
parser.addVersionOption();
@ -350,8 +350,8 @@ int main(int argc, char *argv[])
qCInfo(MainLog) << "MainWindow constructor finished";
ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice")));
// set name of the app desktop file; used by wayland to load the window icon
QGuiApplication::setDesktopFileName("cockatrice");
// Set name of the app desktop file; used by wayland to load the window icon
QGuiApplication::setDesktopFileName("Cockatrice");
SettingsCache::instance().network().setClientID(generateClientID());

View file

@ -9,6 +9,22 @@ const QStringList DownloadSettings::DEFAULT_DOWNLOAD_URLS = {
"https://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=!set:muid!&type=card",
"https://gatherer.wizards.com/Handlers/Image.ashx?name=!name!&type=card"};
// Developer-set ceilings for the per-host request allowance. Users may lower a host's
// allowance via the download settings, but can never raise it above these values. Hosts
// not listed default to DEFAULT_HOST_REQUEST_LIMIT. A cap of UNLIMITED_HOST_QUOTA marks a
// host that is never throttled per host and skips the dispatch pacing (429 backoff still applies).
const QHash<QString, int> DownloadSettings::DEVELOPER_HOST_CAPS = {
// The Scryfall API enforces 10 requests/second; stay one under so a burst can't trip 429s.
{"api.scryfall.com", 9},
// The Scryfall image CDN has no documented per-client rate limit.
{"cards.scryfall.io", UNLIMITED_HOST_QUOTA},
};
const QHash<QString, int> &DownloadSettings::getDeveloperHostCaps()
{
return DEVELOPER_HOST_CAPS;
}
DownloadSettings::DownloadSettings(const QString &settingPath, QObject *parent = nullptr)
: SettingsManager(settingPath + "downloads.ini", "downloads", QString(), parent)
{
@ -50,3 +66,32 @@ void DownloadSettings::setDownloadSpoilerStatus(bool _spoilerStatus)
setValue(_spoilerStatus, "downloadSpoilers");
emit downloadSpoilerStatusChanged();
}
QHash<QString, int> DownloadSettings::getHostRequestLimits() const
{
const QVariantMap stored = getValue("hostRequestLimits").toMap();
QHash<QString, int> hostRequestLimits;
for (auto it = stored.cbegin(); it != stored.cend(); ++it) {
hostRequestLimits.insert(it.key(), it.value().toInt());
}
return hostRequestLimits;
}
void DownloadSettings::setHostRequestLimits(const QHash<QString, int> &hostRequestLimits)
{
QVariantMap stored;
for (auto it = hostRequestLimits.cbegin(); it != hostRequestLimits.cend(); ++it) {
stored.insert(it.key(), it.value());
}
setValue(stored, "hostRequestLimits");
emit hostRequestLimitsChanged();
}
int DownloadSettings::clampHostRequestLimit(const QString &host, int requested) const
{
const int devCap = DEVELOPER_HOST_CAPS.value(host, DEFAULT_HOST_REQUEST_LIMIT);
if (devCap == UNLIMITED_HOST_QUOTA) {
return qMax(MIN_HOST_REQUEST_LIMIT, requested);
}
return qBound(MIN_HOST_REQUEST_LIMIT, requested, devCap);
}

View file

@ -9,14 +9,34 @@
#include "settings_manager.h"
#include <QHash>
class DownloadSettings : public SettingsManager
{
Q_OBJECT
friend class SettingsCache;
static const QStringList DEFAULT_DOWNLOAD_URLS;
static const QHash<QString, int> DEVELOPER_HOST_CAPS;
public:
/** @brief Per-host request allowance (requests/second) when no developer cap applies. */
static constexpr int DEFAULT_HOST_REQUEST_LIMIT = 10;
/** @brief Floor for any per-host request allowance. */
static constexpr int MIN_HOST_REQUEST_LIMIT = 1;
/** @brief Developer cap marking a host as never throttled per host or by the dispatch pacing. */
static constexpr int UNLIMITED_HOST_QUOTA = -1;
/**
* @brief Developer-set per-host allowance ceilings (requests/second), keyed by host.
*
* Hosts not present default to DEFAULT_HOST_REQUEST_LIMIT. An entry of
* UNLIMITED_HOST_QUOTA marks a host that users may still lower, but that is never
* throttled per host by default. Users can never raise a host's allowance above its
* developer cap.
*/
static const QHash<QString, int> &getDeveloperHostCaps();
explicit DownloadSettings(const QString &, QObject *);
QStringList getAllURLs() const;
@ -27,9 +47,23 @@ public:
[[nodiscard]] bool getDownloadSpoilersStatus() const;
void setDownloadSpoilerStatus(bool _spoilerStatus);
/** @brief User-set per-host request allowances (requests/second). Missing hosts use the developer default. */
QHash<QString, int> getHostRequestLimits() const;
void setHostRequestLimits(const QHash<QString, int> &hostRequestLimits);
/**
* @brief Clamps the user's requested allowance for a host against its developer cap.
* @param host The host to clamp for
* @param requested The user-requested allowance in requests/second
* @return The effective allowance. Users may lower a host's allowance but never raise it
* above the developer cap; hosts with UNLIMITED_HOST_QUOTA have no upper bound.
*/
[[nodiscard]] int clampHostRequestLimit(const QString &host, int requested) const;
signals:
void picDownloadChanged();
void downloadSpoilerStatusChanged();
void hostRequestLimitsChanged();
};
#endif // COCKATRICE_DOWNLOADSETTINGS_H

View file

@ -50,8 +50,8 @@ int main(int argc, char *argv[])
QApplication app(argc, argv);
QCoreApplication::setOrganizationName("Cockatrice");
QCoreApplication::setOrganizationDomain("cockatrice");
// this can't be changed, as it influences the default save path for cards.xml
QCoreApplication::setOrganizationDomain("Cockatrice");
// This can't be changed, as it influences the default save path for cards.xml
QCoreApplication::setApplicationName("Cockatrice");
// If the program is opened with the -s flag, it will only do spoilers. Otherwise it will do MTGJSON/Tokens
@ -83,7 +83,7 @@ int main(int argc, char *argv[])
QIcon icon("theme:appicon.svg");
wizard.setWindowIcon(icon);
// set name of the app desktop file; used by wayland to load the window icon
QGuiApplication::setDesktopFileName("oracle");
QGuiApplication::setDesktopFileName("Oracle");
wizard.show();

View file

@ -796,7 +796,7 @@ void SaveSetsPage::retranslateUi()
{
setTitle(tr("Sets imported"));
if (wizard()->downloadedPlainXml) {
setSubTitle(tr("A cockatrice database file of %1 MB has been downloaded.")
setSubTitle(tr("A Cockatrice card database file of %1 MB has been downloaded.")
.arg(qRound(wizard()->xmlData.size() / 1000000.0)));
} else {
setSubTitle(tr("The following sets have been found:"));

View file

@ -20,7 +20,7 @@
* Note that "...enters tapped unless..." returns false.
*
* @param name The name of the card
* @param text The oracle text of the card
* @param text The Oracle text of the card
*/
bool parseCipt(const QString &name, const QString &text)
{

View file

@ -54,7 +54,7 @@ using ScanProgressCallback = std::function<void(qsizetype bytesRead, qsizetype t
* @brief Scans a full MTGJSON document without materializing the JSON tree.
*
* Splits the top-level "data" object into per-set byte ranges and reads each
* set's metadata directly from the raw bytes. The oracle importer can then
* set's metadata directly from the raw bytes. The Oracle importer can then
* parse one set at a time during import, keeping peak memory far below a single
* QJsonDocument::fromJson() over the whole file.
*

View file

@ -86,9 +86,10 @@ bool Servatrice_DatabaseInterface::openDatabase()
<< dbversion << "to version" << expectedversion;
return false;
} else if (dbversion > expectedversion) {
qCCritical(DatabaseInterfaceLog) << poolStr << "Error opening database: the database schema version"
<< dbversion << "is too new, you need to update servatrice"
<< "(this servatrice actually uses version" << expectedversion << ")";
qCCritical(DatabaseInterfaceLog)
<< poolStr << "Error opening database: the database schema version" << dbversion
<< "is too new, you need to update Servatrice" << "(Currently running Servatrice actually uses version"
<< expectedversion << ")";
return false;
}
} else {

View file

@ -132,3 +132,9 @@ add_subdirectory(loading_from_clipboard)
add_subdirectory(movecard_tests)
add_subdirectory(oracle)
add_subdirectory(settings)
# picture_loader_benchmark links libcockatrice_settings, which only exists when
# a client/UI-capable target is being built.
if(WITH_ORACLE OR WITH_CLIENT)
add_subdirectory(picture_loader_benchmark)
endif()

View file

@ -0,0 +1,26 @@
# Manual benchmark hitting the real card image hosts. Not registered with
# add_test(): it requires a cards.xml, touches the network for minutes at a
# time, and needs network access. Build it explicitly and run by hand.
add_executable(
picture_loader_benchmark_test
${VERSION_STRING_CPP}
../../cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp
../../cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp
../../cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp
../../cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp
../../cockatrice/src/client/settings/cache_settings.h
picture_loader_benchmark.cpp
settings_cache_mock.cpp
)
target_include_directories(picture_loader_benchmark_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
target_link_libraries(
picture_loader_benchmark_test
libcockatrice_card
libcockatrice_settings
libcockatrice_interfaces
libcockatrice_utility
Threads::Threads
${TEST_QT_MODULES}
)

View file

@ -0,0 +1,479 @@
/*
* Picture loader benchmark / regression suite against the real card image hosts.
*
* Deliberately not registered with ctest: it hits live Scryfall / Gatherer
* endpoints at ~10 requests per second and takes minutes. Run it by hand.
*
* picture_loader_benchmark_test --carddb /path/to/cards.xml [options]
*
* Modes
* -----
* default : for every URL template in the configured download list (or for each
* --url given), load #count pictures twice: once cold (network) and
* once cached (served from the QNetworkDiskCache). Both passes must
* load every card with zero failures. The cached pass must complete
* well under the cold time, which is the regression gate for serving
* cached pictures instead of re-fetching them. The cold pass must stay
* above a pacing lower bound, the regression gate for burst-free
* request throttling.
* --stress: two CardPictureLoaderWorker instances loading the same cards
* concurrently against one host (~20 req/s aggregate), which forces
* real 429 responses. Both workers must still complete 100% of their
* cards via the shared backoff logic.
*/
#include "client/settings/cache_settings.h"
#include "interface/card_picture_loader/card_picture_loader_worker.h"
#include "interface/card_picture_loader/card_picture_to_load.h"
#include <QCoreApplication>
#include <QDir>
#include <QElapsedTimer>
#include <QEventLoop>
#include <QFile>
#include <QImage>
#include <QList>
#include <QMessageLogContext>
#include <QMetaType>
#include <QMutex>
#include <QTemporaryDir>
#include <QTimer>
#include <QUrl>
#include <cstdio>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/printing/exact_card.h>
#include <libcockatrice/card/printing/printing_info.h>
#include <libcockatrice/interfaces/interface_card_database_path_provider.h>
#include <libcockatrice/interfaces/noop_card_preference_provider.h>
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
#include <libcockatrice/settings/download_settings.h>
#include <optional>
class BenchmarkCardDatabasePathProvider : public ICardDatabasePathProvider
{
public:
BenchmarkCardDatabasePathProvider(QString _cardsXml, QString _customSetsDir)
: cardsXml(std::move(_cardsXml)), customSetsDir(std::move(_customSetsDir))
{
}
QString getCardDatabasePath() const override
{
return cardsXml;
}
QString getCustomCardDatabasePath() const override
{
return customSetsDir;
}
QString getTokenDatabasePath() const override
{
return QString();
}
QString getSpoilerCardDatabasePath() const override
{
return QString();
}
private:
QString cardsXml;
QString customSetsDir;
};
struct PassResult
{
int enqueued = 0;
int finished = 0;
int failed = 0;
qint64 elapsedMs = 0;
QStringList failedCards;
};
static QList<ExactCard> selectCardsForTemplate(CardDatabase &db, const QString &urlTemplate, int maxCards)
{
QList<ExactCard> selected;
const QList<CardInfoPtr> cards = db.getCardList().values();
for (const CardInfoPtr &card : cards) {
if (selected.size() >= maxCards) {
break;
}
const SetToPrintingsMap &sets = card->getSets();
if (sets.isEmpty()) {
continue;
}
const QList<PrintingInfo> printings = sets.first();
if (printings.isEmpty()) {
continue;
}
const ExactCard cardToLoad(card, printings.first());
if (CardPictureToLoad(cardToLoad).transformUrl(urlTemplate).isEmpty()) {
continue;
}
selected.append(cardToLoad);
}
return selected;
}
static PassResult runPass(CardPictureLoaderWorker *worker, const QList<ExactCard> &cards, int timeoutMs)
{
PassResult result;
result.enqueued = cards.size();
QEventLoop loop;
QTimer watchdog;
watchdog.setSingleShot(true);
watchdog.setInterval(timeoutMs);
QObject::connect(&watchdog, &QTimer::timeout, &loop, &QEventLoop::quit);
QElapsedTimer clock;
QObject::connect(worker, &CardPictureLoaderWorker::imageLoaded, &loop,
[&](const ExactCard &card, const QImage &image) {
++result.finished;
if (image.isNull()) {
++result.failed;
if (result.failedCards.size() < 10) {
result.failedCards.append(card.getName());
}
}
if (result.finished >= result.enqueued) {
loop.quit();
}
});
clock.start();
for (const ExactCard &card : cards) {
worker->enqueueImageLoad(card);
}
watchdog.start();
loop.exec();
result.elapsedMs = clock.elapsed();
return result;
}
struct StressResult
{
PassResult a;
PassResult b;
int http429Count = 0;
};
struct LogCounters
{
int http429 = 0;
QMutex mutex;
};
static LogCounters *s_activeCounters = nullptr;
static QtMessageHandler s_previousMessageHandler = nullptr;
static void stressLogHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg)
{
if (s_activeCounters && msg.contains(QStringLiteral("Too many requests from"))) {
QMutexLocker locker(&s_activeCounters->mutex);
++s_activeCounters->http429;
}
if (s_previousMessageHandler) {
s_previousMessageHandler(type, context, msg);
}
}
static StressResult runStress(CardPictureLoaderWorker *workerA,
CardPictureLoaderWorker *workerB,
const QList<ExactCard> &cards,
int timeoutMs)
{
StressResult result;
result.a.enqueued = cards.size();
result.b.enqueued = cards.size();
int completed = 0;
QMutex completedMutex;
QEventLoop loop;
QTimer watchdog;
watchdog.setSingleShot(true);
watchdog.setInterval(timeoutMs);
QObject::connect(&watchdog, &QTimer::timeout, &loop, &QEventLoop::quit);
const auto finishOne = [&](PassResult &pass, const ExactCard &card, const QImage &image) {
++pass.finished;
if (image.isNull()) {
++pass.failed;
if (pass.failedCards.size() < 10) {
pass.failedCards.append(card.getName());
}
}
QMutexLocker locker(&completedMutex);
++completed;
if (completed >= result.a.enqueued + result.b.enqueued) {
loop.quit();
}
};
QElapsedTimer clock;
QObject::connect(workerA, &CardPictureLoaderWorker::imageLoaded, &loop,
[&](const ExactCard &card, const QImage &image) { finishOne(result.a, card, image); });
QObject::connect(workerB, &CardPictureLoaderWorker::imageLoaded, &loop,
[&](const ExactCard &card, const QImage &image) { finishOne(result.b, card, image); });
// Count 429 responses as seen by the shared rate limiter.
LogCounters counters;
s_activeCounters = &counters;
s_previousMessageHandler = qInstallMessageHandler(stressLogHandler);
clock.start();
for (const ExactCard &card : cards) {
workerA->enqueueImageLoad(card);
workerB->enqueueImageLoad(card);
}
watchdog.start();
loop.exec();
const qint64 elapsedMs = clock.elapsed();
result.a.elapsedMs = elapsedMs;
result.b.elapsedMs = elapsedMs;
result.http429Count = counters.http429;
s_activeCounters = nullptr;
qInstallMessageHandler(s_previousMessageHandler);
return result;
}
static QString formatDuration(qint64 ms)
{
return QStringLiteral("%1.%2 s").arg(ms / 1000).arg((ms % 1000) / 100);
}
static bool likelyRedirects(const QString &urlTemplate)
{
return urlTemplate.contains(QStringLiteral("api.scryfall.com"));
}
static QString hostOf(const QString &urlTemplate)
{
return QUrl(urlTemplate).host();
}
static void printUsage()
{
std::printf("usage: picture_loader_benchmark_test --carddb <path> [options]\n"
"\n"
"Loads card pictures from the real configured hosts (not a mock server) and\n"
"verifies the picture loader's pacing / cache 429 behavior.\n"
"\n"
"options:\n"
" --carddb <path> cards.xml to load card data from (required)\n"
" --count <N> cards to load per template (default 300)\n"
" --url <template> test only this URL template; repeatable\n"
" --stress run the two-worker concurrency stress instead\n"
" --stress-url <t> template used by --stress (default: scryfall uuid)\n"
" --timeout-min <N> per-pass watchdog in minutes (default auto)\n"
" --cache-dir <dir> reuse this directory as the sandbox root; non-empty\n"
" cache skips the cold pacing lower bound\n"
" --help show this help\n");
}
int main(int argc, char **argv)
{
QString cardDbArg;
QStringList explicitUrls;
int count = 300;
bool stress = false;
QString stressUrl(QStringLiteral("https://api.scryfall.com/cards/!set:uuid!?format=image"));
QString cacheDirArg;
std::optional<int> timeoutMin;
QStringList args;
for (int i = 1; i < argc; ++i) {
args.append(QString::fromLocal8Bit(argv[i]));
}
for (int i = 0; i < args.size(); ++i) {
const QString &arg = args.at(i);
const auto value = [&]() -> QString { return i + 1 < args.size() ? args.at(++i) : QString(); };
if (arg == QLatin1String("--carddb")) {
cardDbArg = value();
} else if (arg == QLatin1String("--count")) {
count = value().toInt();
} else if (arg == QLatin1String("--url")) {
explicitUrls.append(value());
} else if (arg == QLatin1String("--stress")) {
stress = true;
} else if (arg == QLatin1String("--stress-url")) {
stressUrl = value();
} else if (arg == QLatin1String("--timeout-min")) {
timeoutMin = value().toInt();
} else if (arg == QLatin1String("--cache-dir")) {
cacheDirArg = value();
} else if (arg == QLatin1String("--help") || arg == QLatin1String("-h")) {
printUsage();
return 0;
} else {
std::fprintf(stderr, "unknown argument: %s\n", qPrintable(arg));
printUsage();
return 2;
}
}
if (cardDbArg.isEmpty()) {
std::fprintf(stderr, "error: --carddb is required (a cards.xml generated by Oracle)\n");
printUsage();
return 2;
}
if (count <= 0) {
std::fprintf(stderr, "error: --count must be positive\n");
return 2;
}
QCoreApplication app(argc, argv);
app.setApplicationName(QStringLiteral("Cockatrice"));
app.setOrganizationName(QStringLiteral("Cockatrice"));
app.setApplicationVersion(QStringLiteral("9.0.0-benchmark"));
// The ExactCard argument of imageLoaded crosses threads via a queued connection.
qRegisterMetaType<ExactCard>();
QTemporaryDir sandbox;
const QString rootDir = cacheDirArg.isEmpty() ? sandbox.path() : cacheDirArg;
QDir().mkpath(rootDir);
QDir().mkpath(rootDir + "/config");
QDir().mkpath(rootDir + "/data");
QDir().mkpath(rootDir + "/cache");
#ifdef Q_OS_UNIX
// Redirect every QStandardPaths lookup (and therefore SettingsCache paths)
// into the sandbox so the benchmark never touches user config or caches.
qputenv("XDG_CONFIG_HOME", (rootDir + "/config").toUtf8());
qputenv("XDG_DATA_HOME", (rootDir + "/data").toUtf8());
qputenv("XDG_CACHE_HOME", (rootDir + "/cache").toUtf8());
#endif
const bool warmStart = QDir(rootDir + "/cache/Cockatrice/downloaded")
.entryList(QDir::Files | QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)
.size() > 0;
// Copied into the sandbox so the loader's binary cache ("cards.xml.cache")
// is written next to it instead of next to the user's file, and so a
// --cache-dir rerun can pick it up again.
const QString dataPath = rootDir + "/data/Cockatrice";
QDir().mkpath(dataPath);
const QString cardsXml = dataPath + "/cards.xml";
if (!QFile::exists(cardsXml)) {
if (!QFile::copy(cardDbArg, cardsXml)) {
std::fprintf(stderr, "error: could not copy %s to sandbox\n", qPrintable(cardDbArg));
return 2;
}
}
const QString customSetsDir = dataPath + "/customsets";
QDir().mkpath(customSetsDir);
auto *prefs = new NoopCardPreferenceProvider();
auto *priorityController = new NoopCardSetPriorityController();
auto *pathProvider = new BenchmarkCardDatabasePathProvider(cardsXml, customSetsDir);
CardDatabase db(nullptr, prefs, pathProvider, priorityController);
db.loadCardDatabases();
if (db.getLoadStatus() != Ok || db.getCardList().isEmpty()) {
std::fprintf(stderr, "error: failed to load card database from %s\n", qPrintable(cardsXml));
return 1;
}
const int availableCards = db.getCardList().size();
const QStringList urlsToTest =
explicitUrls.isEmpty() ? SettingsCache::instance().downloads().getAllURLs() : explicitUrls;
if (stress) {
const QList<ExactCard> cards = selectCardsForTemplate(db, stressUrl, count);
if (cards.isEmpty()) {
std::fprintf(stderr, "error: no cards satisfy template %s\n", qPrintable(stressUrl));
return 1;
}
SettingsCache::instance().downloads().setDownloadUrls({stressUrl});
std::printf("=== STRESS: 2 workers x %d cards, host %s ===\n", static_cast<int>(cards.size()),
qPrintable(hostOf(stressUrl)));
auto *workerA = new CardPictureLoaderWorker();
auto *workerB = new CardPictureLoaderWorker();
const int timeoutMs = (timeoutMin.has_value() ? timeoutMin.value() : 15) * 60 * 1000;
const StressResult result = runStress(workerA, workerB, cards, timeoutMs);
const bool completeA = result.a.finished >= result.a.enqueued;
const bool completeB = result.b.finished >= result.b.enqueued;
const bool zeroFailures = result.a.failed == 0 && result.b.failed == 0;
const bool pass = completeA && completeB && zeroFailures;
std::printf("worker A: %d/%d loaded, %d failed (%.2f images/s)\n", result.a.finished, result.a.enqueued,
result.a.failed,
result.a.elapsedMs > 0
? static_cast<double>(result.a.finished) * 1000.0 / static_cast<double>(result.a.elapsedMs)
: 0.0);
std::printf("worker B: %d/%d loaded, %d failed (%.2f images/s)\n", result.b.finished, result.b.enqueued,
result.b.failed,
result.b.elapsedMs > 0
? static_cast<double>(result.b.finished) * 1000.0 / static_cast<double>(result.b.elapsedMs)
: 0.0);
std::printf("429 responses observed: %d\n", result.http429Count);
std::printf("elapsed: %s\n", qPrintable(formatDuration(result.a.elapsedMs)));
if (result.a.failed > 0) {
std::printf(" worker A failures: %s\n", qPrintable(result.a.failedCards.join(QStringLiteral(", "))));
}
if (result.b.failed > 0) {
std::printf(" worker B failures: %s\n", qPrintable(result.b.failedCards.join(QStringLiteral(", "))));
}
std::printf("RESULT: %s\n", pass ? "PASS" : "FAIL");
return pass ? 0 : 1;
}
bool allPass = true;
std::printf("=== PICTURE LOADER BENCHMARK (%d cards available, %d per template)%s ===\n", availableCards, count,
warmStart ? ", WARM cache from previous run" : "");
auto *worker = new CardPictureLoaderWorker();
for (const QString &urlTemplate : urlsToTest) {
const QList<ExactCard> cards = selectCardsForTemplate(db, urlTemplate, count);
if (cards.isEmpty()) {
std::printf("SKIP %-22s %-52s (no cards satisfy the template)\n", qPrintable(hostOf(urlTemplate)),
qPrintable(urlTemplate));
continue;
}
SettingsCache::instance().downloads().setDownloadUrls({urlTemplate});
const bool redirects = likelyRedirects(urlTemplate);
const qint64 perCardMs = redirects ? 200 : 100;
const int timeoutMs =
(timeoutMin.has_value() ? timeoutMin.value() : (cards.size() * perCardMs * 8 + 60000) / 60000) * 60 * 1000;
const qint64 coldLowerMs = static_cast<qint64>(cards.size()) * perCardMs / 2;
const qint64 cachedUpperMs = 8000;
const PassResult cold = runPass(worker, cards, timeoutMs);
const PassResult cached = runPass(worker, cards, timeoutMs);
const bool coldComplete = cold.finished >= cold.enqueued;
const bool coldZeroFailures = cold.failed == 0;
const bool coldPaced = warmStart || cold.elapsedMs >= coldLowerMs;
const bool cachedComplete = cached.finished >= cached.enqueued;
const bool cachedZeroFailures = cached.failed == 0;
const bool cachedFast = cached.elapsedMs < cachedUpperMs;
const bool pass =
coldComplete && coldZeroFailures && coldPaced && cachedComplete && cachedZeroFailures && cachedFast;
allPass = allPass && pass;
std::printf("%s %-22s %-52s cold %s (paced>=%s) cached %s (must be <%.0fs) | %d/%d %d/%d loaded, "
"%d/%d failed\n",
pass ? "PASS " : "FAIL ", qPrintable(hostOf(urlTemplate)), qPrintable(urlTemplate),
qPrintable(formatDuration(cold.elapsedMs)), qPrintable(formatDuration(coldLowerMs)),
qPrintable(formatDuration(cached.elapsedMs)), static_cast<double>(cachedUpperMs) / 1000.0,
cold.finished, cold.enqueued, cached.finished, cached.enqueued, cold.failed, cached.failed);
if (cold.failed > 0) {
std::printf(" cold failures: %s\n", qPrintable(cold.failedCards.join(QStringLiteral(", "))));
}
if (cached.failed > 0) {
std::printf(" cached failures: %s\n", qPrintable(cached.failedCards.join(QStringLiteral(", "))));
}
}
std::printf("cache root: %s%s\n", qPrintable(rootDir),
cacheDirArg.isEmpty() ? " (reuse with --cache-dir to warm on the next run)" : "");
std::printf("RESULT: %s\n", allPass ? "PASS" : "FAIL");
return allPass ? 0 : 1;
}

View file

@ -0,0 +1,200 @@
/*
* Minimal SettingsCache implementation for the picture loader benchmark.
*
* The picture loader code insists on reading every URL, cache and path via
* SettingsCache::instance(). Compiling the real client SettingsCache would drag
* in the network update-checker graph, so instead we provide the SettingsCache
* member functions it actually uses, backed by the real libcockatrice_settings
* manager classes pointed at a sandboxed settings directory.
*
* The mock must be named SettingsCache: PathsSettings declares
* `friend class SettingsCache`, which is the only way to construct it.
*
* Paths resolved through QStandardPaths are redirected into the sandbox by
* setting the XDG_* environment variables before the first instance() call.
*/
#include "client/settings/cache_settings.h"
#include "interface/card_picture_loader/card_picture_loader_cache_method.h"
#include <QDir>
#include <QFile>
#include <QSettings>
#include <QStandardPaths>
#include <libcockatrice/settings/cache_storage_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h>
#include <utility>
QString SettingsCache::getDataPath()
{
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
}
QString SettingsCache::getSettingsPath()
{
return getDataPath() + "/settings/";
}
QString SettingsCache::getCachePath() const
{
return QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
}
QString SettingsCache::getNetworkCachePath() const
{
return getCachePath() + "/downloaded/";
}
QString SettingsCache::getCustomCardDatabasePath() const
{
return paths().getCustomCardDatabasePath();
}
QString SettingsCache::getCardDatabasePath() const
{
return paths().getCardDatabasePath();
}
QString SettingsCache::getSpoilerCardDatabasePath() const
{
return paths().getSpoilerCardDatabasePath();
}
QString SettingsCache::getTokenDatabasePath() const
{
return paths().getTokenDatabasePath();
}
int SettingsCache::getKeepAlive() const
{
return 0;
}
int SettingsCache::getTimeOut() const
{
return 0;
}
bool SettingsCache::getNotifyAboutUpdates() const
{
return false;
}
void SettingsCache::setKnownMissingFeatures(const QString & /*_knownMissingFeatures*/)
{
}
QString SettingsCache::getKnownMissingFeatures()
{
return QString();
}
QString SettingsCache::getClientID()
{
return QString();
}
DownloadSettings &SettingsCache::downloads() const
{
return *downloadSettings;
}
CacheStorageSettings &SettingsCache::cacheStorage() const
{
return *cacheStorageSettings;
}
CardsDisplaySettings &SettingsCache::cardsDisplay() const
{
return *cardsDisplaySettings;
}
PathsSettings &SettingsCache::paths() const
{
return *pathsSettings;
}
SettingsCache::SettingsCache()
{
settings = nullptr;
isPortableBuild = false;
const QString settingsPath = getSettingsPath();
QDir().mkpath(settingsPath);
downloadSettings = new DownloadSettings(settingsPath, this);
cacheStorageSettings = new CacheStorageSettings(settingsPath, this);
cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this);
pathsSettings = new PathsSettings(settingsPath, this);
// Picture downloads enabled, default host list, network disk cache storage.
downloadSettings->setPicDownload(true);
downloadSettings->resetToDefaultURLs();
cacheStorageSettings->setCardImageCacheMethod(
static_cast<int>(CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE));
loadPaths();
}
void SettingsCache::loadPaths()
{
QString dataPath = getDataPath();
QSettings pathsIni(getSettingsPath() + "paths.ini", QSettings::IniFormat);
auto computePath = [&](const QString &key, const QString &defaultPath) -> QString {
QString val = pathsIni.value("paths/" + key).toString();
if (val.isEmpty() || !QDir(val).exists()) {
if (!QDir().mkpath(defaultPath)) {
qCInfo(SettingsCacheLog) << "[SettingsCache] Could not create folder:" << defaultPath;
}
val = defaultPath;
pathsIni.setValue("paths/" + key, val);
}
return val;
};
auto computeFilePath = [&](const QString &key, const QString &defaultPath) -> QString {
QString val = pathsIni.value("paths/" + key).toString();
if (!QFile::exists(val) || val.isEmpty()) {
val = defaultPath;
pathsIni.setValue("paths/" + key, val);
}
return val;
};
computePath("decks", dataPath + "/decks/");
computePath("filters", dataPath + "/filters/");
computePath("replays", dataPath + "/replays/");
computePath("themes", dataPath + "/themes/");
computePath("pics", dataPath + "/pics/");
computePath("redirects", getCachePath() + "/redirects/");
// customPicsPath derived from picsPath
QString picsPath = pathsIni.value("paths/pics").toString();
if (picsPath.endsWith("/")) {
computePath("customPics", picsPath + "CUSTOM/");
} else {
computePath("customPics", picsPath + "/CUSTOM/");
}
computePath("customSets", dataPath + "/customsets/");
computeFilePath("cardDatabase", dataPath + "/cards.xml");
computeFilePath("tokenDatabase", dataPath + "/tokens.xml");
computeFilePath("spoilerDatabase", dataPath + "/spoiler.xml");
}
void SettingsCache::setThemeName(const QString &_themeName)
{
if (themeName != _themeName) {
themeName = _themeName;
emit themeChanged();
}
}
SettingsCache &SettingsCache::instance()
{
static SettingsCache settingsCache;
return settingsCache;
}

View file

@ -334,6 +334,38 @@ TEST_F(SettingsDefaultsTest, Download_DownloadSpoilersStatus_Default)
ASSERT_EQ(s.getDownloadSpoilersStatus(), false);
}
TEST_F(SettingsDefaultsTest, Download_HostRequestLimits_Default)
{
DownloadSettings s(settingsPath, nullptr);
ASSERT_TRUE(s.getHostRequestLimits().isEmpty());
}
TEST_F(SettingsDefaultsTest, Download_HostRequestLimits_SetAndGet)
{
DownloadSettings s(settingsPath, nullptr);
s.setHostRequestLimits({{"api.scryfall.com", 5}});
const QHash<QString, int> limits = s.getHostRequestLimits();
ASSERT_EQ(limits.size(), 1);
ASSERT_EQ(limits.value("api.scryfall.com"), 5);
}
TEST_F(SettingsDefaultsTest, Download_HostRequestLimits_StackedHostCaps)
{
DownloadSettings s(settingsPath, nullptr);
// The developer cap for the Scryfall API lowers the ceiling to 9; a user can
// reduce it further but can never raise it above the cap.
ASSERT_EQ(s.clampHostRequestLimit("api.scryfall.com", 9), 9);
ASSERT_EQ(s.clampHostRequestLimit("api.scryfall.com", 20), 9);
ASSERT_EQ(s.clampHostRequestLimit("api.scryfall.com", 5), 5);
ASSERT_EQ(s.clampHostRequestLimit("api.scryfall.com", 0), 1);
// Hosts without a developer cap fall back to the global default ceiling.
ASSERT_EQ(s.clampHostRequestLimit("gatherer.wizards.com", 10), 10);
ASSERT_EQ(s.clampHostRequestLimit("gatherer.wizards.com", 20), 10);
// The Scryfall CDN is unlocked: no upper bound (values are only floored).
ASSERT_EQ(s.clampHostRequestLimit("cards.scryfall.io", 20), 20);
ASSERT_EQ(s.clampHostRequestLimit("cards.scryfall.io", 0), 1);
}
// --- AppearanceSettings ---
TEST_F(SettingsDefaultsTest, Appearance_ThemeName_Default)