micro-Max on STM32F103 — High Bring-Up Journal

Version 3 · base engine micro-Max 4.5 (H.G. Muller) · IAR 9.30.1 build, semihosting off, High optimization · update 18.10.2026: new section 10 — the High bring-up session, from terminal silence to mate on move 37; board-book section revised (deterministic black replies, branching on white).

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 rejects both off-board-right (bit 3) and off-board-top (bit 7). Advancing to the next square:

x = x + 9 & ~M;

Past the h-file bit 3 rises, & ~M clears it and carries one into the rank.

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

The unused half of the array holds the center-score table:

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

A smaller value means the square is closer to the center (minimum on d4/e4/d5/e5). It is used in evaluation: v = p<6 ? b[x+8]-b[y+8] : 0. It is exactly this "magnetic" table that pulls pieces toward the center.

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 — the side to move is White
4 (16)Black piece; k = 16 — the side to move is Black
5 (32)"Piece has moved" — cleared on the first move; critical for castling

Checking side membership is a single bit: u & k. The piece table n[] = ".?+nkbrq?*?NKBRQ" is indexed by the low four bits. Piece values w[] = {.., 1,1,3,-1,3,5,9}: the king is negative — checkmate converts into a numeric loss, no separate "king evaluation" is needed.

R is the remaining non-pawn material of both sides (incremented 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 — compared on lookup
VPosition score
XBest-move "from" square + bound flags (bit 8 — upper bound, bit 7 (128) — lower bound)
YBest-move "to" square + double-pawn-push flag (bit S)
DDepth at which the score was obtained

256 entries × 12 bytes ≈ 3 KB — a third of all SRAM. Per the map file, the High-optimized build gives a ~5 KB stack with ~168 bytes of free SRAM and ~5.1 KB of free Flash.

3.2. Access

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

Precedence: * and + bind tighter than &, so it computes (J + k*E) & (U-1) — an index modulo 256. Breaking down the terms:

Then the check a->K == J: match — cache hit, score and best move are taken from the cell, the search is cut off; mismatch — the cell belongs 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 (in .data it landed only formally — the packbits zero-initializer took 0x96 = 150 flash bytes). So the content is not the cause: RAM layout, initialization order and stack garbage differ from binary to binary, and in the opening several moves score equally — which one "wins" is decided by the search trajectory. Behavior is deterministic within one build, but not across builds. For repeatability it is enough to explicitly zero A (and the other search structures) at startup.

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

  1. Hash lookup — cell A + (J+k*E & U-1); on key match and a compatible window — continue 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 runs out — this is the move-time control.
  3. Board scan — walk squares with stride 9 through & ~M; for each own piece, enumerate directions from o[].
  4. Move generation — sliders (p≥5) run along the ray until blocked, non-sliders take one step; pawns get their own rules (including the double push with a flag in Y).
  5. Make move — b[] is updated, hash keys updated incrementally, the "has not moved" flag cleared.
  6. Recursion — D(24-k, -l, -f, -v, …): negamax, the window mirrors (24-k switches sides).
  7. Cutoff — when m >= l (fail high) immediate exit.
  8. Unmake — restore b[] and the hash keys.
  9. Store — best move, score, depth and bound type are written into cell a.
  10. STM32 indication — at the root node (z==8): PB11 toggles and an animation frame goes to the MAX7219 — a visual "engine is thinking" indicator.

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

5. Castling

5.1. The virgin mechanics

Castling in micro-Max is not a separate move type but a two-square king move (e1→c1 or e1→g1) whose legality is checked via virgin flags. King u & 7 == 4; the two-square move is allowed only if the king has not yet moved and the corresponding rook square H holds a rook of the side to move with its flag intact:

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

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

5.2. The is_move() trick

Before generating the move list, the move 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 move generation ... */
b[119] = (b[119] & ~64) | old_bit1;  /* restore */

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

What is covered and what is not

Generation and execution of castling work; castling rights in board_to_fen() are a stub (always -). For honest FEN one needs to collect the rights string from the uncleared flags of e1/h1/a1/e8/h8/a8 — a ~10-line fix, low priority while FEN only goes to the log.

5.3. Check positions

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

Point 4 is the most honest test: in minimalist implementations, the "king 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 running the body, so at print time the completed iteration's actual depth equals d-1. A log line "d=1" means a depth-2 iteration has already completed… more precisely — see below: for a book move, d=0 is printed.
N-SN — the global node counter (incremented on every D() call), S = 128 — the count base (the same constant as the dummy square — reuse in the original's spirit). N-S = nodes added by the current iteration.
oc=mThe root best move's score in negamax convention: from the mover's point of view. Positive — the mover is better. The score refines with depth and may "breathe".

How to read the log lines

ObservationInterpretation
d=0 uz=1 + marker enter=0B0XThe move came from the book: the search did not run, one node (the lookup itself). ~9 s — protocol overhead, not search.
d=1…5 uz=…Ordinary search: the line number is the iteration depth.
Move time far above others at the same dVertical extensions (captures, checks) or a collision storm in the hash table.
Same move between games, different across buildsDeterminism within a binary; differences across builds come from RAM layout and equal scores (section 3.2).

7. The book instead of boxes

7.1. What was: box1/box2

Two structures with recorded replies to 1.e4 and 1.d4, chosen by srand(TimingRnd) (SysTick entropy) with the known priority bug:

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

For box1 the random move fired always, for box2 only sometimes.

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

Net result of the replacement (corrected wording). The boxes are gone together with their priority bug — nothing left to patch. The book solves both old problems, but differently from the boxes:

Opening variety is achieved not by random choice at execution time but structurally — by the width of the generator tree: each multipv white branch (within THRESHOLD_CP) spawns its own position with a recorded best black reply. The wider the threshold and MULTIPV, the more coverage. If the white player plays the same moves, black's replies will be move-for-move identical — there is no randomness in the book, and that is a feature, not a bug: the book plays "theory", not a lottery.

Determinism confirmed in practice: in a repeat game the book replies matched move-for-move (c7c5, cxd4, Nc6, Nf6, g6, Bg7) — something random selection would never give. The single discrepancy in the PGN (move 4…Nf6 without a marker) was explained by a UART marker loss, not a lookup miss: the repeat-game log carried [STM] book marker for all four book moves, including Nf6.

7.3. Polyglot format and the key

The book is stored in the polyglot format, positions keyed by Zobrist hashes. Lookup sensitivity: the key must match including side to move, castling rights and e.p. — that is why positions right after captures (such as the position after 3.Qxd4) are a good conversion check. Verified: lookup works after a queen capture too.

8. Session observations

ParameterValue
BuildIAR 9.30.1, semihosting off, High optimization
Move time (Low, formerly)28–37 s
Search move time (High, PGN game)9.3–15.9 s (~2–3× speed-up); outlier 45.9 s on 15…Nxe5
Book move~9.2–9.4 s — fixed protocol overhead (board exchanges, acknowledgements)
Known defectUART drops bytes: entec1g5X, [DRAIN] discarded, a lost Nf6 marker in the PGN
Memory (map file)Stack ~5 KB, free Flash ~5.1 KB, free SRAM ~168 bytes; the table A initializer packed down to 0x96 bytes

The numbers in this section refer to the level=26,000 era. At the current level=45,000 the move times have roughly doubled — see section 10.4.

9. The 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 — in section 10.
  2. level 45,000–60,000 after stabilization. Partially done: 45,000 reached, d=5 already probed; at 9–16 s per move depth 5 becomes the norm rather than luck.
  3. Depth cap d<10…15 under the 5 KB stack — HardFault protection on capture verticals.
  4. Checksum in the protocol (or marker duplication) — the cure for UART byte losses.
  5. Control games against Stockfish — platform ELO calibration; the client engine path is configured.
  6. Game outcome detection — currently games run to mate or manual reset; minimum — a no-legal-moves check (mate/stalemate) via the existing cnt.
  7. Explicit zeroing of A at startup — optional: first-move repeatability across builds.
  8. Heuristics and book expansion — after depth 5 becomes stable.

10. High bring-up: the session of 18.10 — 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 whole firmware went quiet in the terminal — not a crash, not a hang that any visible indicator could catch. The final diagnosis assembled from five suspects into one verdict: the chain "interrupt → main" had to be made volatile before High was allowed to optimize it.

SymptomCauseFix
Pa082 ×3 (order of volatile accesses undefined)Two (or three) different volatile objects read in one statement joined by bitwise |/& without a sequencing point Replace with ||/&& (they order evaluation) or split reads into local copies. Hit: W((data_count==0)|(book_move_ready==1)), if ((enter==1)|(rc_data[0]=='0')), the rc_data[data_count]=rx_char; line in the ISR
Pe167 on memset(&rc_data,…)rc_data became volatile uint8_t[]; memset takes void * — implicit qualifier drop is illegal Explicit cast: memset((void*)rc_data, 0, sizeof(rc_data)); the ampersand is unnecessary — the array decays on its own
Dead TC branch in the ISRTCIE is not enabled — the TC interrupt never fires; the clear-TC code was unreachable and itself contained two volatile accesses in one statementBranch removed entirely
volatile on a declaration not taking effectThe qualifier must match in both the declaration (main.h) and the definition (.c) — a volatile declaration does not make a foreign file's variable volatile (the TimingDelay lesson)Qualifier duplicated in definitions: volatile int data_count; etc.
delayMs retiredAll pauses now go through Delay_us() on SysTick (matrix test: Delay_us(100000) instead of the old 100 ms)—

10.2. The reversed ISR demystified: why it was not a bug

Saving a byte only in the error-flag branch looks like a defect, but it is the working counterpart of the dual-mode UART over 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 lands in the stop slot. For ASCII this produces exactly one flag per byte: FE for an even count of ones in the character, PE for an odd count. 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 reverse direction the ESP listens in 7E1 — hence clean echo lines in the log.

The scheme is fragile: any clean byte (a foreign keepalive, debug output) falls into the else branch and triggers an RXNE storm — the only exit is an ORE pair with one lost byte. Future fix: align both sides to 7E1, move the ISR to the canonical form (read DR always; errors only 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 within a single reflash:

  1. The beacon: while(1){ uart1_write_String("U"); Delay_us(300000); } right after uart1_init(). U appearing in the terminal proved: TX alive, wiring correct, format matched, 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 a single function.
  3. LEDs as a stop-point indicator: PC13 = main loop reached, PB11 = stuck between GpioInit and the loop.

Moral: "silence in the terminal" turned out to be not a code hang but a delivery question — all previous output went through the ESP bridge, and the direct terminal was being checked for the first time. Before diagnosing, verify the observation channel — a rule from now on.

10.4. Measurements at level=45000

A full game against Stockfish 15.1: 37 moves, mate for White (expected against a full-strength engine; the test's value is platform stability under full load — no hangs, no glitches across 36 thinking cycles). The book played five moves; castling e1g1 executed correctly.

MetricValue
Depthd=0 (book) … d=5
Nodes per move50,000 – 762,000
Move time5.6 – 100.7 s, average ~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 spilled over into an intermediate ply and the ply-out was played out honestly. Speed is higher on deep positions — simpler board structure, less branching. For the future: node scaling is non-linear in our favor.

10.5. Observations from the live game

10.6. Reflections

The most instructive part of this series is how long we hunted a bug where there was none and found one where nobody looked. The "reversed" ISR looked flagrantly wrong — and turned out to be a tailor-made counterpart 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 does work" — 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 about 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 its 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. "It runs on High" is not a victory over the compiler — it is an agreement with it: first remove the undefined, then allow it to optimize.

And about scale: a small board, 84% of flash and 98% of RAM — yet a game against a real engine took 37 moves and an hour and a half of stubborn work. The microcontroller does not have to play like Stockfish — it has to move, show the board on the matrix, take book moves and not fall over. It now satisfies all four conditions. A fine result for 72 MHz.