Skip to content

Frame Packs

Pablo Martínez López edited this page Oct 20, 2026 · 15 revisions

Frame Packs

English · Español

A frame pack tells the designer how a card looks: where each piece goes, which colours it uses and which layers are drawn over the artwork. The builtin pack ships inside the app; "Original" (image frames) and "Balatro" (pixel art) are downloaded on demand from pinned, hash-checked sources; users can import their own as a folder or .zip. This page documents the pack format (version 2) as implemented in pack.rs, the shipped packs, the download machinery, the free-font catalogue and the security model.

The user-facing description of the format is docs/packs-de-marcos.md (Spanish); this page re-states it against the code. See Designer-Engine for how packs are consumed by the painter and Designer-Composition for the pack layouts used by multi-piece cards.

Overview

flowchart LR
  subgraph Sources
    B["builtin: embedded in proxy-core (include_str!)"]
    O["Original: Card Conjurer PNGs, pinned commit"]
    D["Downloadable: proxy-builder-packs repo, pinned commit"]
    U["User import: folder or .zip"]
  end
  B --> P["Pack::de_fabrica"]
  O --> L["app_data_dir/marcos/original/"]
  D --> L2["app_data_dir/marcos/id/"]
  U --> L3["app_data_dir/marcos/id/"]
  L --> R["recursos.rs: leer_packs / recargar_pack"]
  L2 --> R
  L3 --> R
  P --> R
  R --> V["Pack::cargar_con_imagenes: validate + huella"]
  V --> E["render::pintar_cara"]
Loading

Code map:

What Where
Format, validation, builtin and Original definitions src-tauri/crates/proxy-core/src/diseno/pack.rs (Pack, PackDef, EstiloDef, Capa, Condicion, Hueco, PackError, VERSION_PACK, BUILTIN, ORIGINAL, ORIGINAL_IMAGENES)
Builtin pack files src-tauri/crates/proxy-core/assets/marcos/builtin/ (pack.json plus SVGs per style and layouts/)
Original definition and PNG list src-tauri/crates/proxy-core/assets/marcos/original/ (pack.json, 4 SVGs, imagenes.json)
Loading/import/removal, PackInfo src-tauri/src/diseno/recursos.rs
Original downloader src-tauri/src/diseno/original.rs
Downloadable packs src-tauri/src/diseno/descargables.rs, manifest src-tauri/assets/packs-descargables.json
Free fonts src-tauri/src/diseno/letras_libres.rs, catalogue src-tauri/assets/letras-libres.json
Commands src-tauri/src/commands/diseno.rs: packs_list, pack_import, pack_remove, fonts_list, font_import, fonts_free_list, font_free_download, original_info, original_download, packs_downloadable_list, pack_downloadable_download, pack_extra_use
UI stores src/features/disenar/marcos-originales.ts, src/features/disenar/packs-descargables.ts, recursos-dialog.tsx
Pack authoring repo proxy-builder-packs, a separate repository (see below)

Pack on disk

