Cockatrice/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp
BruebachL 1c93309952
[Client] Show localized card names, texts and pictures (#7294)
* [Client] Show localized card names, texts and pictures

Localization wiring now runs end to end: the oracle importer collects
foreignData for the configured language and the client renders it.

- [Oracle] Import localized names and rules texts for the selected cardLang
  - single-face cards store their foreignData name and full text
  - multi-face (split/adventure/aftermath/prepare) cards collect the joined
    name once and join each face's translated text with the same separator
    as the English merge; an incomplete translation falls back to English;
    the joined text follows the same highest-priority-set policy as the
    single-face path and is only collected when localization is enabled
  - the wizard switching languages re-imports the card database

- [Client] Display localized card info throughout the client
  - card info text/picture widgets and the game board re-render on language
    change
  - pictures resolve cardLang art through Scryfall's named endpoint using the
    localized name, falling back to id-based art when no match exists
  - deck editor keeps canonical English names as card identity (EditRole)
    while showing localized names (DisplayRole), so decks and wire names
    stay stable

- [Card] Add CardLocalization-backed name/text lookup and cards.xml v4
  localization elements with a bounded-size translation cache

- [Tests] Cover oracle foreignData import (incl. multi-face joins, priority
  and fallback paths), XML v4 localization parsing, deck model localized
  display and the language-aware settings default

Existing installations need to re-run Oracle to see translations: localized
data only lands in cards.xml when the Oracle app is started with the
preferred language selected — launch the separate "Oracle" program that
ships with Cockatrice, pick the language in the wizard and let it re-import
the card database.

The client's database cache (cards.xml.cache) is invalidated by the cache
format bump and the source-hash checks, but a cache written before the
re-import can still hold English-only entries (the hash uses file size and
mtime, so a same-size/same-timestamp rewrite may be served as-is); delete
cards.xml.cache and relaunch if no localized names/texts show up after
re-importing.

* [Card] Pass localized card names and texts into CardInfo construction

Address review: instead of constructing the card and then calling
setLocalizedName/setLocalizedText (which emit a cardInfoChanged signal per
language), both constructors, both newInstance overloads and their callers
(cards.xml v4 parser and the binary cache reader) now pass the localized maps
as constructor arguments.

* [Client] Rename LocalizedCard:: helpers namespace to CardLocalization

The namespace now matches its header file name, as the review pointed out;
LocalizedCard reads more like a class or struct. Callers (card info text
widget, board card name rendering) are updated to match.

* [Client] Drop unused info member from the card info text widget

The CardInfoPtr member was only ever initialized to nullptr and never read;
remove it together with its initializer.

* [PictureLoader] Add the localized picture URL explicitly, not implicitly

Address review: silently prepending the Scryfall named-picture URL to the
download list whenever a non-English card language was active was surprising,
consumed quota per card when it failed, and could grab the wrong (canon) art on
name collisions, with no way to turn it off.

The insert is now opt-in and user-controlled: changing the card language adds
the template to the top of the download URLs once (persisted, documented in the
re-import prompt, and editable/removable in the deck editor settings), while the
picture loader no longer injects it at request time.

* [Card] Show card languages in the same native (English) format as the UI

Address review: the card text & images language dropdown listed bare native
names, some in inconsistent lowercase (e.g. "čeština", "español de España"),
which makes the languages easy to mix up for users that do not read the script
(e.g. 日本語 vs 한국어). It now mirrors the UI language dropdown and always pairs
the native name with its English name (e.g. "Deutsch (German)",
"日本語 (Japanese)"), using the same fixed casing.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-09-18 12:03:07 +02:00

525 lines
15 KiB
C++

#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 <QBuffer>
#include <QDataStream>
#include <QElapsedTimer>
#include <QFile>
#include <QSaveFile>
namespace
{
constexpr quint32 CACHE_MAGIC = 0x43445243; // "CDRC"
constexpr quint32 CACHE_VERSION = 3;
// ---- Primitives -----------------------------------------------------------
void writeString(QDataStream &out, const QString &s)
{
out << s;
}
QString readString(QDataStream &in)
{
QString s;
in >> s;
return s;
}
// Stores a QHash<QString, QString> as a single pre-serialized blob. The reader keeps the
// blob as-is and materializes the QHash<QString, QString> 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 QHash<QString, QString> &h)
{
QByteArray blob;
QDataStream blobOut(&blob, QIODevice::WriteOnly);
blobOut.setVersion(QDataStream::Qt_6_4);
blobOut << h;
out << static_cast<quint32>(blob.size());
out.writeRawData(blob.constData(), blob.size());
}
QByteArray readHashBlob(QDataStream &in)
{
quint32 len = 0;
in >> len;
if (in.status() != QDataStream::Ok || static_cast<qint64>(len) > in.device()->bytesAvailable()) {
return {};
}
QByteArray blob(len, Qt::Uninitialized);
in.readRawData(blob.data(), static_cast<int>(len));
return blob;
}
void writeDate(QDataStream &out, const QDate &d)
{
out << d;
}
QDate readDate(QDataStream &in)
{
QDate d;
in >> d;
return d;
}
void writeStringMap(QDataStream &out, const QMap<QString, QString> &map)
{
out << static_cast<quint32>(map.size());
for (auto it = map.constBegin(); it != map.constEnd(); ++it) {
writeString(out, it.key());
writeString(out, it.value());
}
}
QMap<QString, QString> readStringMap(QDataStream &in)
{
QMap<QString, QString> map;
quint32 count = 0;
in >> count;
if (in.status() != QDataStream::Ok) {
return map;
}
for (quint32 i = 0; i < count; ++i) {
QString key = readString(in);
QString value = readString(in);
if (in.status() != QDataStream::Ok) {
map.clear();
return map;
}
map.insert(key, value);
}
return map;
}
// ---- CardRelation ----------------------------------------------------------
void writeRelation(QDataStream &out, const CardRelation *rel)
{
writeString(out, rel->getName());
out << static_cast<quint32>(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<CardRelationType>(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);
auto set = sets.value(setName);
return PrintingInfo(set, LazyPropertiesHash(propsBlob));
}
// ---- 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<quint32>(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<CardSet::Priority>(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<QString> altNames = card->getAltNames();
out << static_cast<quint32>(altNames.size());
for (const QString &alt : altNames) {
writeString(out, alt);
}
}
// setsToPrintings
const SetToPrintingsMap sets = card->getSets();
out << static_cast<quint32>(sets.size());
for (auto it = sets.constBegin(); it != sets.constEnd(); ++it) {
writeString(out, it.key());
out << static_cast<quint32>(it.value().size());
for (const PrintingInfo &p : it.value()) {
writePrinting(out, p);
}
}
// related cards
const QList<CardRelation *> related = card->getRelatedCards();
out << static_cast<quint32>(related.size());
for (const CardRelation *rel : related) {
writeRelation(out, rel);
}
// reverse-related cards (the reverseRelatedCards list, not the computed 2Me)
const QList<CardRelation *> reverse = card->getReverseRelatedCards();
out << static_cast<quint32>(reverse.size());
for (const CardRelation *rel : reverse) {
writeRelation(out, rel);
}
// localized card data
writeStringMap(out, card->getLocalizedNames());
writeStringMap(out, card->getLocalizedTexts());
}
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<QString> 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<PrintingInfo> printings;
printings.reserve(printingCount);
for (quint32 j = 0; j < printingCount; ++j) {
printings.append(readPrinting(in, sets));
}
cardSets.insert(setName, printings);
}
QList<CardRelation *> 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<CardRelation *> 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));
}
const QMap<QString, QString> localizedNames = readStringMap(in);
if (in.status() != QDataStream::Ok) {
return nullptr;
}
const QMap<QString, QString> localizedTexts = readStringMap(in);
if (in.status() != QDataStream::Ok) {
return nullptr;
}
CardInfoPtr card = CardInfo::newInstance(name, text, isToken, propertiesBlob, related, reverse, cardSets, ui,
simpleName, altNames, false, localizedNames, localizedTexts);
return card;
}
// ---- FormatRules -----------------------------------------------------------
void writeFormat(QDataStream &out, const FormatRulesPtr &format)
{
writeString(out, format->formatName);
out << format->minDeckSize;
out << format->maxDeckSize;
out << format->maxSideboardSize;
out << static_cast<quint32>(format->allowedCounts.size());
for (const AllowedCount &ac : format->allowedCounts) {
out << ac.max;
writeString(out, ac.label);
}
out << static_cast<quint32>(format->exceptions.size());
for (const ExceptionRule &ex : format->exceptions) {
out << static_cast<quint32>(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<quint32>(data.sets.size());
for (const CardSetPtr &set : data.sets) {
writeSet(out, set);
}
// Cards
const quint32 cardCount = static_cast<quint32>(data.cards.size());
out << cardCount;
for (const CardInfoPtr &card : data.cards) {
writeCard(out, card);
}
// Formats
out << static_cast<quint32>(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<int>(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;
}