micro-Max on STM32F103 — High Bring-Up Journal

Version 5.3 · base engine micro-Max 4.5 (H.G. Muller) · IAR 9.30.1 build, semihosting off, High optimization · updated 19.10.2026: two control games at level=60,000 (record cycle 927K nodes / 134.9 s); mating lines analyzed (d=98, oc=−7997…−7999); evaluator edits confirmed live: short-castle priority 50/20, pawn penalty 9→20; new section 10.8, oc=99 anomaly noted. Base — version 5; targeted edits: section 10.4 wording (White delivered mate), board resources C6 — 32 KB flash / 10 KB SRAM.

1. Data representation: the 0x88 board

The board is a one-dimensional array b[129]. The "0x88" coordinate system: square = 16×rank + file; only squares with (x & 136) == 0 are valid — the mask M = 136 cuts off both the overflow past the right edge (bit 3) and the overflow past the top rank (bit 7). Stepping to the next square:

x = x + 9 & ~M;

Past square h, bit 3 appears; & ~M clears it and carries a one into the rank.

The constant S = 128 (the fictitious square 0x80) plays three roles: the "no en passant" marker (E = S), the base of the node counter (see section 6), and a component of several checks. A classic trick of code packed into a couple of kilobytes.

The unused half of the array stores the central-score table:

L=8;W(L--)b[16*L+K+8]=(K-4)*(K-4)+(L-3.5)*(L-3.5);

The smaller the value, the closer the square is to the center (minimum on d4/e4/d5/e5). Used in the evaluation: v = p<6 ? b[x+8]-b[y+8] : 0. It is this "magnetic" table that pulls pieces toward the center — and, as section 10.7 shows, once talked the engine into a bad bishop retreat.

2. Piece encoding

BitsMeaning
0–2 (mask 7)Piece type: 0 empty, 1–2 pawns (up/down), 3 knight, 4 king, 5 bishop, 6 rook, 7 queen
3 (8)White piece; k = 8 — White to move
4 (16)Black piece; k = 16 — Black to move
5 (32)"Piece has moved" — cleared on the first move; critical for castling

Own-or-enemy is one bit: u & k. The piece table n[] = ".?+nkbrq?*?NKBRQ" is indexed by the low four bits. Values w[] = {.., 1,1,3,-1,3,5,9}: the king is negative — mate becomes a numerical loss, no separate "king evaluation" is needed.

R — the remaining non-pawn material of both sides (grows on capture: i >> 7, where i = 99*w[t&7]: knight/bishop +2, rook +3, queen +6). In the initial position R = 40; the endgame criterion is R < 29. Both the "magnetic king" and the level fork depend on it.

3. The two-line hash

3.1. The table

struct _ {int K,V;char X,Y,D;} A[U];
FieldPurpose
KPosition key — verified during the search
VPosition score
X"From" square of the best move + boundary flags (bit 8 — upper bound, bit 7 (128) — lower bound)
Y"To" square of the best move + double-pawn-move flag (bit S)
DDepth at which the score was obtained

256 cells × 12 bytes ≈ 3 KB — one third of all SRAM. Per the map, the High build gives a stack of ~5 KB; free SRAM is 232 bytes per the 19.10 map (the older map said ~168 — kept as a historical reference point), free flash ~5.1 KB.

3.2. Access

struct _*a = A + (J + k*E & U-1);

Operator precedence: * and + bind tighter than &, so the expression evaluates as (J + k*E) & (U-1) — an index modulo 256. Parsing the terms:

Next comes the check a->K == J: a match — cache hit, the score and the best move are taken from the cell and the search is cut short; a mismatch — the cell belonged to another position and is overwritten (always-replace policy). 256 cells on an STM32 produce many collisions — "misses" discard foreign results prematurely; the compensation is that k*E changes the index at every depth, spreading overwrites across the table.

Why the first move changed between builds

The table is declared without an initializer and zeroed at start in both builds (it landed in .data only formally — the packbits zero initializer ate 0x96 = 150 bytes of flash). So the contents are not the issue: RAM layout, initialization order and stack garbage differ from binary to binary, and in the opening several moves score identically — which one "wins" is decided by the search trajectory. Behavior is deterministic within one build, but not across builds. For repeatability it suffices to explicitly zero A (and the other search structures) at start.

