Skip to content

State

A dirty-dirac game is one Go value, kernel.State. This page shows what is inside it and why it is laid out that way: the memory map, the battlefield and its slot masks, how an object keeps its identity across zones, the change marks that let readers work incrementally, the size budget that governs growth, and the canonical Projection the oracle compares. It is for readers who want to know what "clone = assignment" costs and what it rules out.

Sizes on this page were read on 2026-10-21 from the dirty-dirac source of that day, with Go 1.26.3 on linux/amd64, by a scratch program that walks reflect.TypeOf(kernel.State{}) and prints each field's offset and size (reflect.Type.Size is unsafe.Sizeof).

The rule: a value, not a graph

The State type's own doc comment is the contract: "a whole game: a fixed-size value with no pointers, maps, slices, strings or interfaces. Clone = assignment." Every field is a number, a bool, a fixed array or a struct of those. Three things follow, and each one is why the kernel exists:

  1. A clone is w := *root. One 9,960-byte copy, no allocation, no pointer fix-up. A search can afford one per simulation.
  2. The garbage collector never looks inside. A State contains no pointers, so a heap of them is not scanned.
  3. Equal positions compare equal. Projection (below) is a plain struct, so two positions are compared with ==.

The cost is that every collection has a fixed capacity (Scope) and that anything variable-length lives outside the state: card definitions, filters and op programs are in the card table (kop.Table, see Cards), which a State names by a 16-bit index into a process-wide table registry rather than by a pointer.

Memory map

kernel.State: 9,960 bytes, in offset order offset size (bar to scale) header: turn, step, active, priority, config 0 16 B

<text x="12" y="74" class="dg-text">RNG: PCG, draw count, chance record</text>
<text x="262" y="74" text-anchor="end" class="dg-text-muted">16</text>
<rect x="270" y="62" width="18" height="16" class="dg-box-solid"/>
<text x="294" y="74" class="dg-text-mono">168 B</text>

<text x="12" y="100" class="dg-text">players [2]: life, zones, mana pool</text>
<text x="262" y="100" text-anchor="end" class="dg-text-muted">184</text>
<rect x="270" y="88" width="58.2" height="16" class="dg-box-solid"/>
<text x="334" y="100" class="dg-text-mono">544 B (272 each)</text>

<text x="12" y="126" class="dg-text">objects [192]: card, owner, zone, version</text>
<text x="262" y="126" text-anchor="end" class="dg-text-muted">728</text>
<rect x="270" y="114" width="164.7" height="16" class="dg-box-solid"/>
<text x="441" y="126" class="dg-text-mono">1,540 B (8 each)</text>

<text x="12" y="152" class="dg-text">battlefield perms [64], layer stamps</text>
<text x="262" y="152" text-anchor="end" class="dg-text-muted">2,268</text>
<rect x="270" y="140" width="400" height="16" class="dg-box-solid"/>
<text x="676" y="152" class="dg-text-mono">3,740 B</text>

<text x="12" y="178" class="dg-text">slot masks: occupied, by controller, per event</text>
<text x="262" y="178" text-anchor="end" class="dg-text-muted">6,008</text>
<rect x="270" y="166" width="25.7" height="16" class="dg-box-solid"/>
<text x="302" y="178" class="dg-text-mono">240 B</text>

<text x="12" y="204" class="dg-text">stack [24]</text>
<text x="262" y="204" text-anchor="end" class="dg-text-muted">6,248</text>
<rect x="270" y="192" width="61.8" height="16" class="dg-box-solid"/>
<text x="338" y="204" class="dg-text-mono">578 B (24 per entry)</text>

<text x="12" y="230" class="dg-text">pending triggers [24], order, delayed [8]</text>
<text x="262" y="230" text-anchor="end" class="dg-text-muted">6,826</text>
<rect x="270" y="218" width="38.3" height="16" class="dg-box-solid"/>
<text x="315" y="230" class="dg-text-mono">358 B</text>

<text x="12" y="256" class="dg-text">cast in progress + resolution in progress</text>
<text x="262" y="256" text-anchor="end" class="dg-text-muted">7,184</text>
<rect x="270" y="244" width="15.4" height="16" class="dg-box-solid"/>
<text x="292" y="256" class="dg-text-mono">144 B</text>

