Cards¶
dirty-dirac does not parse card text and does not hand-write cards. Its
generator, kgen, reads gorge's compiled card IR and lowers each card into
programs over a small integer op vocabulary, kop; a card pool becomes a
generated Go package. This page explains that vocabulary, the lowering and its
refusal contract, how the card grammar is being closed across the whole
corpus, the layer system, what is generated and what is written by hand, and
how pool packages are found at run time. It is for readers who know gorge's
card pipeline and want to see how the
same cards reach the fast kernel.
Counts on this page were read on 2026-10-21 from the dirty-dirac source of
that day: the kop enums, and the acceptance ratchet's pinned counts.
The pipeline¶
The key decision is the second step's input: kgen lowers gorge's compiled IR, not the Forge script. The kernel therefore inherits gorge's reading of every card: keyword expansion, SVar resolution and parameter meaning are gorge's. That keeps the two engines comparable (the oracle can demand equal behaviour) and avoids a second script parser. It also means a misreading in gorge is a misreading in dirty-dirac; see the independence caveat.
kgen runs in two modes. Given a deck pool or a card list it generates a
cards_<pool> package that a kernel game can play. Given any card list, the
whole corpus included, it can also lower every card without writing a
package and report which ones it would accept: that is how corpus and per-set
acceptance are measured.
The kop vocabulary¶
A card is a kop.CardDef: printed cost, types, subtypes, keywords, colours,
power, toughness and loyalty, a few native fields (kicker and flashback costs,
ward, equip cost, the Aura's enchant target, protection, landwalk, the faces
of a multi-face card) and a list of kop.Ability. An ability is one of seven
kinds (spell, activated, mana, triggered, static, replacement, alternative
cost) and carries its cost, its trigger subscription or static record, up to
three target slots, modes, an optional threshold gate (a Class level band) and
a program: a flat slice of instructions.
type Instr struct {
Op Op // one of 128 ops
Obj Ref // who or what it acts on: a target slot, the source, the controller, each opponent, ...
A ValueRef // an amount: a constant, X, a count of a filter, last damage, a power, a side-table expression
B ValueRef // a second amount
C int16 // an op-specific constant: a keyword mask, a counter kind, a token, a zone, a skip count
F FilterID // a filter in the table, for the "all", search and choice ops
}
Two cards as kgen emits them, abridged:
var shivanDragon = kop.CardDef{ // {4}{R}{R}, 5/5 flying
Cost: kop.ManaCost{Generic: 4, Pips: [6]uint8{0, 0, 0, 2}},
Types: kop.TCreature, Keywords: kop.KwFlying, Power: 5, Toughness: 5,
Abilities: []kop.Ability{{
Kind: kop.AbActivated, // {R}: +1/+0 until end of turn
Cost: kop.Cost{Mana: kop.ManaCost{Pips: [6]uint8{0, 0, 0, 1}}},
Prog: []kop.Instr{{Op: kop.OpPumpUntilEOT, Obj: kop.RefSelf, A: kop.Const(1)}},
}},
}
var burstLightning = kop.CardDef{ // {R} instant, kicker {4}
Types: kop.TInstant, Keywords: kop.KwKicker, Kicker: kop.ManaCost{Generic: 4},
Abilities: []kop.Ability{{
Targets: [3]kop.TargetSpec{{Kind: kop.TgtAny}}, NTargets: 1,
// 2 damage, or 4 if kicked: an expression in the table's side tables
Prog: []kop.Instr{{Op: kop.OpDealDamage, Obj: kop.RefTarget0, A: kop.ValueRef{K: kop.VExpr, N: 3}}},
}},
}
What is in the vocabulary:
| Enum | Values | Examples |
|---|---|---|
Op (a uint8) |
128: 61 in the original vocabulary, the rest appended by kgen | DealDamage, Draw, Destroy, CreateToken, Scry, Fight, Counter, Charm; appended: UnlessPaid, SearchEx, Override, CopySpell, GiveGift, ClassLevelUp, Proliferate, Explore, Saddle |
TrigEvent |
25 | enters, dies, leaves, to graveyard, attacks, phase, spell cast, life gained, damage dealt, becomes target, drawn, discarded, counters added, blocks, sacrificed, taps, mana expended, gift given, token created |
StaticKind |
17 | continuous P/T and keywords, cost changes, can't block, may play from a zone, override, player hexproof, can't be cast, control (layer 2) |
ReplKind |
18 | enters tapped, enters with counters, as-enters choice, double damage, damage plus N, prevent noncombat damage, double tokens, double counters, life doubled, life plus N, graveyard-to-library |
KwMask (a uint32) |
32 of 32 bits used | flying, first strike, deathtouch, ward, protection from black or red, fear, shadow, ... |
CounterKind |
21 counter kinds plus a Class level | +1/+1, -1/-1, loyalty, stun, shield, flying, ...; named kinds with no rules meaning of their own (charge, oil, quest, ...) |
Every enum is append-only: generated tables encode values by ordinal, so a
new op goes immediately above the count constant, never in the middle. The
keyword mask is full and is not widened (that would grow every permanent):
further keywords are either lowered into abilities, statics and effect
records, or carried as card fields. The interpreter contract is in kop's
package doc:
- A resolving ability runs its program front to back against the stack entry's source, controller, targets, modes, X and kicked flag.
- An instruction whose target has become illegal is a no-op; if every target is illegal the whole program is skipped (CR 608.2b).
- Branch ops (
Charm,IfKicked,SkipIf,Repeat, ...) are structured: they skip or loop over the instructions immediately following them. - Decision-raising ops (choose a colour, a card, scry, search, dig, ...) suspend the resolution; the kernel poses a decision and resumes at the same instruction (Step and Apply).
- An op the interpreter does not implement panics, naming itself. It is never a silent no-op.
The variable-length parts live in the table, not in the state: a
kop.Table holds the cards, the filters, the subtype names, and four side
tables kgen fills (value expressions, conditions, override records, emblems),
plus the mapping from gorge's 362-word creature-type vocabulary to the table's
subtypes.
Lowering: read every parameter or refuse¶
kgen walks each card's IR: its abilities and their SubAbility chains, its
triggers, statics and replacement effects, its keywords and SVars. Each node's
$ parameters are wrapped in a tracker that records which ones the lowering
read. When a node is done, any rules parameter left unread refuses the
card, with the node and the reason. Only descriptive and AI-hint parameters
(SpellDescription, AILogic, TgtPrompt, ...) are ignored by name.
The same rule holds inside a parameter's value. A filter property, a value
head, a value modifier (/Plus.Y, /Times.X) or a cost token that kgen
cannot parse is a refusal, never a default: kgen never lowers an unparsed
amount as 0 or an unknown property as "anything". A test feeds every modifier,
parameter and value-head switch an unknown token and asserts a refusal. This
rule exists because the one class of silent mis-lowering found so far (a
modifier that named an SVar and was read as +0) produced games that agreed
with gorge until the missing amount mattered.
Typical refusals, from the BLB coverage report: an unknown filter property
(HasCounters, TopLibrary), an unread parameter (Optional on a mill, a
ClassBand on a static), a value modifier it cannot evaluate, a replacement
shape it does not model.
A refused card is not dropped from the package. It is emitted as an inert fallback: printed cost, types, subtypes, colours and P/T, no abilities. Two consequences:
Matchuprefuses a deck that contains one, so a kernel game never plays a card as vanilla by accident.- The whole-pool table (
Pool) still has a row for it, so a search world that deals hidden cards from a belief over the pool can deal it, as a placeholder.
The generator also writes OPS.md, the interpreter contract for every op,
enum value and field it appended, with the cards that use each one, and a test
fails if an appended op has no entry. Another test regenerates every pool into
a temporary directory and diffs it against the checked-in package, so the
checked-in tables can never drift from the generator.
Stable card IDs. ids.tsv is a checked-in index of every CardID ever
assigned in the pool. An ID never moves: a new card takes the next free ID, and
a card that leaves the pool keeps its ID as an inert placeholder. Recorded
games and caches keyed by CardID stay valid across regenerations.
Growing the card grammar¶
The kernel was built on FDN, and the first measurement over the whole corpus
showed what that meant: most cards in other sets were refused, and what
refused them was overwhelmingly shared grammar (filter properties,
Defined$ forms, parameters, value heads, common effects and trigger modes),
not each set's named mechanics. Fixing a set at a time would re-pay that
grammar for every set. So the grammar is closed once, corpus-wide:
- Classify. kgen normalises every refusal reason into a class (one per
filter property, parameter, effect, keyword, trigger mode, ...) and tags
each class with the kind of work it needs: generator-only, a keyword
desugaring, a new op or predicate arm in the interpreter, or a new rules
mechanism with its own
Stateblock. The coverage report prints "Refusals by class and tier". - Close in greedy order. Classes are closed in order of how many corpus cards each unlocks. Most of the top classes are generator-only work or a single new op; only a handful (multi-face cards, face-down cards, Crew-style aggregate tap costs) need a mechanism.
- Ratchet. An acceptance test lowers the whole corpus and the card list of each registered set, and pins each accepted count exactly. A change that lowers any count fails; a rise is re-pinned in the same change, so the pins are always the live numbers. Today they stand at 16,044 of 34,072 corpus cards; the per-set pins are on Card support by set.
- Keep the verified pools still. A lowering change must leave the FDN and BLB lockstep results unchanged. A change that moves them is a defect in the lowering, not a new behaviour.
An accepted card is lowered, not verified: it plays in lockstep only once it is in a pool that is fuzzed against gorge (Verification). When a card that is accepted turns out to differ from gorge in a later lockstep, the fix is a refusal or a corrected lowering; the ratchet may fall when a class is found to have been accepted too eagerly, and that fall is recorded rather than hidden.
Generated and hand-written¶
| Part | Generated by kgen | Written by hand |
|---|---|---|
| Card definitions, ability programs, filters, expressions, conditions, override records | yes, one Go file per card and token | |
Pool index, name table, Matchup and Pool builders, pool registration |
yes (pool.go, matchup.go) |
|
ids.tsv, coverage report, OPS.md |
yes | |
The kop types and enums |
kgen appends values and fields (append-only) | the original vocabulary and the types |
| The interpreter and every rule (turns, priority, casting, combat, state-based actions, layers) | yes, in kernel |
|
| The generated-table extension: the code behind kgen's ops, statics, replacements and filter predicates | yes, in kernel's *_gen.go files (named for the tables they serve, not generated) |
|
| A tiny test table (basic lands, vanilla and keyword creatures, a few spells) | yes, package vanilla |
The *_gen.go files are worth naming because the suffix misleads: they are
hand-written kernel code that only runs on a generated table. Examples:
- Characteristics: computing a permanent's types, keywords and P/T through the layer system (below).
- Spell copies: a copy is a new card object on the stack with the original's targets, modes, X and kicked status; it is not cast, and it ceases to exist when it leaves the stack (CR 707.10, CR 704.5e).
- Replacement effects, triggers, statics, legality of plays, target slots taking several objects.
On a hand-written table every one of those hooks is skipped. Each newer rules block sits behind its own per-table gate in the same way: a table with no card that needs it pays one predictable branch.
Layers: the effect pool and the CR 613 walk¶
dirty-dirac derives a permanent's characteristics with a small, fixed-size
version of gorge's CR 613 layer system. The derivation applies layers 1 to 7
in order. Within each layer it applies effects in timestamp order and uses
gorge's dependency rules. It allocates nothing, and every mutable byte lives
in State.
The effect pool. An ability that resolved (CR 611.2) records its
continuous effect as an 8-byte fxRec in a fixed pool of 16 in
State. A record names its object, because the affected set is fixed at
resolution (CR 611.2c).
- Records are appended, and
State.tick()is monotonic, so pool order is timestamp order and nothing is ever sorted. - Cleanup drops until-end-of-turn records. The controller's next cleanup drops "until your next turn" records.
- A full pool stalls the game (
StallTables). It never drops an effect silently.
The record kinds are:
| kind | layer | what it holds |
|---|---|---|
fxCopyExc |
1 | a copy's exceptions (Offspring's "except it's 1/1", an added subtype or keyword), copiable by a later copy (CR 707.9b) |
fxControl |
2 | a control change and its duration (end of turn, your next turn, while the source stays, permanent), settled eagerly into the controller |
fxOverride |
3-7b | a kop.Override: set or add types, subtypes and colours, a name, lose all abilities, add or remove keywords, set base P/T |
fxLoseAll |
6 | a one-shot "loses all abilities" and its timestamp |
fxKwGrant |
6 | an until-end-of-turn keyword grant's timestamp, kept only when the table has a static removal (Starforged Sword) or a global lose-all |
Statics stay live. Battlefield statics are not copied into the pool. The walk reads them in place and stamps each one with its source's layer timestamp. An Aura or Equipment is restamped when it becomes attached (CR 613.7e), and an emblem's timestamp is 0. Commutative effects keep their cheap accumulators: +N/+N pumps, counters and anthems (7c) add in any order.
The walk. chars(k, slot, upTo) builds a stack-local dchars, one
layer at a time:
| layer | applied |
|---|---|
| 1 | the card or its copy-of, plus the copy exceptions |
| 2 | control, already settled from the fxControl records |
| 3 | the newest rename whose filter matches the finished layer-4 types (Witness Protection's name) |
| 4 | the changeling CDA first (CR 613.3), then override records, override statics and static subtype grants in gorge's CR 613.8 dependency order (timestamp order unless one effect changes what another applies to; a cycle falls back to timestamp order). Each static's filter is matched against the types built so far. |
| 5 | colour setters by timestamp |
| 6 | printed keywords, keyword counters and copy keywords as the base; then losses, removals and grants by timestamp, with a removal before an addition on a tie, reordered by gorge's abilityDependencyOrder |
| 7a / 7b | the CDA P/T, the copy's set P/T, then base-P/T setters by timestamp |
| 7c | counters, pumps and anthems |
An override static that applied in layer 4 keeps applying in the later layers (CR 613.6). A permanent with at most one order-sensitive effect, or with records only, takes a fixed-order fast path, which gives the same answer. A table with nothing order-sensitive pays one predictable branch per read.
What kgen refuses instead. A layer shape the walk does not implement is refused per card when kgen lowers it, never silently mis-ordered: P/T switching (7d, Twisted Image), layer-3 text changes (Artificial Evolution), P/T set from a dynamic value (so Humility never meets Opalescence), and a few override shapes that could form a 613.8 cycle. Every pair of order-sensitive effects the kernel does accept is ordered by the walk, and each ordering has a scenario whose expected answer comes from gorge's own oracle runner.
Cards that need behaviour no generic op expresses get it in one of three ways, in order of preference:
- a new append-only op or enum value, with its contract in
OPS.md; - an override record;
- kernel-native code keyed by a
kopkind. Ward and granted prowess are kernel-native triggers. Twinflame Tyrant, Crystal Barricade, Doubling Season and Progenitus are fixed replacement kinds.
No kernel code path branches on a card's name. A card's name is in its
CardDef for reports only.
Pool packages and the cardset registry¶
Each generated pool is its own package, cards_<pool>. A pool is generated
either from a deck pool (every distinct card in a directory of deck lists;
FDN) or from a card list (BLB's is every distinct card name on the set's
booster sheets). Besides FDN and BLB there are small constructed pools, each
chosen so that the cards a piece of grammar work unlocked come up in play
under lockstep (Verification).
Each package implements cardset.Set and registers itself under its pool name
in init:
| Method | Returns |
|---|---|
Name() |
the pool name, the registry key |
Lookup(name) |
a card's pool index |
Supported() |
per pool index (cards, then tokens), whether the generated table implements it |
Matchup(a, b) |
a kop.Table for two decks plus each deck as CardIDs; fails on an unsupported card |
Pool(), PoolID(i), InertID(id), NumIDs() |
the whole-pool table and its stable IDs |
AbilityOrigin(i, ab) |
which gorge ability (face ability or trigger index) a kop ability came from, for the oracle |
Names(), Subtypes() |
the name and subtype vocabularies |
The registry is a slice, not a map, so nothing can depend on map order. Tools
select a pool by name (-set fdn).
Sizing the vocabulary: gorge's kcensus¶
Before the vocabulary existed, gorge's
cmd/kcensus
measured what it would need. It loads a deck pool, resolves every card through
the compiled corpus, lists the mechanics each card reaches (API codes along
every sub-ability, trigger and SVar chain; trigger, static and replacement
modes; keyword heads; cost tokens), and prints the smallest mechanic set that
fully covers 50, 80, 95 and 100 % of the decks, plus how many decks need
general layer, replacement, copy or control machinery. Its labels use the same
spelling as gorge's coverage ratchet (api:, trig:, stat:, repl:, kw:),
so its output lines up with gorge's own support reports.