Skip to content

Deck Model

Pablo Martínez López edited this page Oct 27, 2026 · 7 revisions

Deck Model

English · Español

The deck data model is the contract between Rust, the SQLite database and the React screen. It lives in src-tauri/crates/proxy-core/src/model.rs, is pure (no Tauri, no network, no database), and every type is exported to TypeScript with ts-rs into src/bindings/ (generated by pnpm bindings, never edited by hand).

One rule shapes everything: one DeckCard per physical copy, not one per card with a quantity. Each copy can therefore have its own variant, edits, back, design, mark and position.

Type overview

classDiagram
    class Deck {
        id: String
        name: String
        decklistText: String
        cards: Vec~DeckCard~
        settings: PrintSettings
        defaultBack: Option~FaceImage~
        schemaVersion: u32
    }
    class DeckCard {
        id: String  (uuid v4)
        source: CardSource
        front: Option~FaceImage~
        back: BackSpec
        edits: CardEdits
        locked: bool
        meta: CardMeta
        design: Option~CardDesign~
        marca: Option~MarcaCarta~
    }
    class CardSource {
        <<enum kind>>
        Scryfall scryfallId
        Token scryfallId
        Custom scryfallId? externa?
        Blank
    }
    class RefExterna {
        fuente: FuenteCarta
        id: String
    }
    class FaceImage {
        blob: String (SHA-256)
        width: u32
        height: u32
        origin: ImageOrigin
    }
    class BackSpec {
        <<enum kind>>
        DeckDefault
        Dfc image
        Custom image
    }
    class CardEdits {
        rotation: Rotation
        crop: Crop
        adjust: Adjustments
    }
    class CardMeta {
        name, set, collectorNumber, lang
        langFallback, layout, isDfc
        imageStatus, highres, typeLine
        imageLang?
    }
    class PrintSettings
    class CardDesign
    Deck "1" *-- "many" DeckCard
    Deck "1" *-- "1" PrintSettings
    Deck "1" o-- "0..1" FaceImage : defaultBack
    DeckCard *-- CardSource
    DeckCard o-- "0..1" FaceImage : front
    DeckCard *-- BackSpec
    BackSpec o-- FaceImage
    DeckCard *-- CardEdits
    DeckCard *-- CardMeta
    DeckCard o-- "0..1" CardDesign : design
    CardSource o-- RefExterna : Custom.externa
Loading

CardDesign belongs to the card designer (src-tauri/crates/proxy-core/src/diseno/modelo.rs) and is documented in Designer-Engine.

Deck

Field Meaning
id uuid v4, created in store::decks::create.
name Display name (trimmed on create/rename).
decklist_text The decklist exactly as the user typed it. It is an input; the deck is cards. It is kept so the panel can show it again and so Export can recover list sections (see below).
cards Ordered Vec<DeckCard>; order is the print order and is persisted in deck_cards.position.
settings PrintSettings (see below).
default_back Back used by every card whose BackSpec is DeckDefault. A new deck gets the generic back (BlobStore::generic_back).
schema_version DECK_SCHEMA_VERSION (currently 2).

DeckCard and its parts

CardSource (tagged by kind)

#[serde(tag = "kind", rename_all = "camelCase")], so JSON looks like {"kind":"scryfall","scryfallId":"..."}.

Variant Meaning
Scryfall { scryfall_id } A concrete Scryfall printing.
Token { scryfall_id } A token added from another card's all_parts.
Custom { scryfall_id?, externa? } An image supplied by the user (or downloaded from an external source). scryfall_id optionally links it to the real Scryfall card it represents (used for card info and Statistics); the image stays the user's own. externa (RefExterna { fuente: FuenteCarta, id }) links to a card from a non-Scryfall source; today the only FuenteCarta is Cockatrice (tokens Scryfall lacks, see External-Sources).
Blank An empty slot (what "+ Add page" leaves behind).

Decision: externa is an optional field on Custom, not a new enum variant. Why: an older app that does not know the field still deserializes the card, and simply sees a plain custom card (serde ignores unknown fields), so older versions keep opening those decks.

Images: FaceImage and ImageOrigin

FaceImage { blob, width, height, origin }: blob is the lowercase SHA-256 hex (64 chars) of the stored file; the bytes live in the blob store, see Storage-and-Blobs. ImageOrigin decides, among other things, whether edge darkening applies (EdgeDarkening.apply_scryfall / apply_uploads / apply_backs):

Value Source
ScryfallPng Scryfall PNG (745x1040, transparent corners).
ScryfallLarge Scryfall large JPG (672x936), when there is no PNG.
Upload User upload (also the fallback when parsing an unknown origin string).
Generated Generated by the app (the generic back).
Designed Thumbnail of a card-designer design; the PDF does not use it, it repaints the design at export resolution.

front: None means a blank slot or an image not downloaded yet.

BackSpec (tagged by kind)