4. The flow of D(k, q, l, e, J, Z, E, z, n), step by step

  1. Hash probe — cell A + (J+k*E & U-1); on a key match with a compatible window — continuation from the stored depth.
  2. Iterative deepening — while(d++ < n || z==8&K==I&&(N<level&&d<98…)). At the root (z==8) the loop deepens until the node budget level is exhausted — that is the per-move time control.
  3. Board sweep — square by square with step 9 through & ~M; for each own piece — iterate directions from o[].
  4. Move generation — sliders (p≥5) run along the ray until blocked, non-sliders take a single step; pawns live by their own rules (including the double move with the flag in Y).
  5. Making the move — b[] is updated, hash keys are updated incrementally, the "has not moved" flag is cleared.
  6. Recursion — D(24-k, -l, -f, -v, …): negamax, the window is mirrored (24-k flips the side).
  7. Cutoff — at m >= l (fail high) an immediate return.
  8. Undo — restore b[] and the hash keys.
  9. Record — the best move, score, depth and bound type are written into cell a.
  10. Indication on the STM32 — at the root node (z==8): PB11 toggles, an animation frame goes to the MAX7219 — a visual "the engine is thinking".

Signature tricks of the original: the "magnetic" king (central scores pull the king to the center in the endgame and punish its wandering in the middlegame), en passant via the parameter z (forced captures do not eat depth), central scores only for p<6 (king and queen are "central", the rook is not).

5. Castling

5.1. The mechanics of "virginity"

Castling in micro-Max is not a separate move type but a two-square king move (e1→c1 or e1→g1) whose legality is verified through the "virginity" flags. The king is u & 7 == 4; the two-square move is allowed only if the king has not yet moved and on the corresponding rook square H stands a rook of the side to move with an untouched flag:

if (b[H] == u + 66)   /* rook k+6 with flag: (k+4)+66 = (k+6)+64 */

When castling executes, both pieces (king and rook) move and both lose their flags — a second castling becomes impossible automatically; no separate rights counter exists. King and rook are encoded as k+4 and k+6, so u+66 is "a rook of the king's color with the flag".

5.2. The is_move() trick

Before the move list is generated, the validator is_move() forcibly sets bit 64 on the rook squares h1 (119) and a1 (112):

unsigned char old_bit1 = b[119] & 64;
unsigned char old_bit2 = b[112] & 64;
b[119] |= 64; b[112] |= 64;
/* ... pseudo-legal generation ... */
b[119] = (b[119] & ~64) | old_bit1;  /* restore */

Without this, castling pseudo-moves could fail to enter the check list, and a legal move would drop out with the reply #. The real flag check remains inside D().

What is covered, and what is not

Castling generation and execution work; castling rights in board_to_fen() are a stub (always -). For an honest FEN one needs to assemble the rights string from the un-cleared flags on e1/h1/a1/e8/h8/a8 — a ~10-line edit, low priority while FEN goes only to the log.

5.3. Test positions

  1. 1.e4 …, 2.O-O — the short castle right away: must pass.
  2. A king move there and back, then a castling attempt — must be forbidden (the king is no longer "virgin").
  3. Same with the h1 rook — forbidden once the rook has moved.
  4. Castling through an attacked f1 — forbidden (the king may not pass through check).

Item 4 is the most honest test: in minimal implementations the "king's path squares" check is often simplified.

6. The debug line d / uz / oc

sprintf(deb2, " d=%d uz=%d oc=%d", d-1, N-S, m);

Printed at the root node after each completed deepening iteration.