<text x="12" y="282" class="dg-text">mulligan, combat, damage division</text>
<text x="262" y="282" text-anchor="end" class="dg-text-muted">7,328</text>
<rect x="270" y="270" width="10.3" height="16" class="dg-box-solid"/>
<text x="287" y="282" class="dg-text-mono">96 B</text>

<text x="12" y="308" class="dg-text">change marks, stamps, undo, counters</text>
<text x="262" y="308" text-anchor="end" class="dg-text-muted">7,424</text>
<rect x="270" y="296" width="27.4" height="16" class="dg-box-solid"/>
<text x="304" y="308" class="dg-text-mono">256 B</text>

<text x="12" y="334" class="dg-text">generated-table extension (genState)</text>
<text x="262" y="334" text-anchor="end" class="dg-text-muted">7,680</text>
<rect x="270" y="322" width="243" height="16" class="dg-box-accent"/>
<text x="519" y="334" class="dg-text-mono">2,272 B</text>

<text x="12" y="360" class="dg-text">decision counter</text>
<text x="262" y="360" text-anchor="end" class="dg-text-muted">9,952</text>
<rect x="270" y="348" width="2" height="16" class="dg-box-solid"/>
<text x="278" y="360" class="dg-text-mono">8 B</text>

Sizes include alignment padding. Scale: the battlefield bar is 3,740 bytes. For comparison: Decision 1,544 B, Projection 18,336 B, Action 4 B.

Every region of kernel.State, measured 2026-10-21. The accented region is the extension only a generated card table uses; a hand-written test table never touches it.

What each region holds, and the design choice behind it:

Region Holds Why it looks like this
Header turn (uint16), step, active player, priority holder and pass count, starting player, config bits, a timestamp counter config is folded into bits so that a Config struct never has to be carried
RNG math/rand/v2's PCG state (two uint64s), the draw count, the seed, and an optional record of every draw's bound a rand.Rand holds an interface, so the kernel keeps the bare PCG and reimplements IntN (Step and Apply)
Players per seat: life, the library (68 slots read as a [lo, hi) window, so putting a card on top or bottom is O(1) most of the time), hand, graveyard and exile (60 each, in arrival order), mana pool by colour, lands played, per-turn life gained and lost, extra turns zones are arrays of Obj indices, not of cards: a card's data lives once, in the object table
Objects 192 records of 8 bytes: card ID, owner, zone, battlefield slot, version, activations this turn, flags (token, copy, ...) every card instance has a fixed index for the whole game (Object identity)
Battlefield 64 perm records of 56 bytes, a 16-bit layer timestamp per slot, and up to 8 records counting keyword grants past the third a permanent's mutable state (tapped, damage, counters, until-end-of-turn pumps and grants, attachments, combat role, override record) is a slot, separate from the object
Slot masks one 64-bit set per question the rules ask often see The battlefield and its slot masks
Stack 24 entries of 24 bytes: kind, source object and its version, card, ability, controller, modes, X, flags, up to 3 targets each with its version, the triggering object, player and amount, and one-byte indexes into the last-known-information pool and the extra-target pool a stack entry is small enough to copy; it refers to objects by index and version, and to anything larger by a pool index
Pending triggers 24 triggers of 12 bytes waiting to be put on the stack (APNAP), the order picks so far, 8 delayed "next end step" triggers triggers are queued, then ordered by the controller, then pushed
In progress the cast or activation being built (stage, targets so far, cost picks) and the resolution being executed (program counter, the ask it is suspended on, looked-at cards, loop counters) Step can stop in the middle of a cast or a resolution and Apply resumes it, with no Go stack to save
Mulligan, combat mulligan round state; attackers declared, first-strike step, block order, the damage division in progress
Change marks a 192-bit set of objects written since an epoch, a touch counter, a per-object stamp, undo bookkeeping, per-seat counts of elided decisions see Change marks
Generated-table extension the continuous-effect pool (16 records), the last-known-information pool (16 records), remembered and chosen objects, damage maps, play permissions, delayed programs, emblems, once-per-turn records, per-turn history counters, poison and energy, as-enters asks, exile-until-leaves links, replacement-order state the state the generated card tables need; zero and unused on a hand-written table