DeckDefault (use Deck.default_back), Dfc { image } (the back face of a double-faced card), Custom { image } (a back chosen for this card).

CardEdits

rotation (R0/R90/R180/R270), crop (zoom >= 1, offset_x/offset_y in [-1, 1] inside the margin the zoom leaves; 0 = centred), and adjust (Adjustments, the eleven fabricard retouches, 0 = no effect: brightness, exposure, contrast, saturation, warmth, vibrance, sharpness, clarity, noise_reduction, vignette, fade). Formulas live in imaging::adjust, see Imaging-and-PDF. is_identity() lets the pipeline skip work.

CardMeta

Display/decision data: name, set, collector_number, lang, lang_fallback (a language was requested but missing, so it stayed English), layout (Scryfall layout), is_dfc, image_status, highres, type_line, and image_lang. image_lang is only meaningful for custom cards: the language of the image, which need not match the linked card's lang (a Spanish custom image linked to the English printing). It is set by whoever links (the footer read by OCR, see Card-Identification-and-OCR) or chosen in the card editor.

locked

A pinned card: "Auto sort" and "Reapply language" skip it.

design

Optional CardDesign from the card designer (2.0). skip_serializing_if = "Option::is_none" keeps v1 JSON byte-identical for cards that have none.

Marks (marca) and what counts as "in the deck"

marca: Option<MarcaCarta> is a fixed tag for organising the deck. It does not change what is printed (a marked card still goes to the PDF). It changes what counts.

MarcaCarta UI name Counts toward deck size / copies / stats?
(none) unmarked Yes.
Variante design variant Only one copy per card name counts, and only if no unmarked copy of that name exists.
Opcional optional No.
Banquillo sideboard No (but exported to the sideboard section).
Copia copy (acts as a token) No. A real card used as the token of a Clone or a shapeshifter: in the game table it goes to the tokens («Las de tu deck»), not to the library, and it is not a candidate for a play list (mesa::preparar::secciones_de_mesa, candidatas_de_lista). The automatic detection (es_ficha) only knows Scryfall tokens, so this is how a real card is told to be one. Older versions of the app cannot open a deck that has this mark.

proxy_core::model::cuentan(cards) -> (Vec<&DeckCard>, Apartadas) is the single place where that rule is implemented. Names are compared trimmed and lowercased. Apartadas { variantes, opcionales, banquillo, copias } reports how many copies were left out (total()), surfaced to the screen as DeckStats.setAside. Callers:

  • src-tauri/src/commands/stats.rs (calcular): statistics only look at counted cards.
  • src-tauri/crates/proxy-core/src/decklist/exportar.rs (juntar): the exported list skips non-counted cards, except Banquillo, which goes to the sideboard block. Tokens and blanks are never written (va_en_la_lista); a Custom card linked to an externa source counts as a token.

Test: las_marcadas_no_cuentan in model.rs.

Sections

Deck has no section field. Sections come from the decklist parser (proxy_core::decklist::Section: Main, Commander, Sideboard, Companion, Maybeboard, Tokens, see Decklist-Parsing). When the list is resolved (src-tauri/src/scryfall/resolve.rs), only two sections turn into marks: Sideboard becomes Banquillo and Maybeboard (if included) becomes Opcional; the rest get no mark. When exporting, sections are re-derived by name from decklist_text (secciones_de / seccion_de in exportar.rs), so renaming a card in the deck without editing the list can lose its section: Inferred from the name-based lookup; read exportar.rs before relying on it.

Copies

Resolving a list line with quantity N creates N DeckCards with fresh uuids (resolve.rs: for _ in 0..l.qty). "Copies of a card" is therefore a count over cards filtered by cuentan, not a stored number.

Card size and print settings

PrintSettings (everything that decides how the paper comes out) is stored inside the deck as Deck.settings, serialized into decks.settings_json. The same type is what Layout-Engine and Imaging-and-PDF consume, and what the export panel edits through useDeck.setAjustes (see State-Stores). Named, reusable copies of print settings or row offsets live separately as presets (store::presets, table presets, kinds frontOffsets, backOffsets, print; UI in src/features/export/presets.tsx).