ArgumentMeaning
d-1The loop header while(d++ < n || …) increments d before the body executes, so by the time of printing the actually completed iteration has depth d-1. The line "d=1" means that iteration of depth 2 is already finished… more precisely — see below: for a book move d=0 is printed.
N-SN — the global node counter (incremented on every call of D()), S = 128 — the count base (the same constant as the fictitious square — reuse in the spirit of the original). N-S = nodes added by the current iteration.
oc=mThe score of the root best move in the negamax convention: from the mover's side. Positive — better for the mover. The score is refined with depth and may "breathe".
d=98 + oc=−7998/−7999The mate zone. The loop ceiling d<98 also works as the mate-signal constant: a score near −8000 means forced mate for the mover, with the distance to mate encoded in the low digits — the live-observed differences −7997/−7998/−7999 are resistance steps of one half-move. Such lines cost 10²–10³ nodes: forced-mate search is cheap.

How to read the log lines

ObservationInterpretation
d=0 uz=1 + the marker enter=0B0XA book move: the search never ran, one node (the probe itself). ~9 s — protocol overhead, not search.
d=1…5 uz=…Ordinary search: the line number is the iteration depth.
Move time noticeably above the others at the same dVertical extensions (captures, checks) or a collision storm in the hash table.
The same move across games, different across buildsDeterminism within a binary; cross-build differences come from the RAM layout and equal scores (section 3.2).
d=98 lines with oc creeping toward −7997The engine knows it is losing and spends the budget on the longest available mate — resistance, not self-deception.

7. A book instead of boxes

7.1. What there was: box1/box2

Two structures with recorded replies to 1.e4 and 1.d4, chosen via srand(TimingRnd) (SysTick entropy) with a known precedence bug:

if ((&box1==(selected_box))||(&box2==(selected_box))&(half_m<2))
/* parses as: (box1) || ((box2) & (half_m<2)) */

For box1 the random move fired every time, for box2 — only occasionally.

7.2. What there is now: the book generator (clarified 18.10.2026)

The outcome of the replacement (clarified wording). The boxes went away together with the precedence bug — there is nothing left to fix. The book solves both old problems, but differently from the boxes:

Opening variety is achieved not by a random pick at execution time but structurally — by the width of the generator tree: each multipv branch of White's (within THRESHOLD_CP) spawns its own position with a recorded best Black reply. The wider the threshold and MULTIPV, the larger the coverage. If White plays the same moves, Black's replies will match move for move — there is no randomness in the book, and that is a property, not a defect: the book plays "theory", not a lottery.

Determinism confirmed in practice: in the repeat game the book's replies matched move for move (c7c5, cxd4, Nc6, Nf6, g6, Bg7) — a random pick would never have produced that. The only PGN discrepancy (move 4…Nf6 without a marker) was explained by a marker lost in the UART, not by a search miss: in the repeat-game log [STM] book marker was present for all four book moves, including Nf6. Coverage note of 19.10: the book branches from White's first move — against 1.e4 it plays the Sicilian (1…c5), against 1.d4 the classical (1…d5). Coverage of the other openings (1.Nf3, 1.c4) is a matter of rerunning the generator with those roots.

7.3. The polyglot format and the key

The book is stored in the polyglot format; positions are keyed by Zobrist hashes. Search sensitivity: the key must match including the side to move, castling rights and e.p. — therefore positions right after captures (like the position after 3.Qxd4) are a good conversion test. Verified: the search works after the queen capture too.

8. Session observations

ParameterValue
BoardSTM32F103C6T6 — 32 KB flash, 10 KB SRAM
BuildIAR 9.30.1, semihosting off, High optimization
Move time (Low, earlier)28–37 s
Move time (High, the level=26,000 era)9.3–15.9 s (~2–3× speedup); a 45.9 s spike on 15…Nxe5
Move time (level=45,000)5.6–100.7 s, mean ~25 s
Move time (level=60,000)mean ~24–45 s depending on the game; record spike 134.9 s (927,143 nodes)
A book move~6–10 s — fixed protocol overhead (board exchange, acknowledgements)
Known defectThe UART drops bytes: entec1g5X, [DRAIN] discarded, lost book markers (three of six in the 19.10 game)
Memory (map)Stack ~5 KB, free flash ~5.1 KB, free SRAM 232 bytes; the table-A initializer packed down to 0x96 bytes