Two layout decisions from the redesign are visible in the map:

  • Counters are sparse. A permanent keeps a dedicated +1/+1 count and three general slots, each a (kind, count) pair kept sorted so that equal counter states are equal bytes. A fourth distinct kind on one permanent stalls the game. Dense per-kind arrays would cost 2 bytes per kind on all 64 slots; the sparse layout saved 1,024 bytes and lets the number of counter kinds grow at no cost to State.
  • Last known information is pooled. A stack entry or pending trigger that must remember an object as it last existed (CR 608.2h: a dies trigger reading the creature's power) holds a one-byte index into a shared, reference-counted pool of 16 snapshots keyed by object and version, instead of carrying its own copy.

The battlefield and its slot masks

The battlefield is perms [64]perm. A permanent occupies a slot (Slot, a uint8; 255 is "none") for as long as it stays on the battlefield, and its object record points at the slot. Because there are at most 64 slots, every set of permanents the rules ask about is one uint64:

perms [64] and the masks over them (example position) 0 8 16 24 32 40 48 56 63

perms 56 B each

occ occupied

ctrlM[0] seat 0 controls

ctrlM[1] seat 1 controls

subs[TrigDies] 1 of 22 events

statM has a static

legM legendary

"Seat 1's permanents with a dies trigger" is one instruction: ctrlM[1].and(occ).and(subs[TrigDies]) → slot 9

An illustrative position: seven permanents, four controlled by seat 0 and three by seat 1. Each mask is a uint64; a lit cell is a set bit.

The masks the State keeps up to date as permanents enter, leave and change control:

Mask Set when
occ the slot holds a permanent
ctrlM[p] seat p controls it
subs[e], one per slot-subscribed trigger event (22 events: enters, dies, leaves, attacks, spell cast, life gained, damage dealt, becomes target, sacrificed, taps, ...) the permanent's card has a triggered ability listening for event e, so an event scans only its subscribers
statM the card has a static ability
fxM the permanent has a record in the continuous-effect pool
grantM the card's static can grant an ability
legM the card is legendary (legend-rule candidates)

Three rarer trigger events (a gift given, a proliferate, a token created) keep no mask: the kernel scans the battlefield when one happens. The generated-table extension adds more slot masks of the same kind (cast this turn, kicked, sacrifice order, damage-sequence order, ...).

Iterating a mask is the usual lowest-set-bit loop, in ascending slot order:

for m := x; m.any(); m = m.rest() { // rest clears the lowest bit
    sl := m.first()                 // TrailingZeros64
    // ...
}

slotMask is declared as [slotWords]uint64 with slotWords = 1 at 64 slots, and every helper branches on that constant first, so each one compiles to the bare uint64 operation. A wider board is possible in principle, but Go keeps no array of more than one element in registers, so a two-word mask lives on the stack; trial 80- and 128-slot builds measured markedly slower per transition, which is why the board is 64 slots.

The battlefield's order (gorge lists each controller's permanents in entry order) is not the slot order: slots are reused. Each perm carries an entry timestamp, and the projection sorts by it when it needs the list.

Object identity and versioning

Every card instance is an object, Obj (a uint8), with a fixed index for the whole game:

  • objects 0 .. len(deckA)-1 are seat 0's deck in deck-list order;
  • the next len(deckB) are seat 1's deck;
  • tokens and copies take the following indices in creation order.

That is exactly the relative order of gorge's ObjIDs (gorge's genesis adds each seat's deck in deck order and mints tokens later), so the oracle can map one engine's objects to the other's by index. Target bytes reserve 0xF0 and 0xF1 for the two players and 0xFF for "none", which is why MaxObj may be at most 240; a compile-time constant conversion fails the build if it grows past that.

An object keeps its index when it changes zones, but the CR says it becomes a new object (CR 400.7): a spell targeting a creature that was bounced and recast must not find it again. The kernel models this with an 8-bit version on each object, bumped on every zone change. A stack entry stores, for each target, the target's version at the moment it was chosen; at resolution (CR 608.2b) a target whose version moved is illegal.

