mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-08 02:53:25 -08:00
* [CardDB] Reload when the first run enables all sets checkUnknownSets() enabled every set when none was enabled, but the database had already been parsed under that empty enablement, so the parsers had stripped every printing. It only notified the models and never reloaded, leaving the deck editor's card database display empty until the next launch. Route the all-sets-enabled case through the same reload the new-sets path uses, and skip caching a snapshot whose sets are all disabled. * [CardDB] Own checkUnknownSets after startup and fix the cache skip The loader's initialLoadComplete guard skipped checkUnknownSets() on the first successful load of the session, so a download that landed after startupConfigCheck() and the onboarding wizard had already run (e.g. skipping the wizard download and fetching later from the menu) left every set disabled and the deck editor blank until the next launch. The guard also let the check run on the worker before the queued snapshot swap, and alongside reloadCardDatabasesAndNotify()'s own check, double-emitting cardDatabaseNewSetsFound on manual reloads. Move the check to the callers that load after startup: the post-update, custom-import, and spoiler paths run it via a QFutureWatcher once the finished snapshot has been swapped in, and the loader's synchronous path-change reload runs it directly. Judge the cache skip by the parsed sets' own enablement, what the parsers actually saw, instead of getEnabledSetNames(), which reports empty for the noop controller even though it enables every set. Wait for the first-run reparse before opening Manage Sets so the dialog cannot bind to the pre-reload set instances. * [CardDB] Queue post-reload check and unblock first-run Manage Sets reloadCardDatabasesAndNotify() no longer emits cardDatabaseEnabledSetsChanged after the swap's Reset already rebuilt the models (second 36k rebuild froze startup), and its follow-up checkUnknownSets() is queued to the GUI thread so it sees swapped-in data. cardDatabaseAllNewSetsEnabled() chains Welcome/Manage Sets off a QFutureWatcher instead of waitForFinished() blocking the event loop during the reparse. * [CardDB] Batch the first-run enable-all into a single settings flush checkUnknownSets() enabled every set with ~3 QSettings rewrite cycles per set (sort key, known, enabled), and each setValue re-opens and re-syncs the whole cardDatabase.ini. With a full database (874 sets) that froze the GUI thread for ~9s after the database download (LagMonitor: event loop stalled for 9129 ms, measured offscreen with a real 59MB cards.xml), reading as a total application hang that recovers on its own. Persist the enable-all through the controller's batched saveSets() in a single flush instead, mirroring what SetsModel::save() already does: CardSetList::enableAllBatched() assigns fresh sort keys and marks every set known+enabled in memory, then writes all records at once. SetSaveData gains the isKnown flag so the batch covers all three options, and SetsModel::save() passes the sets' known state through (unchanged behavior there). Also drop the cardDatabaseEnabledSetsChanged emission from the all-sets-enabled branch: the reload chained off cardDatabaseAllNewSetsEnabled rebuilds the models from the re-parsed snapshot via cardDatabaseReset, so emitting both rebuilt the 36k-card models twice on the GUI thread. Adds a cache-invalidation test driving the real checkUnknownSets() first-run path. * [CardDB] Batch the new-sets-found enable-all the same way The first-run batching fixed the 874-set case, but the new-sets-found dialog path (enableAllUnknownSets/markAllSetsAsKnown) still wrote per set and per option, each setValue re-opening and re-syncing the whole cardDatabase.ini. Users with hundreds of custom sets hit the same multi-second GUI stall when answering the dialog. Persist both through the controller's batched saveSets() in a single flush, writing only changed sets (so the common no-op check skips the flush entirely). Removes the now-unused write-through CardSetList methods (enableAll, enableAllUnknown, markAllAsKnown, guessSortKeys) so the batched flush is the only way to persist bulk set changes. Extends the test controller mock with per-write/batched-flush counters and adds tests asserting both dialog paths persist with exactly one flush and zero individual writes. --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
448 lines
17 KiB
C++
448 lines
17 KiB
C++
/***************************************************************************
|
|
* Copyright (C) 2008 by Max-Wilhelm Bruker *
|
|
* brukie@gmx.net *
|
|
* *
|
|
* This program is free software; you can redistribute it and/or modify *
|
|
* it under the terms of the GNU General Public License as published by *
|
|
* the Free Software Foundation; either version 2 of the License, or *
|
|
* (at your option) any later version. *
|
|
* *
|
|
* This program is distributed in the hope that it will be useful, *
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of *
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the *
|
|
* GNU General Public License for more details. *
|
|
* *
|
|
* You should have received a copy of the GNU General Public License *
|
|
* along with this program; if not, write to the *
|
|
* Free Software Foundation, Inc., *
|
|
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
|
|
***************************************************************************/
|
|
|
|
#include "main.h"
|
|
|
|
#include "client/network/update/card_spoiler/spoiler_background_updater.h"
|
|
#include "client/settings/cache_settings.h"
|
|
#include "client/sound_engine.h"
|
|
#include "client/url_scheme_event_filter.h"
|
|
#include "database/interface/settings_card_preference_provider.h"
|
|
#include "interface/intents/intent.h"
|
|
#include "interface/intents/intent_open_local_deck.h"
|
|
#include "interface/logger.h"
|
|
#include "interface/pixel_map_generator.h"
|
|
#include "interface/theme_manager.h"
|
|
#include "interface/window_main.h"
|
|
#include "libcockatrice/card/database/card_database.h"
|
|
#include "libcockatrice/rng/rng_abstract.h"
|
|
#include "single_instance_manager.h"
|
|
#include "version_string.h"
|
|
|
|
#include <QApplication>
|
|
#include <QByteArray>
|
|
#include <QByteArrayView>
|
|
#include <QCommandLineOption>
|
|
#include <QCommandLineParser>
|
|
#include <QCoreApplication>
|
|
#include <QCryptographicHash>
|
|
#include <QDebug>
|
|
#include <QFileInfo>
|
|
#include <QGuiApplication>
|
|
#include <QIcon>
|
|
#include <QList>
|
|
#include <QLocale>
|
|
#include <QMessageBox>
|
|
#include <QMessageLogger>
|
|
#include <QNetworkInterface>
|
|
#include <QObject>
|
|
#include <QRegularExpression>
|
|
#include <QRegularExpressionMatch>
|
|
#include <QSharedPointer>
|
|
#include <QString>
|
|
#include <QStringList>
|
|
#include <QStringLiteral>
|
|
#include <QTranslator>
|
|
#include <QUrl>
|
|
#include <QtGlobal>
|
|
#include <algorithm>
|
|
#include <libcockatrice/card/database/card_database_manager.h>
|
|
#include <libcockatrice/rng/rng_sfmt.h>
|
|
#include <libcockatrice/settings/card_database_settings.h>
|
|
#include <libcockatrice/settings/interface_settings.h>
|
|
#include <libcockatrice/settings/network_settings.h>
|
|
#include <libcockatrice/settings/personal_settings.h>
|
|
#include <libcockatrice/utility/cryptoutil.h>
|
|
#include <libcockatrice/utility/translation_loader.h>
|
|
#include <qlogging.h>
|
|
#include <qnamespace.h>
|
|
#include <qsystemdetection.h>
|
|
|
|
QTranslator *translator, *qtTranslator;
|
|
RNG_Abstract *rng;
|
|
SoundEngine *soundEngine;
|
|
QSystemTrayIcon *trayIcon;
|
|
ThemeManager *themeManager;
|
|
|
|
const QString translationPrefix = "cockatrice";
|
|
QString translationPath;
|
|
|
|
static void CockatriceLogger(QtMsgType type, const QMessageLogContext &ctx, const QString &message)
|
|
{
|
|
QString logMessage = qFormatLogMessage(type, ctx, message);
|
|
|
|
// Regular expression to match the full path in the square brackets and extract only the filename and line number
|
|
QRegularExpression regex(R"(\[(?:.:)?[\/\\].*[\/\\]([^\/\\]+\:\d+)\])");
|
|
QRegularExpressionMatch match = regex.match(logMessage);
|
|
|
|
if (match.hasMatch()) {
|
|
// Extract the filename and line number (e.g., "main.cpp:211")
|
|
QString filenameLine = match.captured(1);
|
|
|
|
// Replace the full path in square brackets with just the filename and line number
|
|
logMessage.replace(match.captured(0), QString("[%1]").arg(filenameLine));
|
|
}
|
|
|
|
Logger::getInstance().log(type, ctx, logMessage);
|
|
}
|
|
|
|
#ifdef Q_OS_WIN
|
|
// clang-format off
|
|
#include <Windows.h>
|
|
#include <DbgHelp.h>
|
|
#include <ShlObj.h>
|
|
#include <ctime>
|
|
#include <filesystem>
|
|
|
|
#pragma comment(lib, "DbgHelp.lib") // Link the DbgHelp library
|
|
// clang-format on
|
|
|
|
LONG WINAPI CockatriceUnhandledExceptionFilter(EXCEPTION_POINTERS *exceptionPointers)
|
|
{
|
|
std::filesystem::path path;
|
|
|
|
// Find %LOCALAPPDATA% (or cheat at finding it)
|
|
wchar_t *localAppDataFolder;
|
|
if (SHGetKnownFolderPath(FOLDERID_LocalAppData, KF_FLAG_CREATE, NULL, &localAppDataFolder) != S_OK) {
|
|
path = std::filesystem::temp_directory_path().parent_path().parent_path();
|
|
} else {
|
|
path = std::filesystem::path(localAppDataFolder);
|
|
}
|
|
|
|
// Plan on writing crash files into %LOCALAPPDATA%/CrashDumps/Cockatrice/cockatrice.crash.*.dmp
|
|
path /= "CrashDumps";
|
|
path /= "Cockatrice";
|
|
if (!std::filesystem::exists(path)) {
|
|
std::filesystem::create_directories(path);
|
|
}
|
|
path /= "cockatrice.crash." + std::to_string(std::time(0)) + ".dmp";
|
|
|
|
// Create and write crash files
|
|
#ifdef UNICODE
|
|
HANDLE hDumpFile =
|
|
CreateFile(path.wstring().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
|
#else
|
|
HANDLE hDumpFile =
|
|
CreateFile(path.string().c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
|
#endif
|
|
|
|
MINIDUMP_EXCEPTION_INFORMATION mei;
|
|
mei.ExceptionPointers = exceptionPointers;
|
|
mei.ThreadId = GetCurrentThreadId();
|
|
mei.ClientPointers = 1;
|
|
|
|
MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), hDumpFile, MiniDumpWithFullMemory, &mei, nullptr,
|
|
nullptr);
|
|
|
|
CloseHandle(hDumpFile);
|
|
|
|
return EXCEPTION_EXECUTE_HANDLER;
|
|
}
|
|
#endif
|
|
|
|
void installNewTranslator()
|
|
{
|
|
QString lang = SettingsCache::instance().personal().getLang();
|
|
|
|
// Qt's own strings and ours need a translator each: QTranslator discards its previous contents
|
|
// on every load, so loading both files into one translator silently drops the first of them.
|
|
TranslationLoader::loadQt(*qtTranslator, lang);
|
|
qApp->installTranslator(qtTranslator);
|
|
|
|
TranslationLoader::loadApplication(*translator, translationPrefix, lang,
|
|
TranslationLoader::applicationTranslationPaths(translationPath));
|
|
qApp->installTranslator(translator);
|
|
}
|
|
|
|
QString const generateClientID()
|
|
{
|
|
QString macList;
|
|
for (const QNetworkInterface &networkInterface : QNetworkInterface::allInterfaces()) {
|
|
if (networkInterface.hardwareAddress() != "") {
|
|
if (networkInterface.hardwareAddress() != "00:00:00:00:00:00:00:E0") {
|
|
macList += networkInterface.hardwareAddress() + ".";
|
|
}
|
|
}
|
|
}
|
|
QString strClientID = QCryptographicHash::hash(macList.toUtf8(), QCryptographicHash::Sha1).toHex().right(15);
|
|
return strClientID;
|
|
}
|
|
|
|
static QString redactActivationUrl(const QString &url)
|
|
{
|
|
// Activation URLs carry secrets in their query string (e.g. the deck share
|
|
// token); log only the scheme and the action (cockatrice://opendeck), never
|
|
// the parameters.
|
|
if (!url.startsWith(QStringLiteral("cockatrice://"))) {
|
|
return url;
|
|
}
|
|
const QUrl parsed(url);
|
|
return parsed.scheme() + "://" + parsed.host();
|
|
}
|
|
|
|
int main(int argc, char *argv[])
|
|
{
|
|
#ifdef Q_OS_WIN
|
|
SetUnhandledExceptionFilter(CockatriceUnhandledExceptionFilter);
|
|
#endif
|
|
|
|
// Logging setup
|
|
#ifdef Q_OS_APPLE
|
|
// <build>/cockatrice/cockatrice.app/Contents/MacOS/cockatrice
|
|
const QByteArray configPath = "../../../qtlogging.ini";
|
|
#elif defined(Q_OS_UNIX)
|
|
// <build>/cockatrice/cockatrice
|
|
const QByteArray configPath = "./qtlogging.ini";
|
|
#elif defined(Q_OS_WIN)
|
|
// <build>/cockatrice/Debug/cockatrice.exe
|
|
const QByteArray configPath = "../qtlogging.ini";
|
|
#else
|
|
const QByteArray configPath = "";
|
|
#endif
|
|
|
|
if (!qEnvironmentVariableIsSet(("QT_LOGGING_CONF"))) {
|
|
// Set the QT_LOGGING_CONF environment variable
|
|
qputenv("QT_LOGGING_CONF", configPath);
|
|
}
|
|
|
|
qSetMessagePattern(
|
|
"\033[0m[%{time yyyy-MM-dd h:mm:ss.zzz} "
|
|
"%{if-debug}\033[36mD%{endif}%{if-info}\033[32mI%{endif}%{if-warning}\033[33mW%{endif}%{if-critical}\033[31mC%{"
|
|
"endif}%{if-fatal}\033[1;31mF%{endif}\033[0m] [%{function}] - %{message} [%{file}:%{line}]");
|
|
QApplication app(argc, argv);
|
|
|
|
#ifdef Q_OS_MAC
|
|
UrlSchemeEventFilter cockatriceFilter(QStringList{QStringLiteral("cockatrice")});
|
|
|
|
QStringList pendingMacUrls;
|
|
|
|
const auto cocoaBufferConn =
|
|
QObject::connect(&cockatriceFilter, &UrlSchemeEventFilter::urlReceived,
|
|
[&pendingMacUrls](const QString &url) { pendingMacUrls.append(url); });
|
|
|
|
app.installEventFilter(&cockatriceFilter);
|
|
#endif
|
|
|
|
QObject::connect(&app, &QApplication::lastWindowClosed, &app, &QApplication::quit);
|
|
|
|
qInstallMessageHandler(CockatriceLogger);
|
|
|
|
#ifdef Q_OS_WIN
|
|
app.addLibraryPath(app.applicationDirPath() + "/plugins");
|
|
#endif
|
|
|
|
// 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.github.io");
|
|
QCoreApplication::setApplicationName("Cockatrice");
|
|
QCoreApplication::setApplicationVersion(VERSION_STRING);
|
|
|
|
#ifdef Q_OS_MAC
|
|
qApp->setAttribute(Qt::AA_DontShowIconsInMenus, true);
|
|
#endif
|
|
|
|
// Translations
|
|
#ifdef Q_OS_MAC
|
|
translationPath = qApp->applicationDirPath() + "/../Resources/translations";
|
|
#elif defined(Q_OS_WIN)
|
|
translationPath = qApp->applicationDirPath() + "/translations";
|
|
#else // linux
|
|
translationPath = qApp->applicationDirPath() + "/../share/cockatrice/translations";
|
|
#endif
|
|
|
|
// Command-line parser
|
|
QCommandLineParser parser;
|
|
parser.setApplicationDescription("Cockatrice Client");
|
|
parser.addHelpOption();
|
|
parser.addVersionOption();
|
|
|
|
parser.addOptions(
|
|
{{{"c", "connect"}, QCoreApplication::translate("main", "Connect on startup"), "user:pass@host:port"},
|
|
{{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")}});
|
|
|
|
parser.process(app);
|
|
|
|
if (parser.isSet("debug-output")) {
|
|
Logger::getInstance().logToFile(true);
|
|
}
|
|
|
|
// --- Handle files or URLs passed at startup ---
|
|
// Only positional arguments are treated as files/URLs, so options like
|
|
// --connect are never handed off to another instance.
|
|
const QStringList startupFiles = parser.positionalArguments();
|
|
const bool hasActivationFiles = !startupFiles.isEmpty();
|
|
|
|
SingleInstanceManager instance;
|
|
|
|
if (hasActivationFiles) {
|
|
QStringList redactedFiles;
|
|
redactedFiles.reserve(startupFiles.size());
|
|
for (const QString &file : startupFiles) {
|
|
redactedFiles.append(redactActivationUrl(file));
|
|
}
|
|
qCInfo(MainLog) << "Activation launch, files:" << redactedFiles;
|
|
// Activation launch: hand off to the primary instance if one is
|
|
// running, otherwise become the primary ourselves. Do this before
|
|
// constructing the main window so a hand-off exits cheaply.
|
|
if (!instance.tryRun(startupFiles)) {
|
|
qInfo() << "Handed off to a running instance, exiting";
|
|
// Sent successfully → exit
|
|
return 0;
|
|
}
|
|
// No primary instance → become server
|
|
qInfo() << "No existing instance found, becoming primary instance";
|
|
} else {
|
|
// Plain launch: if another instance is running, run independently
|
|
// instead of handing off and exiting.
|
|
if (!instance.tryRun(QStringList())) {
|
|
// Another instance is already running → just run independently
|
|
qInfo() << "Another instance exists, running independently";
|
|
} else {
|
|
qInfo() << "No existing instance found, starting server";
|
|
}
|
|
}
|
|
|
|
rng = new RNG_SFMT(CryptoUtil::randomUInt64());
|
|
themeManager = new ThemeManager;
|
|
soundEngine = new SoundEngine;
|
|
|
|
qtTranslator = new QTranslator;
|
|
translator = new QTranslator;
|
|
installNewTranslator();
|
|
|
|
QLocale::setDefault(QLocale::English);
|
|
|
|
// Dependency Injections
|
|
CardDatabaseManager::setCardPreferenceProvider(new SettingsCardPreferenceProvider());
|
|
CardDatabaseManager::setCardDatabasePathProvider(&SettingsCache::instance());
|
|
CardDatabaseManager::setCardSetPriorityController(&SettingsCache::instance().cardDatabase());
|
|
|
|
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 deliberately not run for this front-loaded
|
|
// parse. It would run before MainWindow exists, so
|
|
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would have no
|
|
// receivers, and enabling the sets up-front would suppress the first-run
|
|
// experience MainWindow::startupConfigCheck() and the onboarding wizard
|
|
// provide. Callers that load after startup own the check.
|
|
CardDatabaseManager::getInstance()->loadCardDatabases();
|
|
|
|
MainWindow ui;
|
|
|
|
// A URL launch must own the connection: the intent chain triggered by the
|
|
// URL connects to the server named in the URL, so the window's own startup
|
|
// auto-connect must not race against it (two connectToServer calls tear
|
|
// each other down via doDisconnectFromServer).
|
|
bool hasUrlActivation = std::any_of(startupFiles.begin(), startupFiles.end(), [](const QString &file) {
|
|
return file.startsWith(QStringLiteral("cockatrice://"));
|
|
});
|
|
#ifdef Q_OS_MAC
|
|
// On macOS the launch can arrive through the URL scheme instead of as a
|
|
// positional argument (captured in pendingMacUrls); count those too or the
|
|
// window would auto-connect into the link's own connection attempt.
|
|
hasUrlActivation = hasUrlActivation ||
|
|
std::any_of(pendingMacUrls.cbegin(), pendingMacUrls.cend(),
|
|
[](const QString &url) { return url.startsWith(QStringLiteral("cockatrice://")); });
|
|
#endif
|
|
ui.setSkipStartupAutoConnect(hasUrlActivation);
|
|
|
|
auto handleActivation = [&ui](const QString &file) {
|
|
if (file.startsWith("cockatrice://")) {
|
|
qCInfo(MainLog) << "Handling URL activation:" << redactActivationUrl(file);
|
|
// Route through the window's persistent url parser: it serializes
|
|
// link chains so activations handed over while another chain is
|
|
// still connecting do not connect concurrently.
|
|
ui.handleCockatriceLink(file);
|
|
} else if (QFileInfo(file).exists()) {
|
|
auto openDeckIntent = new IntentOpenLocalDeck(ui.getTabSupervisor(), file);
|
|
QObject::connect(openDeckIntent, &Intent::failed, &ui, [&ui](const QString &reason) {
|
|
QMessageBox::warning(&ui, QObject::tr("Open deck"), reason);
|
|
});
|
|
openDeckIntent->execute();
|
|
}
|
|
};
|
|
|
|
#ifdef Q_OS_MAC
|
|
QObject::disconnect(cocoaBufferConn);
|
|
|
|
QObject::connect(&cockatriceFilter, &UrlSchemeEventFilter::urlReceived,
|
|
[&handleActivation](const QString &url) { handleActivation(url); });
|
|
#endif
|
|
if (parser.isSet("connect")) {
|
|
ui.setConnectTo(parser.value("connect"));
|
|
}
|
|
qCInfo(MainLog) << "MainWindow constructor finished";
|
|
|
|
ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice")));
|
|
// Base name of the desktop file installed next to the binary, without the ".desktop"
|
|
// extension. Wayland reads the window icon from it, and xdg-desktop-portal looks up
|
|
// org.freedesktop.host.portal.Registry by this app ID, so it has to match the file name
|
|
// exactly, down to the case.
|
|
QGuiApplication::setDesktopFileName("cockatrice");
|
|
|
|
SettingsCache::instance().network().setClientID(generateClientID());
|
|
|
|
// If spoiler mode is enabled, we will download the spoilers
|
|
// then reload the DB. otherwise just reload the DB
|
|
SpoilerBackgroundUpdater spoilerBackgroundUpdater;
|
|
|
|
ui.show();
|
|
qCInfo(MainLog) << "ui.show() finished";
|
|
|
|
// force shortcuts to be shown/hidden in right-click menus, regardless of system defaults
|
|
qApp->setAttribute(Qt::AA_DontShowShortcutsInContextMenus,
|
|
!SettingsCache::instance().userInterface().getShowShortcuts());
|
|
|
|
#ifdef Q_OS_MAC
|
|
for (const QString &url : pendingMacUrls) {
|
|
handleActivation(url);
|
|
}
|
|
pendingMacUrls.clear();
|
|
#endif
|
|
|
|
for (const QString &file : startupFiles) {
|
|
handleActivation(file);
|
|
}
|
|
|
|
// Connect to future file/URL events from other instances
|
|
QObject::connect(&instance, &SingleInstanceManager::filesReceived, [&handleActivation](const QStringList &files) {
|
|
for (const QString &file : files) {
|
|
handleActivation(file);
|
|
}
|
|
});
|
|
|
|
int ret = app.exec();
|
|
|
|
qCInfo(MainLog) << "Event loop finished, terminating...";
|
|
delete rng;
|
|
PingPixmapGenerator::clear();
|
|
CountryPixmapGenerator::clear();
|
|
UserLevelPixmapGenerator::clear();
|
|
ManaSymbolPixmapGenerator::clear();
|
|
|
|
return ret;
|
|
}
|