9. Strengthening plan

  1. High stabilization. Done 18.10 — the full volatile list grew beyond the original: rx_char, data_count, rc_data[], TimingDelay, TimingRnd, enter, book_move_ready. Details and the Pa082/Pe167 incidents — section 10.
  2. level 45,000–60,000 after stabilization. Done. 45,000 reached on 18.10 (d=5 became the norm); 60,000 confirmed on 19.10 by two full games against Stockfish 15.1: not a single hang, spikes up to 134.9 s — within the predicted ceiling of 2–2.5 minutes. No code changes were needed: the budget is a single int, it costs neither RAM nor stack.
  3. The depth ceiling d<10…15 with a 5 KB stack. Lifted on 19.10, with justification. The ceiling was a shield against HardFault on capture verticals; the 19.10 games showed that 98 recursion frames fit into the 5,120-byte CSTACK (~52 bytes per frame) — the empirical limit sits far above the old alarm. Note the constant's double role: d<98 is at once a recursion limiter and the range of mate scores (see section 6). The static stack analysis .sua remains as the numerical confirmation of the 52-byte estimate.
  4. A checksum in the protocol (or marker duplication) — the cure for UART byte loss; three of six book markers lost in the 19.10 Sicilian strengthen the argument.
  5. Control games against Stockfish — platform calibration by ELO; the engine's client path is tuned. Four games played (2×45,000, 2×60,000), all lost to full Stockfish — expected; the value is in stability and telemetry.
  6. Game outcome detection — games currently run to mate or a manual reset; the minimum is a no-moves check (mate/stalemate) via the existing cnt.
  7. Explicit zeroing of A at start — optional: first-move repeatability across builds.
  8. Heuristics and book expansion — now that d=5 is stable, that is so; the next levers — move ordering or hash growth — both run into the remaining 232 bytes of SRAM.
  9. DMA for the MAX7219 transfer — analyzed 19.10, postponed. Verdict: no effect on node time. A matrix frame is sent only at the root node (z==8) and only once per completed deepening iteration (5–6 per move); a full frame is 16 bytes ≈ 30–60 µs at SPI 2–4.5 MHz — six frames ≈ 0.2–0.4 ms against 25,000 ms of thinking per move: 0.001%. D() does not execute during the transfer, so DMA frees the search not a single cycle. Note: the map already shows DMA1_Channel3_IRQHandler inside spi.o — perhaps some DMA is already wired. DMA stays on the plan as timing hygiene (smooth matrix animation during thinking, responsive input between iterations), not as acceleration. The honest levers (hash 256→512 cells, move ordering) both require SRAM the board does not have.

10. Bringing up High: the 18.10 session — from terminal silence to mate on move 37

10.1. The build that went silent, and what cured it

After the switch to High the entire firmware went silent in the terminal — not a crash, not a hang any visible indicator would catch. The final diagnosis assembled from five suspects into one verdict: the "interrupt → main" chain had to become volatile before High was allowed to optimize.

SymptomCauseCure
Pa082 ×3 (the order of volatile accesses is undefined)Two (or three) different volatile objects are read in one expression joined by bitwise |/& without a sequencing pointReplace with ||/&& (they impose order) or split the reads into local copies. Hits: W((data_count==0)|(book_move_ready==1)), if ((enter==1)|(rc_data[0]=='0')), the line rc_data[data_count]=rx_char; in the ISR
Pe167 on memset(&rc_data,…)rc_data became volatile uint8_t[]; memset takes void * — implicitly stripping the qualifier is illegalAn explicit cast: memset((void*)rc_data, 0, sizeof(rc_data)); no ampersand needed — the array decays to a pointer on its own
A dead TC branch in the ISRTCIE is not enabled — the TC interrupt can never fire; the TC clearing code was unreachable, and on top of that it contained two volatile accesses in one expressionThe branch removed entirely
volatile in the declaration had no effectThe qualifier must match both in the declaration (main.h) and in the definition (.c) — a volatile declaration does not make someone else's variable volatile (the TimingDelay lesson)The qualifier duplicated in the definitions: volatile int data_count; etc.
delayMs retiredAll pauses now via Delay_us() on SysTick (the matrix test: Delay_us(100000) instead of the old 100 ms)—

10.2. The "inverted" ISR without mysticism: why it is not a bug

Saving the byte only in the error-flag branch looks like a defect, but it is a working paired element of the dual-mode UART through the ESP. The STM32 listens in 7E1 (M=0, PCE, even). The ESP sends to the STM32 in 8E1: the frame is one bit longer, data bit d7 lands in the parity slot, and the ESP's parity bit — in the stop slot. For ASCII this gives exactly one flag per byte: FE for an even number of ones in the character, PE for an odd one. FE and PE are mutually exclusive, but their OR is always non-zero — so every byte from the ESP is guaranteed to land in the save branch. In the other direction the ESP listens in 7E1 — hence the clean echo lines in the log.

The scheme is fragile: any clean byte (a foreign keepalive, debug output) falls into the else branch and provokes an RXNE storm — the only way out is an ORE pair with the loss of one byte. Future fix: align both sides on 7E1, bring the ISR to canonical form (always read DR; errors merely reset the command accumulator), plus a checksum in the protocol.

10.3. The beacon and the markers

When the build went silent entirely, the diagnosis narrowed in a single refresh:

  1. Beacon: while(1){ uart1_write_String("U"); Delay_us(300000); } right after uart1_init(). The appearance of U in the terminal proved: TX alive, wiring correct, format matching, SysTick and Delay_us working (the interval held).
  2. Step markers: 1 2 3 U S M after each initialization stage (CoreInit / SysTick_Init / GpioInit / SpiInit / max7219_init) — the first missing marker localizes the hang to one function.
  3. LEDs as a breakpoint indicator: PC13 = reached the main loop, PB11 = stuck between GpioInit and the loop.

The moral: the "terminal silence" was not a code hang but a delivery question — all the previous output went through the ESP bridge, and the direct terminal was tested for the first time. Before diagnosing, check the observation channel — a rule from now on.

10.4. Measurements

Game 1 (18.10, level=45,000)

Full game against Stockfish 15.1: 37 moves; the STM32 played Black, White delivered mate — i.e. the stm32 side lost, expected against a full-strength engine. The value of the test is not the result but platform stability under full load: no hangs, no glitches across 36 think cycles. The book played five moves; castling e1g1 was executed correctly.

MetricValue
Depthd=0 (book) … d=5
Nodes per move50,000 – 762,000
Move time5.6 – 100.7 s, mean ~25 s
Speed~4,000 nodes/s (d=4), ~7,500 nodes/s (d=5, open positions)

The 100.7 s peak is a d=5 position with 761,710 nodes: the budget overflowed into a half-move, and the exit from the half-move was played out honestly. Speed is higher on deep positions — a simpler board structure, less branching.

Game 2 (19.10, level=45,000)

44 moves. Depth 6 was reached twice (280,458 and 440,944 nodes) — the empirical 5 KB stack ceiling now sits far beyond the old d<10…15 alarm (see plan item 3). Sustained speed up to ~9,600 nodes/s. The book played one move (1…d7d5): White opened 1.g1f3, and the generator tree branches from White's first move — coverage, not a bug (section 7.2).

Games 3 and 4 (19.10, level=60,000)

Two full games against Stockfish 15.1, both lost by mate — expected; the interesting parts are the budget behavior and both evaluator edits from 10.8 in live play.

MetricGame 3 (Sicilian, 32 moves)Game 4 (1.d4 d5, 30 moves)
Book6 probes: 1…c5, 2…cxd4, 4…Qc7 with markers; 3…e5, 5…Nf6, 6…Na6 without markers (see 10.5)1 probe: 1…d5
First search moved=5, 565,441 nodes, 83.4 sd=5, 98,414 nodes, 24.8 s
Depthd=5 normal; one d=6 (285,154 nodes, move 18)d=5 normal; no d=6
Node record565,441927,143 (134.9 s) — the largest cycle in the journal's history
Speed~6.5–7.0K nodes/s sustained — unchanged since the 45,000 era, as predicted
Mate zonefrom move 20: nine d=98 lines; mate on move 32 — 12 moves of resistancefrom move 28: two d=98 lines; mate on move 30
oc trajectory+157 → +93 → +64 → −149 → −435 → mate≈0 until move 15, then a glide; one horizon bounce (−194 → +3 → −97)

