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:
- A clone is
w := *root. One 9,960-byte copy, no allocation, no pointer fix-up. A search can afford one per simulation. - The garbage collector never looks inside. A
Statecontains no pointers, so a heap of them is not scanned. - 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¶
<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>
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:
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)-1are 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:
chgis a 192-bit set; every write to an object or to its battlefield slot goes throughtouch(o), which sets the object's bit, bumps a touch counter and stamps the object with it;SetEpochclears 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;ObjHashis 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
Stateto 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;
Stateholds what changes. No field inStateis 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
permcosts 64 bytes ofState, one byte onobject192. 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.