sequenceDiagram
  autonumber
  participant O as Obj 17 (Savannah Lions)
  participant S as stack entry: Burst Lightning
  Note over O: battlefield, ver 5
  S->>O: target chosen: tgt = 17, tver = 5
  Note over O: bounced to hand: ver 6
  Note over O: cast again, resolves: ver 8, back on the battlefield
  S->>O: resolves: objs[17].ver (8) != tver (5)
  Note over S: target illegal, and with no legal target left the spell does nothing (CR 608.2b)

The same check applies to an ability's source (srcVer): an activated or triggered ability whose source left and came back no longer reads the new object's characteristics. Abilities that need to see a creature as it last existed snapshot it into the last-known-information pool when the trigger is queued or the cost is paid.

Change marks and incremental readers

A search clones the root thousands of times, and most objects in a clone are unchanged from the root. Two readers exploit that: the network-input encoder and the evaluation cache (see Search). The state supports them with change marks:

  • chg is a 192-bit set; every write to an object or to its battlefield slot goes through touch(o), which sets the object's bit, bumps a touch counter and stamps the object with it;
  • SetEpoch clears the marks and names the current state as a base. A clone carries the base's epoch and marks, so an unmarked object in any clone is field for field the base's;
  • ObjHash is an object's content hash, a pure function of its fields, so two worlds holding the same object hash the same.

Marks are allowed to over-report (an extra recomputation) but never to miss. The kernel's tests check, after every action of a long soak, that each unmarked object still hashes as it did.

The size budget

State is the kernel's one scarce resource: every byte is copied on every clone, so the redesign treats its size as a budget with a ledger.

  • Today: 9,960 bytes. A size test fails the build above 12 KiB. Padding State to 12,288 and 16,384 bytes left the transition, whole-game and search benchmarks within about 2 % of the unpadded size, so the growth budget is 16 KiB; going past 12 KiB waits on a multi-worker memory-bandwidth check.
  • The table holds what is printed; State holds what changes. No field in State is specific to a card. A rule that only some cards need is a pool of records sized for the worst case seen in play, not a per-object field.
  • Per-slot bytes are expensive. One byte on perm costs 64 bytes of State, one byte on object 192. A new mechanism brings its own block, with its byte cost measured before it lands.
  • A feature that is off costs one branch. Each block is gated by a flag computed once per card table (does this table contain any card that uses it?). A table that never needs a block pays one predictable branch at its sites, and the code behind the gate is kept out of line so it does not disturb the hot loop's layout.

The redesign added the continuous-effect pool, per-slot layer timestamps and turn history while taking State down to 9,960 bytes: sparse counters and the shared last-known-information pool paid for the additions.

The Projection

Project writes a kernel.Projection (18,336 bytes): the comparable view of a position, used by the oracle and by tests.

Part Contents
per object, in Obj order (192 × 60 B) card, owner, controller, zone, position in its zone (library from the top; hand, graveyard and exile in arrival order; battlefield in its controller's entry order), token bit, tapped / attacking / blocking / summoning-sick / copy flags, damage, counters by kind, derived power and toughness (battlefield creatures), attached-to, the attacker it blocks, the defender it attacks
derived characteristics, per object (192 × 32 B) the types, subtypes, colours and keywords after the layer system, for battlefield objects
per seat life, lands played, mana pool, library / hand / graveyard / exile sizes
the stack, bottom to top (24 × 26 B) kind (spell, activated, triggered), source, card, ability index (255 for a permanent spell), controller, targets including the extra objects of a multi-object target slot, modes, X
the rest turn, step, active player, priority holder, RNG draw count, game over, winner

Every field is a fixed array, and equal positions project equal, so a comparison is ==. Two details matter for lockstep with gorge:

  • When something is on the stack. A spell is on the stack from the moment gorge pushes it (after its X and cost picks, before modes and targets); an activated ability only after its targets and costs; a triggered ability before its targets. The kernel pushes at the same points so the stacks project the same mid-cast.
  • Steps. The kernel splits gorge's combat-damage step into a first-strike damage step and a regular damage step (kop.Step); the oracle maps them back.

The counter array keeps the kinds the network inputs read in a fixed layout; counter kinds added later sit in a separate array, so the input layout that trained networks consume does not move when the kernel learns a new counter.

ProjectDelta and ProjectSince update an existing projection from the change marks instead of rebuilding it, for readers that project every node of a search.

The oracle compares not this struct but its own canonical projection, built from both engines; see Verification.