What the mating lines showed

The differences −7997 / −7998 / −7999 within the d=98 lines are resistance steps of one half-move: on move 25 of game 3 the engine spent the full budget (185,209 nodes) and returned oc=−7997 — deliberately choosing a mate one half-move further away. Part of the forced lines came almost free (365 and 652 nodes per move). This is the d<98 constant doing its designed job (section 6): the losing engine does not panic — it computes the slowest of the mates available to it. For 4.5 KB of RAM that is character, not a bug.

10.5. Observations from live play

10.6. Reflections

The most instructive part of this series is how long we chased a bug that did not exist and found the one nobody was looking at. The "inverted" ISR looked blatantly wrong — and turned out to be a tailor-made paired element of the ESP protocol. And the terminal silence looked like a code hang — and was a check of the wrong channel. Several times the conclusion "this cannot work" broke against the fact "but it works" — and each time the right move was not to argue with reality but to look for the missing link in the explanation.

The second lesson is the price of attention. volatile cost three passes (forgotten in the definition, forgotten in the ISR, forgotten in the buffer) — but now the build itself reports problems: Pa082 is not an enemy, it is a compiler that stopped being silent. Under High, warnings are not a nuisance but a free X-ray. "Works on High" is not a victory over the compiler but a contract with it: first remove the undefined, then allow optimization.

The 19.10 games added a third lesson — on losing with dignity. Once in the mate zone, the engine neither falls apart nor hallucinates a rescue: it returns honest d=98 lines and spends the budget choosing the slowest of the available mates. Watching a 4-kilobyte engine fight for twelve moves after its own death sentence is unexpectedly instructive: it knows more about its position than its score suggests.

And on scale: a small board, 84% of flash and 98% of RAM — yet games against a real engine run 30–44 moves and hours of stubborn work. A microcontroller does not have to play like Stockfish — it has to move, show the board on the matrix, take moves from the book and not crash. It meets all four conditions. A good result for 72 MHz.

10.7. Anatomy of the move 2…Be6: the magnet error (game 1)

The magnet hypothesis deserves a section of its own, because it is confirmed by the evaluator's mechanics — verbatim.

What the magnet evaluates

The center table from section 1 pulls every non-rook toward the center. For a black bishop on c8 (table value 16.25), the candidate retreat squares give:

SquareTable valueThe bishop's gain (16.25 − value)
f51.25+15.0
e62.25+14.0
d77.25+9.0
h611.25+5.0
c8 (stay)16.250

Why the search chose e6, not f5

f5 is more central (1.25) but stands on an undefended square: within the d=5 horizon lie the themes g2g4 and Bxf5 — the bishop loses material there. e6 (2.25) is safe and nearly as magnetic. The search is honest: it picked the highest-scoring square it could see. The blind spot is elsewhere — in what the evaluator cannot see: the price of the move lies beyond the horizon. Be6 locks the e7 pawn on its own diagonal and closes the diagonal f8–e7–d6; micro-Max has no term that penalizes locking one's own pawns. The e7 pawn stood locked for fifteen moves (the engine moved it only on move 21 — when the bishop had already gone), and the same magnet then walked the bishop through d5 into a forced trade.

The irony

The move the magnet talked the engine into cost the bishop fifteen idle moves, one hook through d5 and a forced trade — and the score had long been sliding toward Black.

Verdict

This is a birth trait of micro-Max 4.5, not a porting defect: the same magnetism exists in the original. The recommendation is to keep it as the engine's character; reworking the center table (a penalty for locking one's own pawns) is a separate idea with its own SRAM price, and today's price is 232 bytes.

10.8. Evaluator edits: castling preference and pawn structure (made before the 19.10 games, confirmed by them)

Edit 1: the short castle takes priority

if(!(G&M))b[F]=k+6,v+=y>x?50:20;