Field Type / default Notes
paper PaperFormat, A4 A3 A4 A5 Letter Legal Tabloid Photo4x6 Photo5x7 Photo8x10 Photo8x12 (paper.rs).
landscape false
card_size CardSize::MTG 63x88 mm Alternatives CardSize::POKER 63.5x88.9 mm (2.5x3.5 in) and CardSize::FUNDA 65x90 mm, which fills a sleeve exactly (see Export).
min_margin_mm 5.0
gap_mm 2.0 White gap between cards, as fabricard; each card keeps its exact 63x88 size and bleed does not cover the gap.
bleed_mm 0.0 Bleed is available but off by default.
bleed_on_backs true
bleed_mode Clamp Clamp repeats the edge pixel, Mirror mirrors.
fill_corners false (#[serde(default)]) Paint transparent/light rounded corners with the edge colour; off means transparent corners come out as paper colour.
cut_guides CutGuides, disabled enabled, color (#00c853), width_px 0.5, offset_mm 0, length_mm 5.
edge_darkening EdgeDarkening, disabled mode (ContrastEdges/SolidBlack/Threshold), amount 80, width_pct 8, auto_detect, apply_scryfall/uploads/backs.
duplex Off Off, LongEdge (the back is mirrored left to right and never rotated; a saved shortEdge is read as LongEdge).
interleave_dfc false
dfc_filter All HideDfc removes double-faced cards from the plan and the PDF.
quality Dpi600 Dpi300/600/900; dpi() gives the number.
encoding JPEG, quality 95 ImageFormat::{Jpeg,Png}, jpeg_quality 1..=100 (JPEG only).
row_offsets empty RowOffsets { front, back }, millimetres per row.
split_every None Split the PDF every N sheets.
design_lang En for new decks; Es when missing in a stored deck (#[serde(default)]) Language designed cards print in (DesignLang::{Es,En}).
big_cards_back false (#[serde(default)]) Big cards (planes, Vanguard) print the deck back on duplex; otherwise their back is blank.

Invariants worth knowing:

  • Decision: RowOffsets.back is expressed in the front face's frame and indexed by the front row (what you see holding the sheet up to the light from the front). layout::plan applies it before mirroring. Missing rows are 0 (front_row, back_row).
  • Decision: the defaults (2 mm gap, 0 bleed, guides off, fill corners off, 600 dpi, JPEG 95) are asserted by los_ajustes_por_defecto_son_los_de_fabricard. Change them there and in the test together.
  • Decision: the card is stretched into the exact slot and only cropped when the aspect ratio differs by more than 15 percent (MAX_ESTIRADO, in imaging::frame). That is an imaging rule, not a model field.

Serialization

  • All structs/enums use #[serde(rename_all = "camelCase")]; internally tagged enums use tag = "kind" (CardSource, BackSpec). Unit enums serialize as camelCase strings ("dpi600", "off"). Tests los_campos_van_en_camel_case and el_origen_de_una_carta_viaja_etiquetado.
  • ts-rs mirrors this: where serde renames a field explicitly (scryfallId), the code adds #[ts(rename = ...)] and #[ts(optional)] for Option fields that are skipped when None.
  • The database stores each card as one card_json string and the settings as settings_json (see Storage-and-Blobs). The .pbdeck package embeds the whole Deck as JSON in manifest.json (see Deck-Package-Format).

Versioning and backward compatibility

  • DECK_SCHEMA_VERSION = 2. Version 2 added DeckCard.design. Decision: the constant was bumped because a 1.x app that opened and re-saved a v2 deck would silently drop the designs (serde ignores the unknown design field and the resave omits it).
  • store::decks::save always writes the current DECK_SCHEMA_VERSION into decks.schema_version; store::decks::open returns the stored value. I found no code that rejects a deck by schema_version when opening from the database; only package import checks a version (package::VERSION, see Deck-Package-Format).
  • Newer-field strategy (read this before adding a field): old data must still deserialize, so a new field must be Option<T> with #[serde(default, skip_serializing_if = "Option::is_none")] (as design, marca, image_lang, Custom.externa) or carry #[serde(default)] (as fill_corners, design_lang, big_cards_back). skip_serializing_if also keeps unchanged cards byte-identical on resave. The vast majority of PrintSettings fields have no serde default, so adding a required field there would make every stored deck fail to open (ErrorCode::Db, de_json in store/decks.rs).
  • Older app opening a newer deck: unknown fields are ignored (no deny_unknown_fields anywhere in src-tauri), so new optional data (marks, links, designs) is dropped on the next save by the older app. A new enum variant or value an old app does not know (for example a new ImageOrigin) is a hard deserialize error instead. Hence externa was not made a variant.
  • CardDesign has its own version (VERSION_DISENO = 1, diseno/modelo.rs).

Where the model is created and changed

Where What
src-tauri/src/store/decks.rs create, open, save, duplicate (new ids, shared images).
src-tauri/src/scryfall/resolve.rs Builds DeckCards from a parsed list (hacer, copies in list order, tokens last).
src/stores/deck-store.ts The open deck in the screen, history and autosave (see Storage-and-Blobs).
src/bindings/Deck.ts, DeckCard.ts, PrintSettings.ts... Generated types.

Where to start when extending

  1. Add the field in model.rs with the serde attributes above, add #[ts(optional)] if relevant, run pnpm bindings.
  2. If it references images, make store::decks::refs_del_deck / package::blobs_del_deck aware of it (otherwise garbage collection can delete the image, see Storage-and-Blobs).
  3. If it changes counting, change cuentan and its test, and check commands/stats.rs and decklist/exportar.rs.
  4. Update every DeckCard { ... } literal the compiler flags (tests in store/, package.rs, exportar.rs, resolve.rs).

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally