No description
  • C++ 80%
  • GDScript 12.1%
  • Python 6.3%
  • Shell 0.8%
  • CMake 0.5%
  • Other 0.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
zclaude 8d07e86393
All checks were successful
ci / test (push) Successful in 1m18s
ci / windows (push) Successful in 59s
ci / arch (push) Successful in 3m56s
Merge S1: multi-table server foundation; fix abandoned-game lockout and post-GAMEOVER auto-deal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:33:20 -04:00
.forgejo/workflows godot: play and watch a CR 723 controlled turn (protocol 1.20) 2026-09-30 11:31:18 -04:00
cards ledger: Jev round 3 evidence for Karn Liberated, Emrakul, Veil of Summer 2026-09-30 12:12:11 -04:00
cmake Record which commit built every binary, log and protocol connection 2026-09-28 00:40:26 -04:00
core M1: add mtg-master, a Quake-3-style server list (drivers/master/) 2026-09-30 18:17:21 -04:00
decks decks/supersetburn.deck: the 60-card burn list as the main deck; the rest as sideboard/maybeboard 2026-09-30 15:06:58 -04:00
drivers Merge S1: multi-table server foundation; fix abandoned-game lockout and post-GAMEOVER auto-deal 2026-09-30 18:33:20 -04:00
packaging packaging/arch/publish.sh: drop the stale never-run note (CI publishes now) 2026-09-30 10:15:49 -04:00
research/mtgo research/mtgo: findings (client is a thin view; why it feels old) 2026-09-29 22:17:21 -04:00
rules v09 wave 2: the set closes -- 4 live, 5 refused, 15 accounted 2026-09-21 02:42:44 -04:00
tests Oblivion Stone and All Is Dust: UNPROVEN via three small engine widenings 2026-09-29 22:25:14 -04:00
text render: Karn Liberated's +4 reads 'exiles a card from their hand', not 'discards' 2026-09-30 12:11:43 -04:00
.gitignore Windows build of the Godot client (packaging/windows/build.sh) 2026-09-29 21:38:00 -04:00
BURNDOWN.md BURNDOWN.md: follow the probe deck move 2026-09-27 18:26:32 -04:00
CMakeLists.txt M1: add mtg-master, a Quake-3-style server list (drivers/master/) 2026-09-30 18:17:21 -04:00
DECLINED.md M140 study: interactive mana payment declined on size, and the study's premise was wrong 2026-09-08 12:16:43 -04:00
HANDOFF.md handoff: the 2026-09-26 ledger/probe/Jev-suite session 2026-09-26 10:07:27 -04:00
LICENSE godot: add GNOME 2 UI sounds (draw/land/spell/ability/damage/priority/ 2026-09-28 20:53:53 -04:00
M145-STALENESS-SWEEP.md M145 staleness sweep: all 19 tagged rows re-measured, none flip 2026-09-09 00:06:18 -04:00
M146-STUDY.md M146 study: the target:planeswalker/trig:combat-damage pair, one flip not three 2026-09-09 00:24:36 -04:00
M150-REVEAL-STUDY.md M150 study: eff:reveal is 5+ machines, Fact or Fiction sizes as a real minimal-machine milestone 2026-09-09 06:14:20 -04:00
M155-TWO-BLOCKER-SWEEP.md M155 sweep + M156 contract: a latent flashback defect, found by trying to ship 2026-09-09 13:50:19 -04:00
README.md dirus: package name, all-rights-reserved LICENSE, Forgejo registry publish 2026-09-28 10:36:35 -04:00
TESTING.md TESTING.md: the trap sheet -- every lesson that cost an hour, written down once 2026-08-17 07:37:55 -04:00

dirus

A Magic: The Gathering rules engine in C++17. (The source tree, binaries and CMake project are still named mtg; dirus is the public/package name -- see packaging/arch/README.md. All rights reserved: see LICENSE.)

Every complete open-source MTG engine is on the JVM — Forge, XMage and Magarena are all Java. The two C++ projects in the space either simplify the rules (Wagic) or enforce none at all (Cockatrice). This one aims at the Comprehensive Rules in C++, with a pure core you can embed.

Status: M1b. A game shuffles, deals, mulligans and passes turns; lands are played and tapped for mana; spells are cast with targets and paid for; the stack resolves; creatures attack, block and deal combat damage; and things die to state-based actions rather than to the spell or the creature that damaged them.

Not yet: continuous effects and the layer system (CR 613), replacement effects (CR 614), triggered abilities (CR 603), and all 771 rules of keyword abilities — so no flying, no first strike, no trample. A few percent of the Comprehensive Rules by rule count, and none of the hard subsystems. Be honest with yourself about what "implemented" means.

Where things live

~/mtg/engine      this repository
~/mtg/resources   downloads -- rules text, card data, art. In no repository.

Downloads sit outside the repo deliberately: they are large, they come from several sources, they are somebody else's copyright, and more than one project will want the same copy. rules/fetch.sh and rules/check-citations.sh find the tree by walking up from git's common directory (so they work from a worktree too); MTG_RESOURCES overrides it.

The rules text is the source of truth

rules/fetch.sh downloads the current Comprehensive Rules; it is not committed, because it is Wizards' copyright. Implement against the text, not against memory. Thirteen wrong CR n.n citations were written into this codebase in its first days — one naming a rule that had been renumbered, one naming a rule that no longer exists, and one describing a decision the game removed years ago — and a wrong comment becomes a wrong implementation. rules/check-citations.sh prints every citation in the source beside the rule it names, and ctest runs it. It only proves a rule exists: three of the worst errors named a real rule that said something else entirely, and would have passed. Read the text.

The shape of it

A pure core with imperative shells. The core is a resumable step function:

Request advance(GameState&, const CardDatabase&, const Choice& answer);

apply(state, action) -> state would be the obvious signature and it is the wrong one, because it assumes the engine only ever consumes decisions. Magic constantly has to ask: who has priority, what does this target, which mode, what is X, in what order do these three simultaneous triggers go on the stack, which replacement effect applies first. So advance applies your answer, runs the game forward, and stops the moment it needs another decision — returning a Request naming the player, the reason, and every legal option.

Every consumer is then the same loop:

text protocol:  loop { req = advance(st, db, choice); print(req); choice = parse(stdin) }
scenario test:  loop { req = advance(st, db, choice); choice = script[req] }
AI:             loop { req = advance(st, db, choice); choice = search(clone(st)) }
server:         loop { req = advance(st, db, choice); choice = await socket }

One core, many shells, rules logic never duplicated. The server is a shell added later, not the foundation — tree search has to clone a position and play it out thousands of times per decision, and that has to be a copy, not an IPC round trip.

Because the engine enumerates legality, an AI driver is "pick an index", and illegal actions are unrepresentable rather than validated.

Three invariants

  1. The RNG seed lives inside GameState, never in a global. Shuffles are a pure function of state, so replays are exact.
  2. The action log is the source of truth: state = fold(advance, initial, log). Save files, replays, bug reports, network resync and reconnect are one mechanism.
  3. State is complete internally; players get filtered views. view(state, player) is the single chokepoint for hidden information.

One deliberate omission

GameObject caches no current power, toughness, types or colours. They are derived from the printed card plus the continuous effects in play (CR 613) every time they are needed. Caching them is the most common way a homebrew engine paints itself into a corner, and it is why the layer system gets built early rather than bolted on.

Layout

core/       libmtgcore — pure. No I/O, no globals, no threads.
  types.h   ids, enums, mana costs
  cards.*   the static card database; cards are DATA, loaded from text files
  state.*   GameState, Request/Option/Choice, zones
  rng.*     splitmix64, seeded from state
  log.*     action log + state hashing
  engine.*  new_game() / advance()
  view.*    hidden-information filtering
text/       structured data -> wire text, shared by every client
cards/      card data files (.cards)
decks/      deck lists
drivers/
  proto/    the line-oriented text protocol on stdin/stdout
  jev/      Jev decision driver (Python; legal choices only)
tests/
  scenarios/  declarative rules tests (.scen)

The Jev driver connects to the table protocol, sends each seat's filtered view and the engine's already-enumerated legal options to a Jev model — by default the local OpenAI-compatible OpenJev server (an SSH tunnel on port 8689), or TypeSafe's cloud Choice primitive with --backend typesafe — and writes the typed choice back as a PICK. It does not evaluate card rules or bypass the server's hidden-information filter. See drivers/jev/README.md for setup.

The text protocol

The primary interface, in the spirit of GNU Chess: a human can drive it, and so can a bot, with no client library.

> newgame 12345 decks/unlimited-green.deck decks/unlimited-red.deck

REQ 1 P0 mulligan-keep min 1 max 1
OPT 0 keep
OPT 1 mulligan
ENDREQ

> choose 1 0

state <player> dumps that player's filtered view, log dumps the action log, save/load round-trip it, quit exits.

Build

cmake -S . -B build -G Ninja
cmake --build build
ctest --test-dir build

Reading a build's version

Every binary in this tree links core/version.h's build_version(), stamped fresh from git on every build (cmake/GenerateVersion.cmake, run as a custom target so moving HEAD between two builds with no source edit — a git checkout or git pull — is never missed). This exists because a user's mtg-served was once built from an older commit than the checkout beside it, nothing recorded that anywhere, and a replay's divergence took a long investigation to trace back to stale object code rather than an engine bug. There are four places to read it:

  • A binary: every executable answers --version, e.g. mtg-served --version or mtg-tui --version. Prints the full commit hash, -dirty if the build had uncommitted changes to tracked files, the short hash, and git describe when one was available. unknown in all of those means git or .git was unavailable at build time (a release tarball).
  • mtg-served's startup line (stderr) leads with the same tag: mtg-served <hash>[-dirty]: table '<name>' on port <n>, ....
  • A saved log (--save, core/log.h) carries version <hash>[-dirty] and cards <pool-hash> header lines beside its seed/deck lines: which commit wrote the log, and a content hash of the cards/ pool it played against. Both are absent on a log saved before this existed, and load fine either way.
  • The wire protocol (drivers/PROTOCOL.md 1.10): a client sees VERSION <hash> right after the connection banner, unprompted.

The mismatch warning. Any tool that replays a saved log (mtg-resolutions, the proto REPL's load) compares the log's version/cards lines against the running build's own and prints a WARNING to stderr on either difference — never a refusal, since a divergent replay is still useful evidence, just evidence that needs the caveat. Seeing it means either the binary doing the replay is not the one that produced the log (rebuild with cmake --build build), or cards/ has changed since the log was recorded (a local edit, or a different checkout's cards). drivers/jev/jev_arena.sh and jev_suite.sh run the identical commit-vs-checkout check once per run, before any game is played, and write it to <out>/version.txt.

Card data

Cards are data, not code — adding one is a data edit, never a rebuild:

CARD Grizzly Bears
COST 1G
TYPE Creature
SUB Bear
PT 2/2
END

Card text is Oracle text, never the printed 1993 wording, so the 1993 cards are implemented against current rules with no legacy concepts (no mana burn, no damage-on-the-stack). From M2 a card also carries a list of abilities, each a tree of enum-tagged effect nodes; the struct grows, the principle does not change.

Checking a set

A set directory is held to an accounting invariant: every printed card in the set appears exactly once across its .cards files — as a live CARD block, as an # UNPROVEN block, as a # LIVE-ELSEWHERE pointer, or as a # REFUSED <name> -- <blocker> line. Nothing missing, nothing twice, and no refusal without a stated blocker. cards/check_cmm.py enforces that for Commander Masters, and also compares every live block's mana cost, type line, power/toughness and ORACLE text against the oracle extract:

python3 cards/check_cmm.py .            # from the repo root

It needs the set's oracle extract, which is bulk card data and is not distributed with this repository. Point MTG_ORACLE_DIR at the directory holding cmm-oracle.json if you keep it somewhere other than ~/mtg/resources/cards.

Two things worth knowing before trusting a green result. The checker matches a CARD <name> block in the file, so a card the loader later REJECTS still counts as live here — a passing accounting run is not evidence the card works. And a .cards parse failure aborts CardDatabase construction outright, which kills every scenario rather than just the offending one, so after editing a .cards file run a scenario unfiltered to confirm the pool still constructs.

What each ledger state claims

Shipping a card is a ladder of three rungs, and cards/v09/accounting.cards states it outright: a card is live "only if EVERY clause of it is said exactly, and only if a scenario proves it PLAYS — loading is rung two of three."

  1. written — every clause said exactly (the M9a White Ward rule: approximation, strictly-better and strictly-worse all disqualify)
  2. loads — the block parses and the pool constructs
  3. proved — a scenario compares its behaviour against its printed text

The ledger's vocabulary is how far up that ladder a name has got. live means rung three and nothing less — the word is load-bearing across ~2000 blocks, and widening it is the decay DECLINED.md was written to stop.

marker rung what it asserts
live CARD block 3 every clause exact, and a scenario proves it plays
# UNPROVEN <name> -- <file>; <evidence> 2 it loads and it ran, and no scenario has compared it to its printed text
UNTESTED (derived, not written) 2 it loads, and nothing has ever run it — weaker than UNPROVEN
# LIVE-ELSEWHERE <name> -- <file> — a pointer; another set holds the block. The database is keyed by name, so a second block with a loaded name is a load error
# REFUSED <name> -- <blocker>[, ...] — measured, not encodable today. Means "not yet"
DECLINED.md — "not ever" (excluded by design) or "sized and not scheduled" (declined for now)

UNTESTED and UNPROVEN exist because rung two previously had no name at all, so a card that loads but carries no scenario had nowhere honest to sit. Both name the missing evidence rather than the work done — deliberately unlike the other markers, because the absent evidence is the whole content of the row. They are two stops on one road:

UNTESTED  --(play)-->  UNPROVEN  --(scenario)-->  live
nothing has            the harness ran it,        a scenario judged it
ever run it            nothing judged it          against printed text

DERIVE WHAT IS COMPUTABLE; RECORD ONLY JUDGEMENTS. A fact written in two places drifts, and the written copy wins by being the more visible one — which is the duplication DECLINED.md exists to undo. So the states split:

  • Derived, never written down — live (a block with behaviour lines that some scenario names) and UNTESTED (the same, that no scenario names). A written # UNTESTED row would go stale the moment somebody added a scenario naming that card.
  • Recorded, because nothing can compute them — # REFUSED (a judgement carrying a measured blocker), # LIVE-ELSEWHERE (a pointer), # UNPROVEN (an assertion that a harness run happened), and DECLINED.md's two categories.

An UNPROVEN row therefore names the evidence that does exist, the way a # REFUSED row names its blocker:

# UNPROVEN Chameleon Colossus -- cards/v13/creatures.cards;
#     mtg-probe 40 games clean, jev-arena 12 games, no flags.

Two tools read the derived half:

  • drivers/probe/uncovered.py — the UNTESTED set, with the reasoning for what counts as implemented. A card carrying an # UNPROVEN row (in its set's accounting.cards, or beside its block where the set keeps no ledger) has play evidence instead and is listed separately. Measured today: 6 of 2049 blocks. The original 30 were all played by the first drivers/jev/jev_suite.sh run and now carry UNPROVEN rows; the 6 are three cards the pre-fix classifier misread as stubs and the three cards shipped after that run. UNTESTED is the harness's cheapest target set, because moving a card to UNPROVEN needs no engine work at all, only a deck containing it and a run — the suite builds that deck, points the strong seat at it and scores the run (drivers/jev/untested_play.py).
  • drivers/probe/ledger.py — every state for every set in one table, which is the only place the whole ledger is visible at once. It decomposes the blocks number an accounting header calls "live" into what it is actually made of.

An UNPROVEN row is not a weaker live, it is a different claim. Play evidence finds engine defects — drivers/probe audits the client contract across tens of thousands of requests, and the arena's loop guard catches positions that stall. None of it can find card-text infidelity, where the engine does exactly what the card file said and the card file describes the wrong card. STATIC costmod any: +3 loads clean, plays clean, breaks no invariant, and is not Trinisphere; movezone battlefield chosen:sub:Goblin picks a Goblin already in play and is not Goblin Lackey. cards/v09/accounting.cards calls this "the loads-but-plays-wrong shape the Iron Rule exists to refuse", and only rung three rules it out.

The Iron Rule binds live only. A block below rung three — UNTESTED or UNPROVEN — may be a best-effort encoding, because nothing yet claims it plays as printed. What it may not be is silently inexact: every clause the lines do not say exactly carries a # INEXACT: <clause> -- <why> comment above the block, so the scenario that would promote the card knows what it has to fix first. A card with an # INEXACT note cannot become live until the note is gone.

Promotion is one-way and retraction is cheap: when a scenario is written, the row becomes a live block; if the scenario disagrees with the card, the block comes out. The nine accounting.cards headers that spell "in three shapes" name four once a set has its first UNPROVEN row, and their COUNTS line grows a term.

Target pool: Unlimited first, then Kaladesh. Unlimited has no planeswalkers and no modern mechanics, so it exercises the core loop gently; Kaladesh then forces the hard modern machinery — type-changing Vehicles, a player-level Energy resource, loyalty abilities and ETB modal choices.

Style

Procedural and data-oriented. Plain structs, free functions, enums instead of strings. No class hierarchies, no ABCs, no virtuals.