my-pack/
  pack.json            required
  <style>/frame.svg    SVG layers (use {{variables}})
  m15/frame{color}.png PNG layers (one per palette)
  symbols/*.png        own mana symbols (optional)
  labels/*.png         label pill backgrounds (optional)

Only .json, .svg and .png files are read (recursos.rs → tipo_de); other files are ignored. Fonts are not part of a pack: the pack names font families and they must be loaded separately (the Balatro download imports its font as part of the pack download).

Loading and validation

Pack::cargar_con_imagenes(ficheros, imagenes) returns a Pack { def, ficheros, imagenes, huella, simbolos } or a PackError:

PackError Cause
Json pack.json not parseable; or a style base that is not one of the four built-in styles; or a layer with both/neither of svg and png; or a cajaReglas layer that is not alone with a zone; or an own symbol that fails to parse
Version version is 0 or greater than VERSION_PACK (2). An older app therefore rejects a v2 pack instead of painting it wrongly. The builtin pack declares version 1; Original and Balatro declare 2
FaltaFichero a layer's SVG, a PNG for any palette (checked for every palette and, for {color1}/{color2}, for every single-colour combination), a label background PNG, the loyalty PNG, an own symbol file, or pack.json itself is missing
FaltaHueco a style lacks one of titulo, tipo, reglas, pie (HUECOS_OBLIGATORIOS)
FaltaEstilo requested style absent (Pack::estilo)
Color a malformed colour (#rgb, #rrggbb, #rrggbbaa accepted), or a missing palette
Expresion an invalid hueco expression

All nine palettes (W U B R G gold artifact land colorless) are mandatory. Hueco colours may be a {{variable}}. Root-level layouts get their layers and colours checked but not their required huecos. App-side, recursos.rs::de_pack maps each error to AppError InvalidInput with motivo: "pack_invalido" and params (falta, estilo, hueco, color, version, detalle, expresion), which the UI translates (see IPC-and-Error-Handling).

Pack.huella is a SHA-256 over every text file then every PNG (sorted by path, each followed by NUL separators). It feeds the render hash, so changing any pack file invalidates cached renders, and keys the PNG caches in render::imagenes_png.

Where packs live and how they are imported

  • Directory: app_data_dir/marcos/<id>/. leer_packs loads builtin (embedded) plus every directory there at startup; a directory that fails validation is silently skipped.
  • Recursos::importar_pack(ruta): reads a folder or .zip (leer_zip: per-file caps of 8 MiB for json/svg and 32 MiB for png, exceeding one fails with InvalidInput / motivo: "pack_grande"; entries with .. are skipped; if everything sits in one top-level folder, that prefix is stripped; the caps are not applied to folder imports), validates, sanitises the id to [A-Za-z0-9_-], rejects an empty id or builtin (motivo: "pack_id"), replaces any existing directory of that id, writes files atomically (escribir_atomico) and registers the pack. Importing the same id again replaces it.
  • quitar_pack(id): refuses builtin and ids containing /, \ or ..
  • PackInfo { id, nombre, importado, estilos, estilos_propios, origen } goes to the UI; importado is false only for builtin. estilos_propios lists own styles with names per language, base and variants (EstiloPropio, VariantePack).

Units, expressions and variables

All geometry is in millimetres on a 63 x 88 card; a style or layout may declare another size with tamano: [w, h]. The engine scales to the output pixels, so the preview, 300/600/900 DPI PDFs and PNG export come from the same render.

Expressions (pack::evaluar, Valor): any hueco or layer-zone coordinate is a number or a string that is a sum/subtraction of numbers and variable names ("yTipo+1.6", "80-yTipo"). No parentheses, no multiplication. Available variables, only when the style has a dynamic rules box (caja): yReglas (top of the rules text), altoReglas (height of the rules text), yTipo (top of the type bar). If a variable is missing (fixed box) the coordinate evaluates to 0: a hueco with zero width/height is not drawn, and a PNG layer whose zone cannot be evaluated is skipped.

SVG variables (rellenar, unknown names become empty): replaced in layer SVGs before rendering.

Variable Meaning
{{defs}} gradient definitions: url(#marco), #brillo, #panel, #panelfr, #crema, #lateral, #vetas, #bisel, #barra (put inside <defs>; see render::defs)
{{claro}} {{medio}} {{oscuro}} {{tinte}} {{caja}} palette colours (or the mix of two palettes)
{{fondoCaja}} {{opacidadCaja}} {{tintaCaja}} rules-box fill, opacity and the text ink that reads on it
{{refuerzoCaja}} 0..1, how much more opaque the box was asked to be (for image frames with the box baked in: draw a black rect under it with that opacity)
{{tintaCrema}} {{tintaFr}} ink on cream and on the P/T box
{{yReglas}} {{altoReglas}} {{yTipo}} {{yTipo04}} dynamic-box geometry. In SVGs yReglas/altoReglas are the whole box (top and full height); in hueco expressions they are the text area. yTipo04 is yTipo + 0.4

pack.json reference (version 2)

Root (PackDef)

Field Type Meaning
id, nombre, version string, string, int identity; version 1 or 2
paletas map the nine mandatory palettes (below)
letras map role to font roles titulo tipo reglas fr pie cinta (below)
estilos map id to style built-in ids borderless extended classic fullArt plus own ids
layouts map name to full style complete layouts valid for any style (below)
colores map palette to letter what replaces {color} in PNG paths; defaults W U B R G, gold to M, artifact to A, land to L, colorless to C (Card Conjurer letters). Balatro maps them to words (white, blue, …)
origen string where the images come from, shown in the UI
escalado suave / pixel default PNG scaling (below)
simbolos map code to file own mana symbols (SVG or PNG), shadowing Scryfall's; absent codes fall through
etiquetas map value to label label pill per value (common uncommon rare mythic special bonus legendary plains island swamp mountain forest wastes)
insignias {fondo, borde, texto, pngLealtad} colours of section badges and an optional loyalty-badge PNG
coloresTexto {resalte, habilidad} colour of [[highlight]] spans and of ability words in rules. Without it the markers are stripped and everything is normal colour
sustituir map char to char characters the pack font lacks and their replacements, applied only with this pack (set into Maquetador.sustituir)
plantillaPie map lang to template the pack's default footer per language (falls back to en). Used only when the collection's template is the factory default (pie_del_pack)
separacionCoste float gap between cost symbols as a fraction of symbol side (default 0.05)

Palettes (PaletaDef)

Each of W U B R G gold artifact land colorless: claro, medio, oscuro (frame highlight, body, shadow; the vertical gradient), tinte (translucent rules box), caja, tinta (light rules box and its ink). Colour choice: one colour gives its palette; two give a gradient of the two palettes; three or more give gold; none give artifact, land or colorless. The user can force another (FrameColor).

Fonts (LetraDef)

familia, peso (default 400), mayuscula: cap height divided by the em body. Text is sized by cap height, which is what the eye sees: font size = hueco.mayuscula(mm) / letra.mayuscula. The family must be loaded in the engine.

Styles (EstiloDef)

Field Meaning
tamano [w, h] mm canvas, default 63 x 88
arte artwork window (a hueco; accepts box variables)
caja dynamic rules box (CajaDinamica, below)
cajaPorDefecto tinte (translucent, card-coloured, 0.80 opacity) or clara (light, opaque)
capas layers painted over the artwork, in order
fondo layers painted under the artwork (paper of a card whose art does not fill the window)
huecos text slots (below)
layouts style-level layout overrides (ficha, alto, or the ids of variants)
radio screen corner radius in mm (default 3); print is square unless the fine-tuning radius is set
coronaLegendaria legendary cards get the crown automatically; default only for borderless
ventanaArte art window mask: {x, y, w, h, radio, soloAbajo, repintar, repintarTras}
nombre display name per language (own styles)
base the built-in style this own style inherits behaviour from (and falls back to if the pack is missing); default classic; must be one of the four
variantes user-selectable variants: id to names per language
layoutSi [{ "si": {...}, "layout": "id" }]: layouts chosen by card properties; first match wins

CajaDinamica: fondo (fixed bottom edge, mm), altoMin, altoMax, barraTipo (type bar height above the box), padArriba, padAbajo, altoComodo (height up to which the box grows before the font starts shrinking, down to 75 %). When the text does not fit even at altoMax the font goes down in 0.1 pt steps to 6 pt, then a TextoNoCabe warning is raised.

ventanaArte: black outside the window with corners rounded to radio (or FineTuning.art_radius); soloAbajo rounds only the bottom corners; repintar is a list of [x, y, w, h] zones where an image layer has the curve baked in, and repintarTras the number of layers after which those zones are repainted with artwork or black when the user sets a radius. The window is painted above the artwork and below the layers.

Layers (Capa)

Field Meaning
svg path of an SVG with {{variables}}, drawn over the whole canvas
png path of a PNG; {color} becomes the palette letter, {color1} / {color2} the first and second card colour in WUBRG order (colourless letter if none; both equal with one colour), used for the colour squares near the title
en zone {x, y, w, h} (each may be an expression) the PNG is stretched to; absent means the whole canvas
tramos [top, bottom] mm: those slices (at the scale of the zone width) are drawn without distortion and only the middle stretches (growing boxes with corners)
cajaReglas true with en and no png/svg: paints the requested rules-box tint there when the design asks for a dark, light or custom box (image frames have the box baked in). The rules text then takes the ink that reads on that tint
colores per-layer palette-to-letter overrides
si condition (below)
escalado suave or pixel for this layer
mezcla normal, oscurecer (Darken, e.g. scanlines) or multiplicar
opacidad 0..1

A layer is either SVG or PNG (exactly one) or a cajaReglas marker. A layer file missing in a pack falls back to the builtin pack's file of the same path at paint time.

Conditions (Condicion, the si field)

All present keys must hold (absent keys are ignored). Facts are computed by render::hechos_de and pintar_cara.

Key True when
corona the crown is on (legendary rule or forced)
fr the face has power and toughness
cinta the original name goes in the ribbon
emblema an emblem is set
tipo any of these (English, lower case) appears among the words of the English type line: land, creature, planeswalker, artifact, enchantment, instant, sorcery, battle, token…
basica basic land (type line contains basic) or not
legendaria legendary (type line or legendary frame effect)
colores [min, max] number of colours of the face (a colourless land uses its identity)
simbolos [min, max] number of symbols in the mana cost ([3, 3] means exactly three)
variante the user picked that variant

Text slots (Hueco)

Required in every style: titulo, tipo, reglas, pie. Used when present: coste, fr, cinta, emblema, etiqueta (label pill), simbolo (full-art colour symbol), mano and vida (Vanguard).

Field Meaning
x y w h rectangle in mm (expressions allowed)
rol font role
mayuscula capital height in mm (default 1.78)
color #rrggbb[aa] or a variable such as {{tintaCaja}}
alinear inicio, centro, fin; on coste, centro centres the symbols
sombra soft shadow behind the text
versalitas small caps (token titles)
mayusculas upper-case the text (symbols untouched)
alinearV vertical placement in a fixed box: inicio, centro (default), fin
tabs, interlinea footer only: tab stops (mm) and line pitch (default 1.6)
contenido on etiqueta: rareza (default), supertipo ("Legendary"), subtipoTierra (basic land type)

The title and type line squeeze horizontally before shrinking; see Designer-Engine. Footers use \n for lines, \t for columns (with tabs each column is squeezed to its tab, otherwise columns follow each other and the whole line is squeezed down to half) and everything after {>} is pinned to the right. If the template has none of {credit}, {universe}, {year} they are appended after {>} on the last line (e.g. "Universe · (c) 2026 Credit").

Layouts

Two kinds:

  • Style layouts (EstiloDef.layouts[name], a LayoutDef): arte, caja, capas, fondo, huecos. Fields present replace the style's wholesale (a capas list replaces the whole list; a hueco replaces that hueco, others stay).
    • ficha: tokens, emblems, double-faced tokens (centred small-caps title…).
    • alto: taller rules box for section bodies (planeswalker, class, case, leveler, Station). Optional.
    • the id of a user variant, applied automatically when it is chosen.
  • Pack layouts (PackDef.layouts[name], complete EstiloDefs valid for any style): saga, mazmorra, secuelaArriba (63 x 51), secuelaAbajo (33 x 63), fusionar (88 x 6), flip, batalla (88 x 63), plano (127 x 88.9), plan and vanguard (88.9 x 127), pie (footer only). Split, Fuse and Room halves need no layout of their own.

Resolution order (render::estilo_para): own style from the pack (else warning EstiloNoEnElPack and the built-in style), else the pack's built-in-style entry, else the builtin pack's, else the pack's borderless. Layout request priority: the card's own request (ficha, saga…), then the chosen variant if the style has a layout with that id, then the first matching layoutSi. A missing token layout falls back to the builtin pack's style silently; any other missing layout falls back to the builtin pack's complete layout and, if none exists, PackSinLayout is reported (not for alto).

Scaling (Escalado)

suave (default): Lanczos3, for painted or photographed frames. pixel: when enlarging, nearest-neighbour so every pixel is repeated; when reducing, box-filter average (render::reescalar_pixel, via fast_image_resize). Set at pack level or per layer; own symbols follow the pack flag. This is what keeps pixel art crisp at 300 to 900 DPI.

The builtin pack

Location: src-tauri/crates/proxy-core/assets/marcos/builtin/, embedded with include_str! through the fichero! macro (BUILTIN list in pack.rs), so it is part of the binary. Pack::de_fabrica() builds it; Pack::de_fabrica_compartido() caches one copy for fallbacks.

  • Id builtin, version 1, nine palettes, fonts Spectral (titles, type, rules, P/T, ribbon) and Montserrat (footer).
  • Four styles: borderless (art 63 x 83 with a growing rules box fondo 83, altoMin 12, altoMax 27.4, barraTipo 6, art window with a 6 mm radius rounded at the bottom, crown, ribbon, title bar and P/T SVGs; layouts ficha, alto), extended and classic (frame SVGs marco.svg / marco-alto.svg; layouts ficha, alto), fullArt (landscape basics: strip plus colour symbol, no layouts).
  • Eleven pack layouts: saga, mazmorra, secuelaArriba, secuelaAbajo, fusionar, pie, flip, batalla, plano, plan, vanguard.
  • Because every other pack falls back to it, it must keep working as the reference implementation of the format.
  • Tests that guard it: dorados.rs (golden images), layouts.rs, and the unit tests in pack.rs and render.rs (un_pack_de_antes_sin_el_layout_usa_el_de_la_casa, un_estilo_que_falta_sale_del_pack_de_la_casa, etc.).

"Original" frames (Card Conjurer)

Decision: the M15 image frames from the Card Conjurer project are not shipped in the repository or installers. They are downloaded on first use and verified.

  • Definition: assets/marcos/original/pack.json (id original, version 2, origen = Card Conjurer), four SVGs (cinta.svg, cubierta-corona.svg, refuerzo-caja.svg, sombra-pie.svg) and imagenes.json: { commit, base, imagenes: { path: sha256 } }. Currently 51 PNGs under m15/regular (16), m15/borderless (9 plus 8 P/T), m15/crowns (18), pinned to a 40-hex commit of the public repository, base img/frames/.
  • Two styles: borderless (full-bleed "showcase" frame, crown overlay, P/T box; cajaPorDefecto: tinte, ventanaArte with repintar zones and repintarTras: 4, coronaLegendaria on) and classic (regular M15 frame, cajaPorDefecto: clara). Both use fixed rules boxes baked into the image plus a cajaReglas layer, not the dynamic box; other styles and all layouts come from the builtin pack through the fallback chain.
  • Download (original.rs): URL = https://raw.githubusercontent.com/Investigamer/cardconjurer/<commit>/<base><path>. descargar always rewrites the definition files with the version embedded in the app, lists the images whose on-disk SHA-256 does not match (pendientes/bueno), fetches those one by one, rejects any file whose SHA-256 differs (ErrorCode::Network with fichero), writes atomically into app_data_dir/marcos/original/, reports progress, and calls recargar_pack("original"). Progress event: original://progress with { done, total }. info() returns OriginalInfo { listo, faltan, total }.
  • leer_packs calls original::definicion_al_dia on every start so the pack definition on disk always matches the app version even if the images were downloaded long ago.
  • Tests (original.rs): the list is well formed (40-char commit, trailing-slash base, more than 40 images, .png paths without .., 64-char hashes); a file with another hash is rejected; an old definition is rewritten; nothing downloaded means all pending. en_vivo (ignored) downloads for real.

Downloadable packs

descargables.rs + assets/packs-descargables.json deliver packs hosted in the separate packs repository. Unlike Original, everything (including pack.json) comes from that repo.

Manifest shape (Manifiesto { repo, packs: Vec<Descargable> }):

Field Meaning
repo https://raw.githubusercontent.com/PoolFlamingo/proxy-builder-packs
packs[].id, nombre pack id (also the install directory) and display name
commit 40-hex commit of the packs repo; every URL is <repo>/<commit>/<carpeta><path>
carpeta pack folder inside the repo (balatro/)
ficheros map relative path to SHA-256: pack.json, layers/, symbols/, labels/ (128 files for Balatro)
letras fonts: ruta, sha256, licencia, licencia_sha256
extras extra images: ruta (prefixed extras/), sha256, tipo (reverso, ficha, tierra, carta), nombre (137 for Balatro: 82 ready cards, 50 lands, 3 tokens, 2 backs)

Behaviour:

  • lista(state) returns DescargableInfo { id, nombre, listo, faltan, total, bytes, extras } for each pack that is disponible: its commit must be a real 40-hex hash (publicado), or, in debug builds only (cfg!(debug_assertions)), PB_PACKS_LOCAL must be set. listo means everything on disk matches its hash and the pack is loaded; extras is only listed once ready.
  • descargar(state, id, progreso): computes pending tasks (files plus extras whose on-disk hash differs) and missing fonts (font id equals file hash, compared with the imported fonts), downloads each through state.externo.bajar(url, MAX_IMAGEN) (15 MiB cap), verifies the hash (comprobar; a mismatch is ErrorCode::Network with fichero), writes atomically, imports fonts with importar_letra_datos and stores each licence under fuentes/licencias/<stem>-LICENCIA.txt, then quitar_lo_que_sobra deletes anything in the pack and extras folders that the new manifest no longer lists (old paths, leftover .part files), and finally recargar_pack. A second run with nothing changed downloads nothing (total == 0), so upgrading to a new pack version fetches only changed files.
  • Locations: pack in app_data_dir/marcos/<id>/, extras in app_data_dir/packs-extras/<id>/ (kept apart so they do not load with the pack).
  • Extras: extra_por_indice (bytes to display, hash re-checked), usar_extra / ruta_extra (to the blob store so the extra can be a card back or a custom card; the pack_extra_use command).
  • Dev override: with PB_PACKS_LOCAL=<folder of the packs repo> (debug builds only), files are read from that folder instead of the network; hashes are still checked.
  • Events: packs://progress with { id, done, total }. UI: src/features/disenar/packs-descargables.ts (usePacksDescargables).
  • Tests: el_manifiesto_se_entiende (repo URL shape, pack.json present, 64-char hashes, and each file URL passes the allowlist url_permitida), una_huella_que_no_cuadra_no_vale, lo_de_una_version_anterior_se_quita; ignored balatro_en_vivo downloads and checks 7 own styles.

Balatro

The only downloadable pack so far (id: balatro, version 2, escalado: pixel). Pixel-art frames themed after the game, built from seven PSD templates, each exposed as an own style: joker, planeswalker, espectral, tarot, planeta, vale, tierra (the ids stay in Spanish because saved designs refer to them; display names are bilingual). Six inherit from classic, tierra from fullArt. It uses:

  • layouts: every style has a ficha layout; tierra also basica, selected by layoutSi when the card is a basic land.
  • 31 own symbols (W U B R G C T, 0 to 20, X Y Z) drawn as poker chips, with separacionCoste 0.122.
  • 13 label pills (rarities, legendary, basic land types) with the text written by the engine in the design's language.
  • coloresTexto (highlight and ability-word colours), insignias (including pngLealtad), sustituir (8 typographic characters mapped to ASCII for the pixel font), plantillaPie (single-line footer) and a dynamic rules box with altoComodo in six of the seven styles (planeswalker uses a fixed box).
  • The font m6x11plus.ttf (free to use with attribution; licence file shipped and stored on the user's machine).
  • Variants are supported by the format, but the current pack.json declares none.
  • Extras: two card backs, tokens, basic lands and ready-made cards of the sample deck.

Credits are in the pack's CREDITS.md (templates by a community artist redistributed with permission; Balatro belongs to its publisher; the cards are proxies for playing, not selling).

The pack authoring repository

Pack content is built and versioned in a separate repository, cloned next to this one (proxy-builder-packs, published as PoolFlamingo/proxy-builder-packs). It has its own working sessions and history, and the app only ever sees it through the manifest. Layout of the repo:

proxy-builder-packs/
  README.md
  balatro/
    pack.json         the pack definition
    layers/<template>/  PNG layers cropped to the card area (common, joker, land, planeswalker, planet, spectral, tarot, voucher)
    symbols/          mana chips
    labels/           rarity, legendary and land-type pills
    fonts/            m6x11plus.ttf and its licence
    extras/{cards,tokens,backs,lands}/   ready-made images
    extras.json       inventory of extras (type and display name)
    CREDITS.md
    source/           convert.py, build.py, manifest.py, inventory.json/.md, references.json, views/

Folders and file names are in English; the style ids and JSON keys stay in Spanish because the app and saved decks read them.

Workflow (from the repo README, run inside balatro/source/ in a virtualenv with psd-tools, pillow and numpy; always python -I):

  1. convert.py <folder with the PSDs> ..: exports every image layer (except the sample art, text and swatches) cropped to the card area of a 1365 x 1855 canvas, with each layer's zone in mm, and writes inventory.json/inventory.md. Text layers are recorded with box, size, colour and alignment. The visible layers are recomposed and compared with the PSD's own composite as a check.
  2. build.py .. <folder with the original extras> <m6x11plus.ttf>: generates symbols, labels, the layers/common colour boxes, copies fonts and extras (cropped without the 3 mm bleed) and writes pack.json. It deletes the layers it does not use, so run convert.py again before re-running it.
  3. manifest.py .. <commit>: prints the app's manifest entry for the pack (every file's SHA-256, the font list with licences, extras with extras/ prefix and hashes). It skips source/, extras/, fonts/, golden/, extras.json, README.md, CREDITS.md from ficheros.

Publishing a new pack version:

  1. Commit and push the pack changes to the packs repo (the commit must exist on the remote because the app downloads by commit).
  2. Run manifest.py with that full commit hash and replace the pack's entry in src-tauri/assets/packs-descargables.json.
  3. Run the app's tests (el_manifiesto_se_entiende) and, optionally, the ignored balatro_en_vivo.
  4. Ship an app release; players get the new files the next time they open the pack download (only changed files are fetched).

During development use PB_PACKS_LOCAL with a placeholder (non-40-hex) commit in the manifest to test before pushing. The engine-side tests that need the pack contents (balatro_con_sus_imagenes, balatro_matriz, balatro_contra_el_autor) take the pack folder through PB_BALATRO (see Designer-Engine).

Free fonts catalogue

src-tauri/assets/letras-libres.json lists 22 OFL-licensed families from the Google Fonts repository, pinned to a commit, with the SHA-256 and size of each file and of each licence. They are downloaded by a button in the resources dialog, are not in the repo or the installers, and are imported like any user font.

Entry (Familia): id, familia (the name used in collections and packs), uso (UsoLetra: titulo, reglas, ambos), cursiva (has an italic, used for reminder and flavour text), ficheros: [{ ruta, sha256, bytes }], licencia: { ruta, sha256 }. Root: commit and base (ofl/).

Selection rule: legible at card sizes, no hairline strokes. Families: Cinzel, Almendra, Uncial Antiqua, Grenze Gotisch, Eczar, Amarante, Metamorphous, Inknut Antiqua, Bitter, Domine (titles); Grenze, Caudex, Cardo, Vollkorn, Libre Baskerville, Literata, Merriweather, Lora, Pixelify Sans (both; Pixelify Sans is the free alternative to the Balatro font); Crimson Pro, Source Serif 4, PT Serif (rules). Preview images are in src/assets/letras/<id>.png.

Flow (letras_libres.rs):

  • lista(state): LetraLibre { id, familia, uso, cursiva, descargada, bytes }; descargada when every file's SHA-256 is already among the imported fonts (font id equals file hash).
  • descargar(state, id): for each file not yet present, fetch https://raw.githubusercontent.com/google/fonts/<commit>/ofl/<ruta>, require the pinned SHA-256, import with importar_letra_datos(datos, "ttf") (stored as fuentes/<sha256>.ttf), then fetch and verify the licence and write it to fuentes/licencias/<id>-OFL.txt. Any mismatch aborts with ErrorCode::Network.
  • Tests: el_catalogo_esta_bien_escrito (40-char commit, at least 10 families, unique ids, each path starts with its id and ends .ttf with no .., 64-char hashes, licence ends OFL.txt), sin_descargar_ninguna_esta; ignored los_ficheros_son_los_del_catalogo (needs PB_GF, a local copy of the Google Fonts repo; checks hashes and that each file registers the declared family) and en_vivo.
  • Adding a family: copy its files and licence from the Google Fonts repo at the (pinned or a new) commit, compute SHA-256 and size, add an entry, add a preview PNG, run the ignored hash test with PB_GF. If the commit changes, every entry must still match that commit.

Security model

  • Pinned content. All remote pack content is addressed by a full commit hash and verified by a SHA-256 stored in the app binary (imagenes.json, packs-descargables.json, letras-libres.json). A force-pushed branch, a changed file or a tampered response is rejected (ErrorCode::Network), so a compromised host cannot inject images or fonts.
  • Atomic writes. Files are written through escribir_atomico (temp then rename), so a crash leaves no half file that could pass as complete; the next run re-checks hashes.
  • Allowlist for the packs repo. descargables.rs goes through state.externo (fuentes::Externo), which only accepts the closed list PERMITIDOS in src-tauri/src/fuentes/mod.rs (the packs repo entry is raw.githubusercontent.com with path prefix /PoolFlamingo/proxy-builder-packs/), re-checks every redirect and caps each download at MAX_IMAGEN (15 MiB). A unit test asserts every manifest URL passes the list.
  • Original and fonts. original.rs and letras_libres.rs use their own reqwest client with compile-time fixed repository URLs (Card Conjurer, Google Fonts) and the pinned hashes, not the Externo allowlist. Their URL prefixes are not user-controllable.
  • Web view. Pack content is fetched by Rust; the web view never goes to the network (its CSP in tauri.conf.json only allows connect-src to the IPC endpoints), it only displays rendered images via pbimg://. See Architecture and External-Sources.
  • User packs. Only .json, .svg, .png are read; size caps apply to zips; the id is sanitised and builtin is reserved; paths containing .. are skipped. A user pack is trusted to the extent that its SVGs are rasterised by resvg; Inferred: a hostile SVG can at worst produce wrong pixels or waste CPU, as no script engine is involved.
  • Licensing. Downloaded fonts keep their licence text next to them in fuentes/licencias/.

Notes on the existing documentation

docs/packs-de-marcos.md is the maintained authoring guide in Spanish. Statements checked against the code on this page: pack version rules (1 or 2), required palettes and huecos, si keys, layout names and sizes, PB_PACKS_LOCAL. Small drift noticed: the guide says the Original pack ships "three own SVGs" while ORIGINAL in pack.rs lists four; the module comment of descargables.rs mentions fuente/manifiesto.py while the script in the packs repo is source/manifest.py.

Where to start when extending

Goal Start at
A new field in pack.json pack.rs (add with #[serde(default, skip_serializing_if…)], bump VERSION_PACK only if an old app would paint it wrongly), consume in render.rs, document in docs/packs-de-marcos.md and here
A new si condition Condicion, Hechos and Condicion::cumple in pack.rs, fill Hechos in render::hechos_de
A new downloadable pack build it in the packs repo, add its manifest entry, ensure its URL prefix is allowed in PERMITIDOS
A new pack layout add it to builtin pack.json and its SVG, add a case in tests/layouts.rs (Designer-Composition)

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