Skip to content

Deck Package Format

Pablo Martínez López edited this page Nov 6, 2026 · 9 revisions

Deck Package Format

English · Español

A .pbdeck file is a ZIP that carries one deck together with every image it uses, so it can be moved to another computer (or backed up) without losing anything. Code: src-tauri/src/package.rs (export, import, blobs_del_deck), commands deck_export_package / deck_import_package in src-tauri/src/commands/library.rs, and, for the import dialog, sharing and passing decks, paquete_leer, paquete_importar, deck_paquete_info, deck_paquete_guardar and deck_paquete_compartir in src-tauri/src/commands/pasar.rs. On Android the paths are content:// URIs and go through src-tauri/src/fichero.rs.

The format is deliberately boring: a JSON manifest plus the original image files named by their SHA-256. Import distrusts everything in the file.

Contents of the ZIP

x.pbdeck
├── manifest.json                 (Deflate-compressed)
└── blobs/
    ├── <sha256>.jpg|png|webp     (stored, not recompressed)
    └── ...                       (one entry per distinct image)

manifest.json

Serialized from the private Manifiesto struct (pretty-printed JSON):

Field Type Meaning
format string Must equal FORMATO = "proxy-builder-deck".
version u32 Package version, currently VERSION = 2.
deck Deck The whole Deck-Model value, as JSON (cards, settings, default back, decklistText, marks, designs...).
colecciones Vec<Collection> The designer collections referenced by the designs in the deck (design.collection). #[serde(default)].
biblioteca optional Library data: etiquetas ([{nombre, color, icono?}]; icono, an Iconify id, is written only when the tag has one), notas (string), idioma (optional language override), linaje, actualizado and portada (see «Import options, lineage and «without proxies»»), listas (the game table's game lists, [ListaDeJuego] with copy ids of this deck; omitted when empty). #[serde(default)]; every inner field also defaults.

Short example of the shape (abridged):

{ "format": "proxy-builder-deck", "version": 2,
  "deck": { "id": "...", "name": "...", "cards": [ ... ], "settings": { ... } },
  "colecciones": [],
  "biblioteca": { "etiquetas": [{ "nombre": "Tournament", "color": "red" }],
                  "notas": "", "idioma": "es" } }

Which blobs are packed

blobs_del_deck(deck) returns the set (a BTreeSet, so order is stable) of hashes of: the default back, every card front, every Dfc/Custom back, and every designer image (CardDesign::blobs(): art, "crop" source blobs, own emblem, "before" front and back). escribir adds the emblem of each exported collection. Each is written once as blobs/<hash>.<ext> where ext comes from the stored file.

What does not travel

Not included Consequence on the other computer
Imported fonts and frame packs (app_data_dir/fuentes, marcos/) Designs fall back to the built-in ones ("the house" fonts and packs).
Favourite flag Not part of Biblioteca. (The chosen cover card does travel, as portada.)
Card ids Regenerated on import.
Scryfall metadata cache Not needed: CardMeta is inside each card.

Version compatibility

Package version What it adds
1 Deck + images.
2 Designer data (DeckCard.design) and the collections they use (colecciones).

biblioteca (tags, notes, and later the language override, the game lists, the lineage, its time and the cover) was added without bumping version: it is optional, and serde ignores unknown fields in older apps, which simply do not see it. The same pattern is used for later Deck fields (see Deck-Model).

Import rules, in order (package::import):

  1. A ZIP that cannot be read is InvalidInput (motivo = zip); an IO error is Io.
  2. No manifest.json, or one that does not deserialize into Manifiesto: InvalidInput, motivo = manifiesto. Note the parse happens while scanning entries, before the version check, so a package from a future app whose deck JSON no longer fits the current types reports manifiesto, not "unsupported". Inferred from the order in the code.
  3. format != "proxy-builder-deck": InvalidInput, motivo = formato.
  4. version > VERSION: Unsupported with params.version. This is how a 1.x app rejects a v2 package (it would otherwise lose the designs on its next save).
  5. A package with a lower version imports normally; missing newer fields take their serde defaults.

The package version is independent of DECK_SCHEMA_VERSION (both happen to be 2 now). The deck's own schema_version inside the manifest is not checked on import; decks::save stamps the current value.

Export flow

flowchart TD
    A["menu-exportar.tsx / library-screen.tsx: guardarAhora()"] --> B["guardarParaExportar(): pick .pbdeck path"]
    B --> C["deck_export_package(id, path)"]
    C --> D["decks::open: deck from the database"]
    D --> E["package::export"]
    E --> F["escribir: manifest + collections + tags/notes/language"]
    F --> G["for each hash in blobs_del_deck + emblems: BlobStore::locate, read file"]
    G --> H["ZIP written to a temporary file next to the destination, a few images at a time"]
    H --> I["sync, then rename over the destination"]
    I --> J["returns size in bytes to the UI"]
Loading

Details:

  • The screen first flushes pending edits (guardarAhora()), then asks for a destination through the shared dialog helper guardarParaExportar (src/lib/dialogos.ts, which remembers the last export folder), then calls the command. Two entry points: the "..." menu next to "Export PDF" (src/features/export/menu-exportar.tsx) for the open deck, and the library screen's per-deck action (src/features/library/library-screen.tsx).
  • The export reads the deck from the database (decks::open), not from the screen, which is why the flush comes first.
  • A package has no size limit, so it is never held whole in memory. The ZIP is written to a temporary file beside the destination, a few images at a time, then synced and renamed; a crash never leaves a partial .pbdeck. Past 4 GiB it becomes ZIP64 by itself (un_paquete_de_mas_de_4_gb_va_y_vuelve). A missing blob file is NotFound (params.blob).
  • Deterministic output: entries use a fixed timestamp (zip::DateTime::default()), blobs come in sorted order, so exporting the same deck twice yields byte-identical files (test el_mismo_deck_da_el_mismo_fichero).
  • Images are Stored, the manifest is Deflated: images are already compressed.

Import flow

flowchart TD
    A["library-screen.tsx: abrirParaImportar(.pbdeck)"] --> B["deck_import_package(path)"]
    B --> C["package::import(reader)"]
    C --> D["for each ZIP entry: leer_entrada"]
    D --> E{"name"}
    E -- "manifest.json" --> F["parse Manifiesto"]
    E -- "blobs/xxxx.ext" --> G["validate name, SHA-256 must match"]
    E -- other --> H["ignored"]
    F --> I["check format and version"]
    G --> I
    I --> J["every image the deck needs is in the zip or already local?"]
    J --> K["BlobStore::put_bytes for each image (origin taken from the deck)"]
    K --> L["collections: add those whose id does not exist locally"]
    L --> M["new deck id + new card ids, rename if the name exists"]
    M --> N["decks::save"]
    N --> O["tags, notes, language, game lists"]
    O --> P["returns the new Deck, library reloads"]
Loading

Step-by-step behaviour:

  • Entries. leer_paquete first walks the ZIP's index without decompressing anything, and reads only the manifest (at most 128 MiB, MAX_MANIFIESTO). The images are read afterwards, one at a time, and only those the deck uses and this computer lacks: what is in memory is the largest image, not the deck. Names pass through ZipFile::enclosed_name, which rejects .. and absolute paths (zip-slip), then backslashes are normalised. Unsafe names fail with InvalidInput (motivo = ruta). Entries other than manifest.json and blobs/... are silently ignored.
  • Blob entries. The name must be blobs/<hash>.<ext> with a valid 64-hex <hash> and no extra slash (es_huella); otherwise ruta. The bytes are hashed and must equal the name's hash or the whole import fails (motivo = huella, params.blob). Images are stored as they pass, so one failing halfway leaves earlier ones in the store with nothing using them; the deck does not exist, and BlobStore::gc collects them. The extension in the file name is not trusted: BlobStore::put_bytes probes the real format from the bytes.
  • Completeness. Every hash in blobs_del_deck(&deck) must either be in the ZIP or already exist locally (blobs.locate), else InvalidInput (motivo = falta_imagen). This check does not cover collection emblems; those are verified later by colecciones::save (blob_missing).
  • Images. put_bytes stores each image (rejecting non-images with Unsupported/Image and sides over 12000 px). The origin is looked up in the deck (deck_origen), so a Scryfall image keeps ScryfallPng/ScryfallLarge (and therefore edge-darkening behaviour); unknown ones become Upload. Existing blobs are deduplicated for free.
  • Collections. A collection whose id already exists locally is not replaced: this computer's version wins. Others are saved through colecciones::save, which also writes the emblem into blob_refs.
  • Deck identity. The imported deck always gets a new uuid and every card a new uuid (so importing the same file twice yields two independent decks). If a deck with exactly the same name exists, it is renamed to "<name> (importado)" (this Spanish suffix is hard-coded in package.rs, which departs from the rule that Rust never writes user-facing prose). With Destino::Reemplazar no deck is created: the existing one is rewritten and keeps its id and name (see «Import options, lineage and «without proxies»»).
  • Library data. Tags: each name is created or matched case-insensitively to an existing local tag; an existing tag keeps its local colour and its local icon; the package's icon is only set on a tag that has none. Notes are applied if non-empty. The language override is applied only if it is one of the supported codes (etiquetas::IDIOMAS), otherwise ignored. Game lists are saved again with a new id and their copy ids translated to the new card ids (nuevos, the map built while renumbering the cards); ids that are not in the deck are dropped (Game Table).
  • Result. The new Deck is returned and the library reloads. The command opens the file with BufReader.

Import options, lineage and «without proxies» (import_con, Opciones)

Since lineage and passing decks (Deck Sharing), importing is package::import_con(reader, blobs, db, &Opciones); import is the same with the defaults. The screen no longer calls a bare import: the library's «Importar .pbdeck», a file opened from another app and a package received from another computer all open one dialog (features/pasar/importar-dialog.tsx) that first reads the file without importing (paquete_leer → package::resumen: name, card count, whether it carries proxies, what it would weigh without them, lineage, time, and a small cover) and then imports with what the person chose (paquete_importar). deck_import_package is still registered (Inferred: nothing on the screen calls it now).

Option Effect
solo_originales The deck goes through proxy_core::compartir::sin_proxies and the collections are dropped; if the deck has no default back, the app's generic one is used. Only the images that remain are stored.
destino: Nuevo A new deck (new ids). It keeps the lineage and time the package carries (a lineage from outside is only accepted if it is a short id, linaje_sano).
destino: GuardarLosDos A new, separate deck: its own lineage, as a duplicate.
destino: Reemplazar(id) The deck with that id is rewritten (see below). Its existence is checked before anything is touched.

New optional fields of Biblioteca (all #[serde(default)], written only when present, so VERSION stays at 2 and an older app simply ignores them): linaje (the deck's lineage, shared by every copy of the same deck), actualizado (the time in ms of the version's last save, on the computer where it was made) and portada (the chosen cover card, by its id in the package). A package without them (from before) imports as a new deck whose lineage is its own id. Which lineage and what «newer» means are explained in Deck Sharing; the table is deck_linaje (Storage and Blobs).

Replacing (Destino::Reemplazar) keeps what belongs to this computer and takes the rest from the package:

  • Kept: the deck's id and name, its tags, favourite, notes, language override and game history.
  • Taken: the cards (with new card ids), the deck's settings and default back, the game lists of the package (the local ones pointed at card ids that no longer exist, so they are deleted first) and the cover if the package has one (otherwise none). The lineage stays and actualizado becomes the package's.
  • The tags, favourite, notes and language of a package are only applied to a new deck; a replaced one is not touched.
  • A failed replace undoes itself. Before rewriting, import_con keeps a Copia of the local deck (the deck, its play lists, its cover and its lineage); if any step after saving the new content fails, Copia::restaurar writes them back and the error is returned. Why not a transaction: each step (decks, listas, etiquetas, linaje) asks for the database connection on its own. A new deck that fails halfway is simply deleted.
  • If the replaced deck is the one open, the screen first lends it to Rust (prestarDeck(id, { conservar: false }), State Stores): pending changes are saved, the autosave does not write over what Rust is writing, and afterwards the deck is reloaded from Rust.

Exporting has matching variants: export_con(…, modo) (deck_paquete_guardar, deck_paquete_compartir), export_en_fichero (writes straight to a file a few images at a time, to send it without holding the ZIP in memory) and tamano_estimado (what it would weigh in a given Modo, without building anything, for the offer). miniatura makes the small JPEG cover. The export of a deck now also writes its lineage, time and cover.

Optimized packages (Modo::Optimizado)

calidades_de lists the images worth trying: only upload PNGs (Scryfall's carry transparent corners), at quality 92, and the «before» images of a design (before.front, before.back), which only serve to remove the design, at 85; an image also used elsewhere keeps the higher one. proxy_core::imaging::compactar::png_a_jpeg then decides from the bytes: it refuses anything under 64 KiB, any real transparency (corners print in the paper's colour), a changed size from an EXIF orientation, and a result that saves under 10 %. A JPEG is never recompressed, so nothing loses quality twice.

The manifest is written after the images because it needs their new hashes (entries are read by name, so order does not matter to any version). cambiar_huellas replaces each hash across the whole JSON rather than field by field, so a reference added to the model later cannot be missed. Two PNGs with the same pixels give one JPEG, and a ZIP cannot hold the same name twice, so each hash is written once. biblioteca.optimizado records it. format and version do not change: to an older app this is an ordinary package with JPEGs in it. The same deck gives the same file. Images are processed in small parallel batches (LOTE_MAXIMO), and avance returning false stops with Cancelled.

Opening a .pbdeck from another app (Android)

The .pbdeck has no MIME type of its own, so the manifest (src-tauri/gen/android/app/src/main/AndroidManifest.xml) has two VIEW intent-filters on the main activity:

  1. content and file URIs with application/zip, application/x-zip-compressed or application/octet-stream and a path ending in .pbdeck (pathPattern only works with a host, hence host="*"). There are four patterns, with zero to three more dots before .pbdeck: Android's PatternMatcher does not backtrack, its .* stops at the first dot, and a name such as my.deck.pbdeck would not match .*\\.pbdeck alone.
  2. The same types for content from the Downloads provider only (com.android.providers.downloads.documents): its URIs are numeric (…/document/msf:1234) with no file name, so the path pattern cannot match. Side effect: the app appears in «Abrir con» for any binary in Downloads, and Rust refuses anything that is not a package when reading it.

The android-extra plugin collects the URI of the intent and abrir.rs stores it and emits pbdeck://abierto; if the app was started by that file, the screen was not listening yet and asks for it once with pbdeck_pendiente. When the activity is recreated from Recents after the process died, Android hands over the original intent again; the plugin ignores an intent with FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY, so the same file does not open the import dialog a second time. Nothing is imported at that point: it opens the import dialog above. The URI is a content://, which fichero.rs copies to the cache before reading (a ZIP needs Seek, and a cloud provider may give a pipe); the copy is deleted afterwards.

Safety limits and trust model

Threat Defence
Zip-slip (../x, /etc/passwd) enclosed_name; rejected with ruta (test una_ruta_con_puntos_se_rechaza).
Tampered or swapped image Hash must match the name (test una_imagen_alterada_se_rechaza). Also protects against mislabelled files.
Zip bomb on a single entry MAX_ENTRADA = 512 MiB per image and MAX_MANIFIESTO = 128 MiB checked against the declared size, and a read never goes past that declared size; tamano error.
Zip bomb across many entries, or a package that does not fit The declared sizes of the new images are added up and compared with the free disk before storing anything (BlobStore::cabe, leaving 200 MB free); sin_espacio error. Memory stays at one image.
A manifest asking for too much validar_manifiesto, before anything is done with it: at most 100,000 cards, a 500-character name, 1 MiB of notes, 1,000 play lists, tags and collections; otherwise manifiesto. Control characters are removed from the deck's name.
A card id that tries to be another path Scryfall::card only asks for ids shaped like a Scryfall id: a deck cannot make the app call another API route.
Non-image payload under blobs/ probe in put_bytes rejects it.
Oversized images 12000 px side cap in put_bytes.
Wrong file type format check; test sin_manifiesto_o_de_otro_formato_no_entra.
Package from a newer app Unsupported.
Deck referencing a missing image Rejected up front (falta_imagen) and again by decks::save (blob_missing).

Known gaps to be aware of (read from the code; Inferred where noted as risk):

  • There is no fixed cap on the number of entries. A crafted package can make the import work hard (it decompresses up to the free disk), but it can no longer exhaust memory. Inferred: the import cannot be cancelled while this runs.
  • Import is not transactional across steps. Images and collections are written before the deck is saved, and tags are applied after. If a later step fails, earlier images stay in the blob store as orphans (garbage collection removes them after the 24 h grace period, see Storage-and-Blobs) and a newly saved collection stays.
  • The user sees a generic error text, because the screen translates by error code only (src/lib/errores.ts, for example InvalidInput becomes "Los datos no son válidos."); the specific motivo is not shown. See IPC-and-Error-Handling.

Tests (src-tauri/src/package.rs)

Test Guards
ida_y_vuelta_en_un_equipo_limpio Round trip into an empty library, new ids, same blob set, second import renamed "(importado)".
las_etiquetas_y_las_notas_viajan Tags merge with existing ones by name, notes and language.
las_listas_para_jugar_viajan_con_los_ids_nuevos A game list travels, follows the new card ids and loses an id that was not in the deck.
un_deck_disenado_viaja_con_su_arte_y_su_coleccion Designs, art, emblem and collection survive, and blob_refs protects the art from GC on the new machine.
el_mismo_deck_da_el_mismo_fichero Deterministic output.
el_linaje_hace_ida_y_vuelta_entre_equipos Export, import elsewhere, export and import back: same lineage and time.
guardar_los_dos_es_otro_linaje_y_un_duplicado_tambien «Guardar los dos» and a duplicate get their own lineage.
un_linaje_raro_de_fuera_se_ignora A crafted lineage is not accepted.
reemplazar_conserva_lo_de_aqui_y_toma_las_cartas_las_listas_y_la_portada What replacing keeps and takes.
solo_originales_no_guarda_las_imagenes_propias_y_pesa_menos Import without proxies.
a package from 3.2.3 without lineage Still imports, as a new deck.
una_imagen_alterada_se_rechaza Hash validation.
un_paquete_estropeado_al_azar_…, un_manifiesto_estropeado_al_azar_…, una_imagen_estropeada_al_azar_… Mutation tests with a fixed seed: a damaged package never panics, an error leaves no deck, and what does get in is whole. 300 rounds in CI, PB_ITERACIONES for more.
una_ruta_con_puntos_se_rechaza Zip-slip.
sin_manifiesto_o_de_otro_formato_no_entra Manifest and format checks.

Where to start when extending

  • Adding data to the package: make it an optional field with #[serde(default)] in Manifiesto or Biblioteca, fill it in escribir, apply it at the end of import. Bump VERSION only when an older app would lose or misread data (as designs did); otherwise keep it unchanged so older apps keep importing.
  • Adding a new place that holds image hashes: include it in blobs_del_deck, otherwise the package will be incomplete and import will fail with falta_imagen.
  • Hardening import: add total-size and entry-count caps in the loop of import.

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

Mobile · Móvil

Frontend · Frontend

Reference · Referencia

Clone this wiki locally