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.
| Bits | Meaning |
|---|---|
| 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.
struct _ {int K,V;char X,Y,D;} A[U];
| Field | Purpose |
|---|---|
K | Position key — compared on lookup |
V | Position score |
X | Best-move "from" square + bound flags (bit 8 — upper bound, bit 7 (128) — lower bound) |
Y | Best-move "to" square + double-pawn-push flag (bit S) |
D | Depth 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.
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:
T[1035]: K(A,B) = *(int*)(T+A+(B&8)+S*(B&7)), and on a move —
J(A) = K(y+A,b[y]) - K(x+A,u) - K(H+A,t). The key changes on exactly three squares touched by the move.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.
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.
A + (J+k*E & U-1); on key match and a compatible window —
continue from the stored depth.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.& ~M; for each own piece,
enumerate directions from o[].Y).b[] is updated, hash keys updated incrementally, the "has not moved" flag cleared.D(24-k, -l, -f, -v, …): negamax, the window mirrors
(24-k switches sides).m >= l (fail high) immediate exit.b[] and the hash keys.a.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).
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".
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().
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.
Point 4 is the most honest test: in minimalist implementations, the "king path squares" check is often simplified.
sprintf(deb2, " d=%d uz=%d oc=%d", d-1, N-S, m);
Printed at the root node after each completed deepening iteration.
| Argument | Meaning |
|---|---|
d-1 | The 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-S | N — 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=m | The 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". |
| Observation | Interpretation |
|---|---|
d=0 uz=1 + marker enter=0B0X | The 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 d | Vertical extensions (captures, checks) or a collision storm in the hash table. |
| Same move between games, different across builds | Determinism within a binary; differences across builds come from RAM layout and equal scores (section 3.2). |
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.
filtered_moves()
asks Stockfish for top-MULTIPV lines and keeps moves within THRESHOLD_CP of the best score.engine.play() — exactly that move is written into the book.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.
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.
| Parameter | Value |
|---|---|
| Build | IAR 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 defect | UART 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 |
rx_char, data_count, rc_data[], TimingDelay,
TimingRnd, enter, book_move_ready. Details and the Pa082/Pe167
incidents — in section 10.d<10…15 under the 5 KB stack — HardFault protection on capture verticals.cnt.A at startup — optional: first-move repeatability across builds.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.
| Symptom | Cause | Fix |
|---|---|---|
| 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 ISR | TCIE is not enabled — the TC interrupt never fires; the clear-TC code was unreachable and itself contained two volatile accesses in one statement | Branch removed entirely |
volatile on a declaration not taking effect | The 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 retired | All pauses now go through Delay_us() on SysTick
(matrix test: Delay_us(100000) instead of the old 100 ms) | — |
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.
When the build went silent entirely, the diagnosis narrowed within a single reflash:
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).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.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.
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.
| Metric | Value |
|---|---|
| Depth | d=0 (book) … d=5 |
| Nodes per move | 50,000 – 762,000 |
| Move time | 5.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.
0X the STM re-prints
mov ... w; the client, waiting for a black move, reads it as a move announcement. Fixable from
either side: the STM should not print the repeated mov after processing 0X, or the
client should ignore lines with the w suffix while waiting for black.RECV: ed2c3X GO d2c3 OK (the "nter=" eaten),
truncated board lines like RECV: .+. A frame in flight at the moment of the ESP direction switch
dies. The client survives via DRAIN. Remedy: a few milliseconds of pause and an RX-buffer flush on the ESP
after the switch.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.