The flat bonus v+=50 for any castling became differentiated: the comparison y>x is true for the short castle in both colors (g1>c1, g8>c8 in 0x88 indexing), so the short castle gets 50 and the long one 20. This is not a ban: if the search rates the long castle at least ~30 points higher, it will still win — the bonus merely tilts nearly equal alternatives toward the theoretically preferred choice: the king behind the g-file cover, the rook immediately on f1 with an eye on f7.

Confirmed: both 19.10 games castled correctly (12…O-O in the Sicilian, 10…O-O in the 1.d4 game); no pathological refusal to castle.

Edit 2: the pawn penalty strengthened

if(p<3)
//{v-=9*((x-2&M||b[x-2]-u)+
{v-=20*((x-2&M||b[x-2]-u)+
 (x+2&M||b[x+2]-u)-1
      +(b[x^16]==k+36));
if(y+r+1&S)b[y]|=7,i+=C;
}

The expression counts the structural defects of the pawn on x: the absence of an own-pawn neighbor to the left, to the right, the −1 normalization (a pawn with both neighbors costs nothing), plus the doubling flag (x^16 — the same file one rank away). The old coefficient 9 became 20. The second line is active; the first is kept commented out as a rollback point.

The risk, recorded honestly

20 is more than six pawns on the material scale — a heavier thumb on the scales than the flat 9. The failure mode is visible in advance: the engine starts paying material to fix its structure. If the next control games show such episodes — roll back to 9 or an intermediate 12–14. A cheap attribution method: the same book opening position, two builds, coefficient 9 versus 20 — compare the first 15 moves side by side; the book plays the opening for free, so the difference is pure evaluator. No pathologies are visible in games 3–4 — but two games are anecdote-grade statistics, not proof.

11. The long and the short castle: reflections

11.1. One check for both wings

Castling legality rests on a single comparison, b[H] == u+66, where H is derived from the king's destination square. One constant serves both wings: the rook-square byte is self-sufficient — color, piece type and the "virginity" flag all live inside one value. No parallel structures for a1/h1 and a8/h8: the same expression works mirrored, because the color already sits inside u.

11.2. The cheap symmetry of is_move()

The validator does not know which wing it is checking. It sets bit 64 unconditionally on both squares, 119 and 112 — a symmetric hack costing two bytes of state and zero logic: the real check happens later, inside D(), where the wing difference is settled by geometry, not by flags.

11.3. Rights as flags, not as structure

There is no "castling rights" object here, no enumeration, no four booleans. The rights are the very "virginity" bits, on the pieces that must move to lose them. Consequence: the rights cannot drift out of sync with the board — they are the board. It is the same trick as the e.p. square doubling as the marker of no e.p. (S=128): one datum plays two roles, and these two roles cannot contradict each other.

11.4. The geometric asymmetry of the rook ray

The short castle needs two empty squares between king and rook, the long one — three. The geometry is asymmetric, and so is the price: on a tight board the short castle is structurally cheaper. This asymmetry is invisible to the code — both wings are expressed by one operator — but visible to the search: the short castle enters the move list more often in closed positions. That is, the 50/20 tilt from 10.8 encodes a fact the geometry already knew.

11.5. Check on the path

The king may not cross an attacked square. micro-Max checks this not by a special rule but by the search itself: the two-square king move is generated as an ordinary move, and an attacked intermediate square is filtered out by the king-safety terms in the evaluation and by legality on the next half-move. A minimalist but honest design: there is no special code that could itself be buggy — the price is paid in generated moves, not in a special branch.

11.6. Practice statistics

Across the recorded games (~80 moves) a short castle (e1g1, e8g8) was executed, and the long one was never chosen — consistent with 11.4 and with the 50/20 tilt. Not a law of nature, but an honest sample: in the 1.d4 game the long castle was available and lost to the tilt.

11.7. The closing thought: computation instead of structure

Castling is the most vivid example of the general philosophy: where classical engines keep a state structure (rights counters, castling flags, e.p. fields), micro-Max stores almost nothing and computes in place. The same philosophy feeds the magnetic king: the endgame is not a stored phase but a computed R<29; mate is not a special state but a score near −8000. Every datum removed is one desynchronization that can no longer happen. At 4 KB this is not aesthetics — it is survival.