Move Generation: Castling - micro-Max by H.G. Muller

Discussion of chess software programming and technical issues.

Moderator: Ras

Alexlaw1964
Posts: 41
Joined: Fri Jan 03, 2025 6:36 am
Full name: alex lobov

Re: Move Generation: Castling - micro-Max by H.G. Muller

Post by Alexlaw1964 »

zobrist_gen.py

Code: Select all

# zobrist_gen.py
# Generator of Zobrist table for Micro-Max on STM32
# 781 unique 64-bit numbers, seed = 0x54321F00D

import random

SEED = 0x54321F00D
OUTPUT_FILE = "zobrist_table.h"
NUM_ENTRIES = 781

def main():
    random.seed(SEED)
    numbers = []
    seen = set()
    while len(numbers) < NUM_ENTRIES:
        n = random.getrandbits(64)
        if n not in seen:
            seen.add(n)
            numbers.append(n)

    assert len(numbers) == NUM_ENTRIES
    assert len(set(numbers)) == NUM_ENTRIES

    with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
        f.write("#ifndef ZOBRIST_TABLE_H\n")
        f.write("#define ZOBRIST_TABLE_H\n\n")
        f.write("#include <stdint.h>\n\n")
        f.write("/* Zobrist table: 781 random 64-bit numbers, seed 0x54321F00D */\n")
        f.write("/* Index: color*384 + (ptype-1)*64 + sq  (sq: a1=0, h8=63) */\n")
        f.write("/*   color: 0=white, 1=black; ptype: 1=P,2=N,3=B,4=R,5=Q,6=K */\n")
        f.write("/* 768: side to move (XOR if black)                    */\n")
        f.write("/* 769-772: castling (WK, WQ, BK, BQ)                  */\n")
        f.write("/* 773-780: en passant files (a..h)                    */\n\n")
        f.write("const uint64_t ZobristTable[781] = {\n")

        for i in range(0, NUM_ENTRIES, 3):
            chunk = numbers[i:i+3]
            parts = ["0x{:016X}ULL".format(n) for n in chunk]
            line = "    " + ", ".join(parts)
            if i + 3 < NUM_ENTRIES:
                line += ","
            f.write(line + "\n")

        f.write("};\n\n")
        f.write("#endif // ZOBRIST_TABLE_H\n")

    print("Done! Generated {} unique 64-bit numbers".format(NUM_ENTRIES))
    print("Saved to {}".format(OUTPUT_FILE))

if __name__ == "__main__":
    main()
Alexlaw1964
Posts: 41
Joined: Fri Jan 03, 2025 6:36 am
Full name: alex lobov

Re: Move Generation: Castling - micro-Max by H.G. Muller

Post by Alexlaw1964 »

Alexlaw1964
Posts: 41
Joined: Fri Jan 03, 2025 6:36 am
Full name: alex lobov

Re: Move Generation: Castling - micro-Max by H.G. Muller

Post by Alexlaw1964 »

I corrected and added to it.
https://brewpage.app/public/bwGDoRJC3H
Alexlaw1964
Posts: 41
Joined: Fri Jan 03, 2025 6:36 am
Full name: alex lobov

Re: Move Generation: Castling - micro-Max by H.G. Muller

Post by Alexlaw1964 »

[pgn][Event "Computer Schach Partie"]
[Site "PC"]
[Date "?"]
[Round "?"]
[White "stockfish 15.1"]
[Black "stm32"]
[Result "*"]
[Termination "normal"]

1. e2e4 {0.1s} c7c5 {book, d=0 uz=1 oc=0, 9.6s}
2. g1f3 {0.1s} d7d6 {book, d=0 uz=1 oc=0, 6.6s}
3. f1b5 {0.1s} c8d7 {book, d=0 uz=1 oc=0, 7.4s}
4. b5d7 {0.2s} d8d7 {book, d=0 uz=1 oc=-297, 5.8s}
5. e1g1 {0.1s} g8f6 {book, d=0 uz=1 oc=0, 6.1s}
6. d2d3 {0.1s} b8c6 {d=5 uz=157359 oc=0, 27.0s}
7. c2c3 {0.2s} f6g4 {d=5 uz=399781 oc=-9, 57.8s}
8. d3d4 {0.2s} g4f6 {d=4 uz=67321 oc=-9, 15.5s}
9. d4d5 {0.1s} c6e5 {d=5 uz=266179 oc=-8, 38.4s}
10. f1e1 {0.1s} e5f3 {d=5 uz=348600 oc=-16, 49.9s}
11. d1f3 {0.2s} h7h6 {d=5 uz=220215 oc=-5, 32.8s}
12. c3c4 {0.1s} e7e5 {d=5 uz=288184 oc=6, 40.6s}
13. a2a4 {0.2s} f8e7 {d=5 uz=194826 oc=4, 29.1s}
14. f3e2 {0.1s} e8g8 {d=5 uz=201536 oc=0, 30.8s}
15. h2h3 {0.1s} e7d8 {d=5 uz=164566 oc=-24, 24.3s}
16. a4a5 {0.1s} d8c7 {d=5 uz=254457 oc=-8, 45.8s}
17. b1c3 {0.2s} d7d8 {d=5 uz=215797 oc=23, 39.2s}
18. c3b5 {0.2s} c7a5 {d=5 uz=150518 oc=91, 37.1s}
19. a1a5 {0.1s} d8a5 {d=5 uz=77052 oc=191, 15.7s}
20. b5d6 {0.1s} a5b6 {d=5 uz=209581 oc=196, 29.7s}
21. d6f5 {0.2s} f8e8 {d=5 uz=156907 oc=190, 23.4s}
22. e2f3 {0.2s} b6b4 {d=5 uz=262709 oc=198, 35.5s}
23. e1e2 {0.1s} b4c4 {d=5 uz=213035 oc=284, 32.6s}
24. c1h6 {0.1s} g7h6 {d=5 uz=322299 oc=139, 60.9s}
25. f3g3 {0.1s} f6g4 {d=98 uz=6404 oc=-7998, 6.5s}
26. g3g4 {0.0s} g8h7 {d=98 uz=643 oc=-7999, 5.7s}
27. g4g7 {0.0s} 1-0
1-0
[/pgn]

Code: Select all

A meaningful game — and the most instructive in the journal so far. In short: for the first time stm32 was actually winning material (by its own evaluation, up to +284) and lost not positionally but to a study-like finish — a bishop sacrifice and a forced mate that micro-Max cannot see by design of its evaluator. 27 moves is the fastest loss of all five games, but also the most interesting slice.

The opening: the book without a single lost marker
The book played all five moves — 1…c5, 2…d6, 3…Bd7, 4…Qxd7, 5…Nf6 — and for the first time all book markers arrived in the log. This is a telling detail for 10.5: in game 3 three markers out of six were lost; here, on the same path, zero. This indirectly supports hypothesis (a) — losses in the UART, not in the book logic. The branch is different from the Sicilian of 19.10: White played 2.Nf3 instead of 2.d4, and the generator's tree returned its recorded branch — determinism still move for move.

And the main thing — a new anomaly, a sibling of oc=99: on the book move 4…Qxd7 the log printed oc=-297. Exactly 297 = 3×99 — the price of one minor piece in micro-Max's scale. And this is no coincidence: at that moment Black was indeed a bishop down — White's Bxd7 had already captured Bd7, while the queen had not yet recaptured. Check the rest: 1…c5, 2…d6, 3…Bd7, 5…Nf6 — material is even, oc=0 everywhere ✓. And in game 3, before 3…e5, 5…Nf6 and 6…Na6, Black held the extra d4 pawn → +99 ✓✓✓ — only 4…Qc7 diverges (printed 0 with the same +99).

The pattern emerges: the book path prints not a constant but a live evaluation of the position before the move — the zeros simply masked this in even positions. Hypothesis (b) from 10.5 gains a lot of ground. The decisive test stays the same, plus a new, even cheaper one: at every book move, compare the printed oc with the actual material on the board.

The middlegame: an exchange that should have been enough to win
The key episode: 16.a5 (pawn to a5), 18…Bxa5 — the bishop takes the pawn, 19.Rxa5 — Stockfish gives up the exchange, 19…Qxa5 — the queen takes the rook. On pure material Black is +2 pawns, and oc showed it honestly: 191 right after the exchange. Then 23…Qxc4 — another pawn, peak +284. The oc curve ran smoothly: ~0 → +91 → +191 → +198 → +284. The engine believed it was winning — and on pure material it was right.

The subtle point: 19.Rxa5 is not a Stockfish blunder but a deliberate exchange sacrifice: Black's queen is driven away to b4/c4, far from her king, and White assembles an attack with the Nd6→f5 and the queen. micro-Max cannot evaluate this compensation — it has no king-safety term, and the «magnetic king» only works in the endgame (R<29). This is blind spot #2, the mirror image of the magnet error from 10.7: there the magnet talked the engine into a bad bishop retreat; here the absence of an attack evaluation prevented it from seeing what was coming.

24.Bxh6! — the horizon cuts off
24…gxh6: Black took the bishop, oc dropped from 284 to 139 — the evaluator «sensed something» (minus 145 — the price of the compensation in its eyes), but kept +1.4 pawns for itself. At that moment mate lay three plies away, beyond what d=5 can verify against all of Black's defenses — the iteration reached 322,299 nodes and 60.9 s (the biggest spend of the game) and still returned «better».

And then — an effect absent from games 3–4: the mate zone arrived abruptly. 25.Qg3 — and immediately d=98 uz=6404 oc=-7998: mate found by force (threat Qxg7# with the queen protected by the knight on f5), three plies before the end. 25…Ng4 — the choice of maximum resistance among the available moves (−7998 against −7999 for the alternatives), 26.Qxg4 Kh7 (−7999), 27.Qg7# — queen on g7 shielded by Nf5, mate with no escape.

The contrast with game 3 is fundamental: there d=98 was already burning 8–12 moves before mate, because the material collapse was visible at d=5. Here the position was better on material, the zone did not trigger until the mate became short and forced. So there are two modes of the mate zone: «early conscious defeat» (games 3–4) and «late cutoff» (this one). As a diagnostic marker for the journal this is very valuable: a late d=98 after a sacrificial attack is direct evidence of the evaluator's blindness to king safety, not of a worn-out position.

Telemetry
Metric	Value
Game length	27 moves — shortest yet (was 30–44)
Book	5 moves, 5 markers — first time without losses
Search moves	21
Total nodes	≈ 4.18 M
Total search time	≈ 678 s
Speed	≈ 6.2 k nodes/s — unchanged
Peak	399,781 nodes / 57.8 s (7…Ng4) — no blowups
Depth	d=5 normal; one d=4; d=98 twice, final phase only
The only d=4 (8…Nf6, 67,321 nodes) is an interesting detail: the budget died inside the d=4 iteration itself — that iteration overspent the 60,000 (vertical extensions after 8.d4, many captures and checks), and d=5 never got its turn. A line for the «how to read the log» table: «d=4 at level 60,000 — not a depth rollback, but an expensive iteration».

The evaluator edits in live play
Castling: 14…O-O short, clean — the 50/20 tilt works. Pawn penalty: Black had almost no structural defects all game (gxh6 is not a defect), no pathologies visible. That is the third game with the edits and no failure mode — the sample is still anecdotal, but the trend is calm.

Bottom line
Humanly — a game to be proud of: a 72 MHz engine took the exchange from Stockfish 15.1 and held the lead for half an hour. Engineerially — it delivered three findings in one go: flawless marker delivery (an argument for UART, not book logic), the correlation of oc on book moves with material (−297 = minus a minor piece, exact to the board), and a new type of mate zone — the late cutoff.

Shall I add all of this to both journals as game 5: a supplement in 10.4, an update to 10.5 (three oc values on book moves: 0, 99, −297 — and the live-evaluation hypothesis) and a paragraph in 10.7 on the second blind spot? Or do we run the penalty A/B first, as agreed?
Alexlaw1964
Posts: 41
Joined: Fri Jan 03, 2025 6:36 am
Full name: alex lobov

Re: Move Generation: Castling - micro-Max by H.G. Muller

Post by Alexlaw1964 »

[pgn][Event "Computer Schach Partie"]
[Site "PC"]
[Date "????.??.??"]
[Round "?"]
[White "stockfish 15.1"]
[Black "stm32"]
[Result "1-0"]
[Termination "normal"]

1.Nf3
{0.1s}
1...d5
{d=5 uz=60774 oc=15, 19.1s}
2.d4
{0.1s}
2...Bf5
{d=5 uz=136601 oc=15, 28.3s}
3.Bf4
{0.2s}
3...e6
{d=5 uz=201751 oc=15, 36.3s}
4.e3
{0.2s}
4...Nc6
{d=4 uz=61129 oc=-2, 15.9s}
5.c4
{0.1s}
5...Bxb1
{d=4 uz=109411 oc=2, 22.6s}
6.Qxb1
{0.1s}
6...Bb4+
{d=5 uz=334669 oc=45, 48.9s}
7.Ke2
{0.1s}
7...dxc4
{d=5 uz=424130 oc=73, 60.7s}
8.Qc2
{0.1s}
8...b5
{d=4 uz=73486 oc=85, 25.5s}
9.g3
{0.2s}
9...Nf6
{d=4 uz=60087 oc=112, 14.0s}
10.b3
{0.2s}
10...Na5
{d=4 uz=92013 oc=86, 19.3s}
11.bxc4
{0.1s}
11...Nxc4
{d=5 uz=462568 oc=129, 64.8s}
12.a3
{0.1s}
12...Bd6
{d=4 uz=97519 oc=108, 18.9s}
13.Bg2
{0.2s}
13...Nxa3
{d=4 uz=103601 oc=165, 21.2s}
14.Qc6+
{0.1s}
14...Nd7
{d=5 uz=105588 oc=-93, 21.1s}
15.Bxd6
{0.1s}
15...cxd6
{d=5 uz=230801 oc=-73, 36.6s}
16.Rxa3
{0.1s}
16...O-O
{d=5 uz=305316 oc=-168, 42.8s}
17.Raa1
{0.1s}
17...Rc8
{d=5 uz=242843 oc=-189, 33.8s}
18.Qxb5
{0.1s}
18...Rb8
{d=5 uz=653154 oc=-190, 79.8s}
19.Qa6
{0.1s}
19...Qc7
{d=5 uz=293863 oc=-282, 37.5s}
20.Rhc1
{0.1s}
20...Qb7
{d=5 uz=336821 oc=-271, 43.2s}
21.Qxb7
{0.1s}
21...Rxb7
{d=6 uz=483683 oc=-218, 56.4s}
22.Ne1
{0.2s}
22...d5
{d=5 uz=134906 oc=-222, 19.7s}
23.Kd1
{0.2s}
23...f5
{d=5 uz=146154 oc=-202, 22.0s}
24.Nd3
{0.2s}
24...Rb3
{d=5 uz=170708 oc=-200, 22.9s}
25.Bf1
{0.1s}
25...Rb7
{d=5 uz=189044 oc=-205, 24.3s}
26.Ke1
{0.2s}
26...Nf6
{d=5 uz=135448 oc=-204, 22.3s}
27.Ne5
{0.1s}
27...Ng4
{d=5 uz=248984 oc=-208, 31.8s}
28.Be2
{0.1s}
28...Nxh2
{d=5 uz=208112 oc=-110, 27.2s}
29.f3
{0.1s}
29...Rb3
{d=5 uz=257363 oc=-99, 31.1s}
30.Nd7
{0.1s}
30...Ra8
{d=5 uz=259607 oc=-91, 32.1s}
31.Rab1
{0.1s}
31...Rxe3
{d=5 uz=183401 oc=-7, 26.8s}
32.Rb7
{0.0s}
32...Nxf3+
{d=5 uz=154155 oc=64, 20.3s}
33.Kf2
{0.0s}
33...Rxe2+
{d=5 uz=166238 oc=-85, 20.4s}
34.Kxe2
{0.0s}
34...Nxd4+
{d=5 uz=70916 oc=-105, 13.9s}
35.Kd3
{0.1s}
35...Nf3
{d=5 uz=76496 oc=-15, 13.1s}
36.Rcc7
{0.0s}
36...a5
{d=5 uz=93165 oc=-14, 14.3s}
37.Nb8
{0.1s}
37...Ne5+
{d=6 uz=256798 oc=-116, 26.5s}
38.Ke2
{0.0s}
38...h5
{d=5 uz=63680 oc=-129, 12.3s}
39.Rxg7+
{0.0s}
39...Kh8
{d=6 uz=213143 oc=-457, 23.7s}
40.Rgc7
{0.0s}
40...Nf7
{d=98 uz=159968 oc=-7997, 18.9s}
41.Rxf7
{0.1s}
41...Kg8
{d=98 uz=21928 oc=-7998, 9.1s}
42.Rfe7
{0.0s}
42...Kf8
{d=98 uz=16012 oc=-7998, 8.7s}
43.Red7
{0.0s}
43...Ke8
{d=98 uz=17972 oc=-7998, 8.0s}
44.Re7+
{0.0s}
44...Kf8
{d=98 uz=16468 oc=-7998, 7.3s}
45.Red7
{0.0s}
45...Ke8
{d=98 uz=17975 oc=-7998, 7.4s}
46.Rg7
{0.0s}
46...Kf8
{d=98 uz=25768 oc=-7998, 8.1s}
47.Rgf7+
{0.0s}
47...Kg8
{d=98 uz=26136 oc=-7998, 8.8s}
48.Rfd7
{0.0s}
48...Kf8
{d=98 uz=2232 oc=-7999, 6.6s}
49.Rd8#
{0.0s}
1-0
[/pgn]
The first game with no book at all — and arguably the most dramatic in the journal: stm32 won the exchange from Stockfish on move five, held a positive score for fourteen moves, and then sold back everything it had won through the very greed that earned it. A move-by-move breakdown.

The opening: the book silent, on schedule
1.Nf3 — and not a single book marker all game. Exactly what 7.2 records: the generator's tree branches off White's first move, and the roots 1.Nf3 and 1.c4 were never seeded into the book. This is direct confirmation of the coverage note — and a ready-made argument for the plan item «re-run the generator with these roots». The search kicked in from move one: 1…d5, d=5, 60,774 nodes in 19.1 s.

5…Bxb1! — the exchange won honestly
The mechanics are elegant: 5.c4 opened the f5–b1 diagonal (the pawns left d2 and c2), and the bishop picked up the rook from its root. oc=2 — exactly the price of the exchange in micro-Max's scale: rook 5 minus bishop 3. Then the signature tempo: 6…Bb4+ forces 7.Ke2, because the king's cover squares are empty. The king left e1 on move 7 and never castled — the entire game went with the king in the center.

The score climbed: 45 → 73 → 85 → 112 → 129 → 165 after 13…Nxa3. The engine believed it was winning — and on pure material it was not deceived.

The price of greed: the knight on a3
13…Nxa3 is the classic gesture of materialism: the knight grabs a pawn without noticing the a1 rook is already watching it. One move later the horizon opened: 14.Qc6+ Nd7 — and oc collapsed from +165 to −93. The sharpest drop in all six games — not a slide, but a jump across zero. 15.Bxd6 cxd6, 16.Rxa3 — the knight died, the whole knight affair netted zero, and as a by-product doubled pawns d6 and d5 were born (c7xd6 with the live d7→d5).

Incidentally, this is the first live appearance of doubling in our own camp — i.e. the first real test of the strengthened penalty 9→20. No pathologies visible: the engine did not start paying material for structure. To be fair, attribution remains impossible without the A/B run — but the observation deserves a journal entry.

The hunt and the rook's death
After the queen trade (21.Qxb7 Rxb7 — a rare d=6, 483,683 nodes) stm32 was down the exchange, and oc slid: −218 → −200. Then the final raid: 28…Nxh2, 31…Rxe3, 32…Nxf3+, 33…Rxe2+ — knight and rook chewing pawns with checks, oc briefly back to −7 and even +64: the engine believed it was winning again. And paid immediately: 34.Kxe2 — the rook that had taken the bishop was eaten by the king. Raid tally: a pawn and a bishop for a rook — minus.

The short +64 spike on move 32 is the second case in the journal where oc «invents» a win out of capture scraps. The «how to read the log» table wants a line: oc jumps across zero on a capture streak — the horizon believes the last capture, not the position.

32.Rb7 — the quiet move that decided the endgame
While Black's rook chased pawns, White's settled on b7 and sat there for seventeen moves. That was the mate mechanism: the rook on the seventh rank kept e7/f7/g7 covered, so after 39.Rxg7+ and 40…Nf7 the king was locked on the back rank, and the Rd8+ battery, supported from b7, closed everything. 49.Rd8# — a textbook back-rank mate, the classic micro-Max cannot see by design of its evaluator until the very end.

The mate zone: late cutoff, again
d=98 switched on only at 40…Nf7 — like game 5, the late mode: not the conscious defeat of game 3, but a cutoff at a short forced mate. Then ten d=98 lines with oc between −7997 and −7999 (at 48…Kf8 — −7999, the deepest resistance choice in the journal): the king shuttles Kf8/Ke8, choosing the slowest death. The mate zone repeats games 3–5 move for move — this is now a stable character, not a coincidence.

Telemetry
Metric Value
Game length 49 moves — longest in the journal (was 27–44)
Book 0 hits — 1.Nf3 outside coverage, expected
Search moves 43
Total nodes ≈ 8.2 M — a record
Total time ≈ 1234 s (~20.5 min)
Speed ≈ 6.6 k nodes/s — unchanged
Peak 653,154 nodes / 79.8 s (18…Rb8) — second all-time
Depth d=5 normal; d=6 three times (record); d=4 seven times
Mate zone from move 40, ten d=98 lines, mate on 49
Seven d=4 iterations — a new detail: in positions with frequent captures, vertical extensions make the iteration expensive, the 60,000 budget dies inside it, and d=5 never gets its turn. A second confirmation of the line «d=4 at level 60,000 — not a depth rollback, but an expensive iteration».

The edits in live play
Castling is not tested at all in this game — the king was forced to Ke2 as early as move 7, so the 50/20 tilt never got a chance. Pawn penalty: the doubled pawns d6/d5 — real work with no visible pathologies, the fourth game running with the edits in calm mode.

Bottom line
Three storylines in one game: the book silent on schedule (an argument for re-running the generator with root 1.Nf3), the exchange won and sold off by greed (the knight on a3, the rook walked into the king), and the quiet Rb7 as the carrier of mate. Humanly — a game where the engine fought again: 49 moves, eight million nodes, pawn raids with checks. Engineerially — a rich haul: the sharpest oc drop, the false +64 spike, the first live sighting of doubled pawns, and a second specimen of the late mate zone.

By the way — the new thing: 2…Bf5. The most elegant rhyme of the whole series. Bf5 — the very f5 from the 10.7 table: the top row, payoff +15.0, the square the search rejected in game 1 in favor of the «safe» e6. Now the magnet finally got its chance — and played exactly there.

Why it worked now and not in the Sicilian is all in the position context, and it is a perfect illustration that the magnet error of 10.7 was not an error of the magnet:

The e2 pawn still stands on e2 (it has not gone to e4 as in game 1): the c1–f5 diagonal is… in fact the c1 bishop looks the other way and already leaves for f4 on move 3, never creating a Bxf5 theme.
There is no g2g4 lunge: from the position after 2.d4, a g2–g4 lunge would cost tempo and expose the king — at horizon d=5 it is not a «cheaply won bishop» but simply lost time. The capture themes on f5 that in the Sicilian clawed the +15 bonus down to zero simply did not exist here.
Net result: the search honestly went through the retreat squares, the horizon found nothing against f5 — and the magnet played its first candidate, which happened to be the theoretically best move as well (1.Nf3 d5 2.d4 Bf5 — the classical setup, bishop developed before the e6 that would have entombed it).
So the 10.7 verdict «an inherited trait, not a porting defect» received a second, even cleaner confirmation: the magnet is not bad — it maximizes centrality on the visible board, and filtering out unsuitable candidates is the search's job. In game 1 the search filtered f5 because the horizon saw the bishop falling; here the horizon was silent — and the magnet played its best.

And the cherry on top: the bishop that reached f5 took the rook on b1 three moves later (5…Bxb1) — the magnet move literally won the exchange, the very trophy later sold off by the knight on a3. The bishop the magnet once talked into fifteen idle moves in game 1 became the hero of this game's first half.

This wants a short sidebar in 10.7, «the magnet's redemption»: one game showed the price of its blindness, this one shows the purity of its work given an honest horizon. I'll fold it in together with game 6 into 10.4 and the 10.5 update — or shall we leave the observation in the chat until the next session?
Alexlaw1964
Posts: 41
Joined: Fri Jan 03, 2025 6:36 am
Full name: alex lobov

Re: Move Generation: Castling - micro-Max by H.G. Muller

Post by Alexlaw1964 »

micro-Max on STM32: FEN castling rights via the "moved" flag (bit 32) + center-pts table as a positional evaluation
Intro

We ported H.G. Muller's micro-Max (umax4_8.c) to an STM32 board. To load arbitrary
positions we wrote a FEN parser, and castling rights were initially handled through
an extra bit (64) OR-ed onto rooks and kings, with a hack that saved/restored those
bits in the move generator. It worked, but it was ugly. This post describes the
cleaner approach we switched to: using the engine's own "moved" flag (bit 32) as
the single source of truth, driven entirely by the FEN castling-rights field. We
also include a walkthrough of micro-Max's center-pts table, and how we extended it
into a working positional evaluation (PST), which took us some time to decode.

The original "virgin" scheme in micro-Max

In the original there is no "has not moved yet" bit. Instead, bit 32 means
"already moved":

Code: Select all

b[G]=b[H]=b[x]=0; b[y]=u|32;    /* do move, set non-virgin */
At setup, piece bytes are created without the flag, so every piece starts virgin
by default. Nothing needs to be set explicitly. Undo simply restores the old byte,
and the flag disappears with it.

The generator checks are tied to the absence of the flag:
  • Castling: the rook byte in the corner must equal exactly k+6
    (own color, rook type, no flag). A moved rook becomes k+6+32 and the comparison
    fails.
  • Double pawn push: the condition on u&32 stops the ray after one step
    for a moved pawn; a virgin pawn may run two squares.
So virginity is stored as an absence of a flag, not a presence of one.
Our first port did the opposite (a bit 64 for "virgin"), which forced hacks both
in the parser and in the generator.

The FEN parser fix (inverted logic)

The idea: if a castling-rights letter is present, do nothing (piece stays virgin).
If the letter is missing, set bit 32 on the king and the corresponding rook,
but only if those squares are actually occupied (checked with &7), otherwise an
empty square would become a phantom "piece of type 0".

Code: Select all

/* collect rights while scanning the rights field */
case 'K': rights |= 1; break;
case 'Q': rights |= 2; break;
case 'k': rights |= 4; break;
case 'q': rights |= 8; break;
...
/* no right -> mark king and rook as "moved" (if present) */
if(!(rights&1)){ if(b[116]&7) b[116]|=32; if(b[119]&7) b[119]|=32; } /* no K: e1,h1 */
if(!(rights&2)){ if(b[112]&7) b[112]|=32; if(b[116]&7) b[116]|=32; } /* no Q: a1,e1 */
if(!(rights&4)){ if(b[4] &7) b[4] |=32;  if(b[7] &7)  b[7] |=32;  } /* no k: e8,h8 */
if(!(rights&8)){ if(b[4] &7) b[4] |=32;  if(b[0] &7) b[0] |=32;  } /* no q: a8,e8 */
Also important: the board must be cleared before parsing a new FEN, otherwise
flags from the previous game leak into the new position through |=.

The move generator

The rook test changes from u+66 to (u&24)+6 — "virgin rook of the king's color",
independent of what k actually is — plus an explicit virgin-king check (u&32),
which in the old scheme was hidden inside the u+66 arithmetic:

Code: Select all

t += p<5
  ^ p<3 & 6*k+(y&V)==S
  ^ j<8 & y & !(u&32)
    & b[H1=x^3^r>>1&7]==(u&24)+6
    & !b[H1^1] & !b[H1^2];
The trick is unchanged: the condition fires on every square of the king ray,
zeroing t (1^1=0), so the king passes the second square (g1/c1) and stops on
the virgin rook. If the rook is gone, is the wrong color, or has moved, the
condition is false, t stays 1, and the ray ends after one step like any king move.

In D() only three lines changed (k+6 / k-6 -> (u&24)+6):

Code: Select all

if(!(G&M))b[F]=(u&24)+6,v+=y>x?50:20;      /* rook relocation bonus */
...
b[G]=(u&24)+6;b[F]=b[y]=0;b[x]=u;b[H]=t;   /* undo: restore virgin rook */
...
p>2&(p-4|j-7||
b[G=x+3^r>>1&7]-(u&24)-6                   /* virgin rook test */
||b[G^1]|b[G^2])
Note: 32 & 15 == 0, so the PST index u&15 is unaffected by the moved flag —
moved and virgin pieces of one side index the same table row.

Applying real moves

Instead of clearing a virgin bit, we set the moved flag; castling moves the rook
separately and must flag it too, or a rook returning to its corner would grant
a second castling:

Code: Select all

b[y] = b[x] | 32;  b[x] = 0;           /* ordinary move */

/* white O-O: */
b[118] = b[116] | 32;  b[116] = 0;
b[117] = b[119] | 32;  b[119] = 0;
/* white O-O-O: */
b[114] = b[116] | 32;  b[116] = 0;
b[113] = b[112] | 32;  b[112] = 0;
/* black analogues on 4/6/7 and 4/2/1 */
Search and undo need no changes: state copies and undo restore the flag bytes
automatically.

Castling encodings (256*K + L, board a8=0 .. h1=119)

Code: Select all

e1g1  white O-O    : 256*116+118 = 29814 (0x7476)
e1c1  white O-O-O  : 256*116+114 = 29810 (0x7472)
e8g8  black O-O    : 256*4+6     =  1030 (0x0406)
e8c8  black O-O-O  : 256*4+2     =  1026 (0x0402)
Neighbors for reference: e1f1=29713, e8f8=1029, e1d1=29811, e8d8=1027.

The center-pts table

The mysterious line inside the setup loop:

Code: Select all

K=8;W(K--)
{
  b[K]=(b[K+112]=o[K+24]+8)+8;b[K+16]=18;b[K+96]=9; /* initial board setup*/
  L=8;W(L--)b[16*L+K+8]=(K-4)*(K-4)+(L-3.5)*(L-3.5); /* center-pts table */
} /*(in unused half b[])*/
It computes the squared distance to the board center: (K-4)^2 + (L-3.5)^2.
Smaller = closer to the center. File component (K-4)^2: a=16, b=9, c=4, d=1,
e=0, f=1, g=4, h=9. Rank component (L-3.5)^2 truncated to int: rank8=12,
rank7=6, rank6=2, rank5=0, rank4=0, rank3=2, rank2=6, rank1=12.

So the minimum is 0 on e4/e5, the maximum is 28 in the a-file corners (a8, a1)
and 21 in the h-file corners. A quirk: because it is (K-4) rather than
(K-3.5), the table is shifted half a file toward the e-file — d4/d5 score 1
against 0 on e4/e5. A known asymmetry of the original, not a bug.

The table is stored in the phantom half of the 0x88 board: real squares
occupy 16L+K (addresses 0..119), and the second half of each 16-byte band
(16L+K+8) is unused padding that exists for the off-board test y&M. Pieces and
the table physically never overlap — hence the comment "(in unused half b[])".

It is consulted in D() when applying a move x->y:

Code: Select all

v = p<6 ? b[x+8] - b[y+8] : 0;   /* center positional pts. */
The delta b[x+8]-b[y+8] is positive when moving toward the center, so such
moves score higher. p<6 excludes rooks and queens (their centralization is
already covered elsewhere). Two known side effects of the original:
the "magnetic king" (in the endgame the table pulls the king to the center),
and the king-wander penalty (v -= p-4|R>29 ? 0 : 20 — minus 20 for a king move
while there is still material on the board).

The table is static and position-independent: it is initialized once at startup.
The FEN parser must not touch the phantom bytes (it writes pieces to real
16L+K only). If the loop never runs, b[x+8] holds garbage and the
centralization term becomes noise.

Extending it into a positional evaluation (PST)

We used the same idea to build a proper piece-square table on top of the engine.
Three additions:
  • Move delta in D(): the original single-line center term
    b[x+8]-b[y+8] was replaced by a full PST lookup per piece type:

    Code: Select all

    v = PST[u&15][((x>>4)&7)*8+(x&7)]
      - PST[u&15][((y>>4)&7)*8+(y&7)];
    
    The index u&15 conveniently folds color and type while ignoring bit 32, so the
    moved flag does not affect the lookup.
  • Bonus for the captured piece: when a capture happens, the value of
    the captured square is taken from the PST as well, so a capture on a central
    square is preferred over the same capture on the rim:

    Code: Select all

    i = 99*w[t&7]
      + ((t&7)==4 ? 0 : PST[t&15][((H>>4)&7)*8+(H&7)]);
    
    The (t&7)==4 exclusion skips the king row of the table — a king can never be
    captured (the t&k test breaks earlier), so that row would be dead weight.
  • Scale consistency: both terms stay small compared to the material
    scale (99 per pawn-step in w[]): the move delta is at most a few dozen table
    points, so PST can never distort material decisions. In our runs the
    self-assessment oscillated around zero against a stronger engine, which we
    take as a sign there is no systematic bias.
One known imperfection: on an en-passant capture H has already been flipped
with ^16 (the phantom square), so the PST value of the captured pawn is taken
slightly off its true square. The error is within +-12 table points and very
rare, but it is the first place to look if a strange evaluation jump ever
appears on an e.p. capture.

Test results

We played live against Stockfish 15.1 (python-chess, ENGINE_DEPTH=10, skill 4):

[pgn][Event "Computer Schach Partie"]
[Site "PC"]
[Date "????.??.??"]
[Round "?"]
[White "?"]
[Black "?"]
[Result "*"]
[FEN "r1bqk2r/pppp1ppp/2n2n2/2b1p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 5"]

5.Nc3
{0.3s}
5...Bb4
{d=5 uz=835856 oc=16, tt=384, 126.7s}
6.Bd2
{0.1s}
6...O-O
{d=4 uz=76646 oc=27, tt=0, 18.8s}
7.Qe2
{0.2s}
7...Bxc3
{d=4 uz=67557 oc=7, tt=0, 16.0s}
8.bxc3
{0.2s}
8...d6
{d=5 uz=389074 oc=-1, tt=143, 58.2s}
9.O-O
{0.1s}
*

[/pgn]

Both sides castled in the game, including a black castling chosen on its own
search (oc=+27 at d=4).

Remaining checks
  • En-passant in live play: if moves are applied by code rather than by
    re-parsing FEN, the double pawn push must update the ep square itself, or the
    capture silently disappears from move generation.
  • Undo through a castling move: the position must restore byte-for-byte,
    including bit 32 on king and rook.
Alexlaw1964
Posts: 41
Joined: Fri Jan 03, 2025 6:36 am
Full name: alex lobov

Re: Move Generation: Castling - micro-Max by H.G. Muller

Post by Alexlaw1964 »

Bug we found in our PST implementation on micro-Max (STM32 port): wrong sign in the move delta + a rook mirror error
Intro

We recently added a proper piece-square table (PST) to our STM32 port of
micro-Max, replacing the original one-line center-pts term. After two games
against Stockfish 15.1 (skill 4), the engine showed a suspicious pattern:
its own assessment (oc, the root score) kept climbing while the real
position was getting worse, and then collapsed. Digging into the PST code
revealed two independent bugs. Both are classic, so we describe them here in
case someone repeats the same mistakes.

Bug 1: inverted sign in the move delta

micro-Max's original center table stores the squared distance to the
center
: higher = worse, so the delta is

Code: Select all

v = p<6 ? b[x+8] - b[y+8] : 0;   /* moving to center increases v */
Our PST stores square values: higher = better (pawn on the 7th = 50,
knight in the center = +20). So the same polarity requires the opposite
subtraction order — and we had blindly kept the original one:

Code: Select all

/* WRONG: */
v = PST[u&15][x_idx] - PST[u&15][y_idx];

/* CORRECT: */
v = PST[u&15][y_idx] - PST[u&15][x_idx];
Example: white e2-e4. Original: b[e2+8]-b[e4+8] = 6-0 = +6 (centralization
encouraged). Our wrong formula: PST[e2]-PST[e4] = 8-20 = -12 — the same move
is penalized, and retreat is rewarded.

The consequences were very visible in the games:
  • knights and the queen kept retreating to their home squares
    (Ng8, Qc8 — each retreat was worth up to +15 in the evaluation);
  • black pawns almost never played double pushes — every forward step cost
    points;
  • the root score drifted upward (+104, +191, +227) while the true position
    deteriorated, and then collapsed with a swing of -700 in the opponent's
    assessment.
In other words, the evaluation quietly taught the engine to huddle at home,
and it was honestly convinced it was winning.

Bug 2: broken mirror for the black rook row

We checked all mirror pairs programmatically (white square X vs black square
X^112, with the standard 0x88 board). Five of six piece types were perfect;
the black rook row had its first two ranks swapped:

Code: Select all

/* black rook row had: */
{ 0, 0, 0, 5, 5, 0, 0, 0,     /* rank 1: the 7th-relative bonus, wrongly */
  -5, 0, 0, 0, 0, 0, 0, -5,   /* rank 2: penalized, wrongly */  ... }

/* correct mirror of the white row: */
{ 0, 0, 0, 0, 0, 0, 0, 0,     /* rank 1 */
  15, 15, 15, 15, 15, 15, 15, 15,   /* rank 2 = 7th relative */  ... }
So the black rook got the "7th rank" bonus on its first rank and a
penalty on its second — a 20-point asymmetry between mirrored positions.

What we did NOT change
  • The capture bonus i = 99*w[t&7] + PST[t&15][H] is correct as is:
    capturing a piece off a good square should be rewarded.
  • The king rows are vertically symmetric, so they mirror correctly by
    construction.
  • The index u&15 is unaffected by the "moved" flag (32 & 15 == 0), so the
    flag-free castling scheme we described earlier keeps working.
A minor imperfection we noted for the future: on promotion, the move delta is
taken from the pawn row on the last rank (60-68 points), while the
piece appearing there is the promoted one. The effect is small (about -10
against the promotion bonus) but worth knowing if the engine ever seems
reluctant to promote.

Test results (before the fix)

Both games ended in losses after long resistance (53 and 75 moves), but the
diagnostic value was in the self-assessment curve, not the result:

Code: Select all

[Event "Computer Schach Partie"]
[FEN "r1bqk2r/pppp1ppp/2n2n2/2b1p3/2B1P3/3P1N2/PPP2PPP/RNBQK2R w KQkq - 4 5"]

22... e6f5 {oc=+103}   <-- first big optimism jump
23... f6h4 {oc=+139}
24... h4h2 {oc=+166}
25... h2g2 {oc=+255}
26... c8e6 {diff=-275} <-- reality check arrives
30... g8h8 {diff=-488}
Second game, same pattern: oc = +104, +191, +227 in a objectively equal or
worse position, then -296, -728 after the decisive blows.

Summary

If you port micro-Max's center-pts term into a real PST, remember: the
original term is a distance (minus is good), a PST is a value
(plus is good) — flip the subtraction. And test every black row against the
white mirror; a single swapped rank pair is invisible in casual play but
shows up as a systematic eval bias over a game.
Alexlaw1964
Posts: 41
Joined: Fri Jan 03, 2025 6:36 am
Full name: alex lobov

Re: Move Generation: Castling - micro-Max by H.G. Muller

Post by Alexlaw1964 »

micro-Max STM32 follow-up: the PST sign fix paid off - first win against Stockfish 15.1 (skill 4), and a mate in 1 delivered by the engine itself
Intro

This is a follow-up to our earlier posts on the STM32 port of micro-Max
(umax4_8.c): the FEN castling scheme via the "moved" flag and the PST bug
report (inverted sign in the move delta + a rook mirror error). After
applying the fixes we played a new series against Stockfish 15.1
(python-chess, ENGINE_DEPTH=10, skill 4; our engine always plays black),
with two additional tuning changes on top of the bug fixes. This post
summarizes what changed in the engine's behavior - the diagnostics turned
out to be more interesting than the game results, and one of the games was
a first win.

What we changed since the bug-fix post
  • Differentiated shield penalty. In the original, the pawn
    penalty for moving in front of an unmoved king is a flat -20:

    Code: Select all

    v -= 20 * ((x-2&M||b[x-2]-u) + (x+2&M||b[x+2]-u) - 1
         + (b[x^16]==k+36));
    
    We split the king term: -10 for a single step or capture (displacement
    +-8, +-7/+-9), -20 for the double push (displacement +-16):

    Code: Select all

    v -= 20 * ((x-2&M||b[x-2]-u) + (x+2&M||b[x+2]-u) - 1);
    if(b[x^16]==k+36) v -= 10 + 10*!((y-x)&15);
    
    The intent: e7-e6 (which frees the f8 bishop and enables O-O) should be
    cheap, e7-e5 in front of an unmoved king should stay expensive.
  • Halved the top pawn PST row (60/62/64/68 -> 30/31/32/34, both
    colors). We should note honestly: this row is only reachable in the
    promotion delta (a pawn can never stand on the last rank), so it
    cannot influence opening pawn pushes at all - the original "suicidal pawn"
    regulator hypothesis was wrong. We kept the change as a promotion-behavior
    tuning, nothing more.
The sign fix in action

The difference is immediately visible in the move lists. Before the fix,
the engine was paid for retreating (Ng8, Qc8 each scored up to +15)
and black pawns almost never played double pushes. After the fix:
  • no rewarded retreats anywhere in the series;
  • 7 out of 8 black pawns moved in one of the games (d5, c5, c4, f5, g5,
    b5, a6, h6 - including double pushes);
  • the famous "magnetic king" works as designed: in the winning game the
    king marched Kf8-e7-e6-f5 into the attack in the middlegame, exactly
    what the PST endgame pull is supposed to encourage once material thins
    out.
[pgn][Event "Computer Schach Partie"]
[Site "PC"]
[Date "????.??.??"]
[Round "?"]
[White "stockfish 15.1"]
[Black "stm32"]
[Result "0-1"]
[Termination "normal"]

1.e4
{0.2s}
1...c5
{book, bal=+0, diff=+0, 7.5s}
2.d4
{0.1s}
2...cxd4
{book, bal=+0, diff=+0, 7.0s}
3.Nf3
{0.1s}
3...e5
{book, bal=+0, diff=+0, 6.9s}
4.c3
{0.1s}
4...Nc6
{book, bal=+0, diff=+0, 6.3s}
5.Bc4
{0.2s}
5...Nf6
{book, bal=+0, diff=+0, 6.2s}
6.cxd4
{0.1s}
6...Nxe4
{book, bal=+0, diff=+0, 6.0s}
7.O-O
{0.1s}
7...d5
{d=4 uz=78718 oc=137, tt=1, bal=+1, diff=+38, 17.9s}
8.Re1
{0.1s}
8...Bb4
{d=4 uz=81743 oc=217, tt=1, bal=+1, diff=+118, 18.8s}
9.Nbd2
{0.2s}
9...Nxd2
{d=4 uz=111843 oc=252, tt=15, bal=+1, diff=+153, 22.8s}
10.Bxd2
{0.1s}
10...Bxd2
{d=5 uz=455939 oc=284, tt=15, bal=+1, diff=+185, 68.3s}
11.Nxd2
{0.1s}
11...dxc4
{d=5 uz=259638 oc=295, tt=21, bal=+1, diff=+196, 40.4s}
12.Nxc4
{0.1s}
12...Nxd4
{d=5 uz=389095 oc=324, tt=73, bal=+3, diff=+27, 51.2s}
13.Rxe5+
{0.2s}
13...Be6
{d=5 uz=388453 oc=317, tt=103, bal=+3, diff=+20, 52.5s}
14.Re4
{0.1s}
14...Nf5
{d=5 uz=363249 oc=271, tt=90, bal=+3, diff=-26, 45.9s}
15.Qa4+
{0.1s}
15...Kf8
{d=5 uz=177769 oc=280, tt=71, bal=+3, diff=-17, 27.7s}
16.Rae1
{0.1s}
16...h5
{d=5 uz=810469 oc=314, tt=304, bal=+3, diff=+17, 100.3s}
17.Qb5
{0.1s}
17...Qd5
{d=5 uz=620245 oc=309, tt=195, bal=+3, diff=+12, 79.7s}
18.Qxd5
{0.1s}
18...Bxd5
{d=5 uz=78371 oc=308, tt=27, bal=-6, diff=+902, 14.2s}
19.R4e2
{0.2s}
19...Bxc4
{d=6 uz=524717 oc=601, tt=213, bal=+3, diff=+304, 58.3s}
20.Rc2
{0.4s}
20...Bxa2
{d=5 uz=68266 oc=700, tt=12, bal=+6, diff=+106, 13.6s}
21.Rcc1
{0.3s}
21...Rh7
{d=5 uz=108441 oc=727, tt=51, bal=+7, diff=+34, 18.2s}
22.Rc7
{0.3s}
22...Nd6
{d=5 uz=121640 oc=702, tt=20, bal=+7, diff=+9, 18.8s}
23.h3
{0.4s}
23...Be6
{d=5 uz=134225 oc=735, tt=33, bal=+7, diff=+42, 19.8s}
24.Re5
{0.3s}
24...Rc8
{d=5 uz=370175 oc=715, tt=207, bal=+7, diff=+22, 41.4s}
25.Rc3
{0.2s}
25...Nc4
{d=5 uz=244621 oc=726, tt=111, bal=+7, diff=+33, 31.3s}
26.Rb5
{0.2s}
26...Rc7
{d=5 uz=273638 oc=723, tt=378, bal=+7, diff=+30, 33.9s}
27.b3
{0.4s}
27...Nd6
{d=5 uz=184225 oc=663, tt=3, bal=+7, diff=-30, 23.8s}
28.Rxc7
{0.1s}
28...Nxb5
{d=6 uz=267834 oc=601, tt=13, bal=+2, diff=+403, 28.4s}
29.Rc5
{0.2s}
29...Nd4
{d=5 uz=60711 oc=725, tt=0, bal=+7, diff=+32, 10.9s}
30.Rc7
{0.2s}
30...Bd5
{d=5 uz=59919 oc=710, tt=0, bal=+7, diff=+17, 12.0s}
31.h4
{0.2s}
31...Nxb3
{d=5 uz=64973 oc=794, tt=0, bal=+7, diff=+101, 12.6s}
32.Rd7
{0.1s}
32...Be4
{d=6 uz=308326 oc=804, tt=26, bal=+8, diff=+12, 31.8s}
33.Kh2
{0.1s}
33...Nc5
{d=5 uz=90536 oc=814, tt=3, bal=+8, diff=+22, 14.7s}
34.Rd8+
{0.1s}
34...Ke7
{d=6 uz=119091 oc=779, tt=74, bal=+8, diff=-13, 16.7s}
35.Rd1
{0.1s}
35...Ke6
{d=5 uz=89296 oc=802, tt=3, bal=+8, diff=+10, 14.7s}
36.Re1
{0.2s}
36...Kf5
{d=5 uz=70268 oc=800, tt=2, bal=+8, diff=+8, 13.4s}
37.f3
{0.2s}
37...Bd5
{d=6 uz=443640 oc=783, tt=57, bal=+8, diff=-9, 43.8s}
38.Kh3
{0.2s}
38...Be6
{d=5 uz=63718 oc=801, tt=7, bal=+8, diff=+9, 11.7s}
39.Rb1
{0.2s}
39...Kf4+
{d=6 uz=326508 oc=800, tt=25, bal=+8, diff=+8, 35.5s}
40.Kh2
{0.0s}
40...Nb3
{d=5 uz=101717 oc=799, tt=0, bal=+8, diff=+7, 15.2s}
41.Rf1
{0.1s}
41...g5
{d=5 uz=108077 oc=813, tt=0, bal=+8, diff=+21, 16.0s}
42.g3+
{0.1s}
42...Kf5
{d=6 uz=171357 oc=803, tt=4, bal=+8, diff=+11, 20.3s}
43.Rd1
{0.2s}
43...Rg7
{d=5 uz=81849 oc=814, tt=3, bal=+8, diff=+22, 13.5s}
44.Kh3
{0.1s}
44...Ke5+
{d=5 uz=104899 oc=821, tt=8, bal=+8, diff=+29, 16.1s}
45.Kh2
{0.1s}
45...Nd4
{d=5 uz=90609 oc=823, tt=2, bal=+8, diff=+31, 14.5s}
46.f4+
{0.1s}
46...gxf4
{d=6 uz=456272 oc=905, tt=31, bal=+8, diff=+113, 47.6s}
47.gxf4+
{0.1s}
47...Ke4
{d=6 uz=436412 oc=795, tt=0, bal=+8, diff=+3, 45.6s}
48.f5
{0.0s}
48...Nxf5
{d=6 uz=494534 oc=904, tt=25, bal=+8, diff=+112, 47.4s}
49.Ra1
{0.1s}
49...b5
{d=5 uz=94046 oc=907, tt=32, bal=+9, diff=+16, 14.2s}
50.Kh1
{0.1s}
50...Nxh4
{d=5 uz=77069 oc=920, tt=2, bal=+9, diff=+29, 12.9s}
51.Rd1
{0.1s}
51...Nf3
{d=5 uz=66296 oc=1046, tt=3, bal=+10, diff=+56, 11.9s}
52.Rb1
{0.0s}
52...f5
{d=5 uz=89779 oc=1034, tt=2, bal=+10, diff=+44, 14.5s}
53.Rc1
{0.0s}
53...f4
{d=5 uz=67995 oc=1044, tt=2, bal=+10, diff=+54, 12.5s}
54.Ra1
{0.0s}
54...Bh3
{d=5 uz=91827 oc=1038, tt=2, bal=+10, diff=+48, 14.3s}
55.Ra4+
{0.0s}
55...Kd5
{d=6 uz=490489 oc=1548, tt=9, bal=+10, diff=+558, 47.7s}
56.Ra3
{0.0s}
56...Bg2#
{d=98 uz=171 oc=7999, tt=0, bal=+10, diff=+7009, 5.8s}
0-1
[/pgn]
The first win - and what made it possible

Second game of the series, black (micro-Max) wins:

Code: Select all

33. g1h2 {0.1s} b3c5 {oc=+814, diff=+22}
...
55. a1a4 {0.0s} e4d5 {oc=+1548, diff=+558}   <-- engine sees +5.5 pawns
56. a4a3 {0.0s} h3g2 {d=98 uz=171, 5.8s}     <-- mate in 1, 171 nodes
0-1
Two details worth noting. First, the +1548 assessment at move 55 was not
false optimism of the kind we saw before the sign fix: a mating net was
genuinely materializing, and even Stockfish skill 4 did not see it in time.
Second, the mate in 1 was found by the engine itself: d=98, 171
nodes, 5.8 s. The mate-distance scoring (score = -(8000 - plies)) works
exactly as in the original.

Calibration: a 40-move endgame with oc ~= -200, bal = -2
[pgn][Event "Computer Schach Partie"]
[Site "PC"]
[Date "????.??.??"]
[Round "?"]
[White "stockfish 15.1"]
[Black "stm32"]
[Result "*"]
[Termination "normal"]

1.Nf3
{0.2s}
1...d5
{d=5 uz=65402 oc=25, tt=0, bal=+0, diff=+25, 20.6s}
2.e3
{0.1s}
2...c5
{d=5 uz=143027 oc=20, tt=0, bal=+0, diff=+20, 32.6s}
3.h3
{0.1s}
3...Be6
{d=5 uz=227927 oc=8, tt=2, bal=+0, diff=+8, 39.5s}
4.b3
{0.2s}
4...f5
{d=5 uz=226147 oc=8, tt=8, bal=+0, diff=+8, 41.0s}
5.d4
{0.2s}
5...Nc6
{d=5 uz=255953 oc=13, tt=24, bal=+0, diff=+13, 43.0s}
6.Ba3
{0.2s}
6...c4
{d=5 uz=320611 oc=-16, tt=63, bal=+0, diff=-16, 49.7s}
7.bxc4
{0.1s}
7...dxc4
{d=5 uz=130209 oc=-17, tt=17, bal=-1, diff=+82, 23.7s}
8.c3
{0.2s}
8...Nf6
{d=5 uz=343354 oc=-5, tt=65, bal=+0, diff=-5, 52.6s}
9.Nfd2
{0.1s}
9...b5
{d=5 uz=389992 oc=-31, tt=25, bal=+0, diff=-31, 59.3s}
10.Be2
{0.1s}
10...g5
{d=5 uz=464881 oc=-18, tt=48, bal=+0, diff=-18, 68.9s}
11.Nf3
{0.1s}
11...Bh6
{d=5 uz=395898 oc=-15, tt=21, bal=+0, diff=-15, 60.4s}
12.Bc5
{0.2s}
12...Ne4
{d=4 uz=73635 oc=3, tt=1, bal=+0, diff=+3, 16.5s}
13.Nfd2
{0.1s}
13...Nxc5
{d=4 uz=65358 oc=-10, tt=4, bal=+0, diff=-10, 15.5s}
14.dxc5
{0.1s}
14...O-O
{d=5 uz=361377 oc=-14, tt=49, bal=+0, diff=-14, 51.9s}
15.O-O
{0.2s}
15...Qd5
{d=5 uz=277304 oc=99, tt=47, bal=+0, diff=+99, 41.4s}
16.a4
{0.1s}
16...a6
{d=5 uz=332401 oc=75, tt=133, bal=+0, diff=+75, 47.8s}
17.Nf3
{0.1s}
17...Qxc5
{d=5 uz=380571 oc=95, tt=383, bal=+0, diff=+95, 55.3s}
18.Nd4
{0.1s}
18...Bd5
{d=5 uz=584270 oc=98, tt=581, bal=+1, diff=-1, 83.3s}
19.axb5
{0.1s}
19...Nxd4
{d=5 uz=566839 oc=84, tt=514, bal=+0, diff=+84, 74.8s}
20.exd4
{0.1s}
20...Qxb5
{d=5 uz=178597 oc=104, tt=87, bal=+0, diff=+104, 27.3s}
21.Bf3
{0.1s}
21...Rf7
{d=5 uz=278274 oc=117, tt=255, bal=+1, diff=+18, 40.6s}
22.Nd2
{0.1s}
22...Bxf3
{d=5 uz=1107346 oc=98, tt=1446, bal=+1, diff=-1, 134.4s}
23.Nxf3
{0.2s}
23...Ra7
{d=5 uz=143476 oc=121, tt=44, bal=+1, diff=+22, 23.1s}
24.Qe2
{0.1s}
24...Rc7
{d=5 uz=224831 oc=101, tt=163, bal=+1, diff=+2, 32.6s}
25.Rfb1
{0.1s}
25...Qc6
{d=5 uz=200789 oc=101, tt=14, bal=+1, diff=+2, 29.1s}
26.Rb8+
{0.1s}
26...Rf8
{d=5 uz=378655 oc=1, tt=321, bal=+1, diff=-98, 48.4s}
27.Rxf8+
{0.1s}
27...Bxf8
{d=5 uz=121969 oc=16, tt=22, bal=-4, diff=+412, 19.2s}
28.Rb1
{0.1s}
28...Qf6
{d=5 uz=183996 oc=101, tt=48, bal=+1, diff=+2, 26.2s}
29.Nxg5
{0.1s}
29...Qxg5
{d=5 uz=111288 oc=196, tt=15, bal=+0, diff=+196, 17.8s}
30.Qe6+
{0.1s}
30...Kg7
{d=6 uz=224786 oc=-218, tt=26, bal=+3, diff=-515, 28.0s}
31.Qe5+
{0.1s}
31...Qf6
{d=6 uz=259560 oc=-221, tt=18, bal=+3, diff=-518, 29.6s}
32.Qg3+
{0.1s}
32...Kf7
{d=6 uz=309510 oc=-221, tt=38, bal=+3, diff=-518, 33.7s}
33.Qxc7
{0.1s}
33...Qe6
{d=5 uz=90087 oc=-202, tt=7, bal=-2, diff=-4, 14.9s}
34.Qf4
{0.1s}
34...Bg7
{d=5 uz=116460 oc=-197, tt=8, bal=-2, diff=+1, 17.4s}
35.Qg5
{0.1s}
35...Bh6
{d=5 uz=165176 oc=-223, tt=14, bal=-2, diff=-25, 20.7s}
36.Qh4
{0.1s}
36...Qg6
{d=5 uz=173452 oc=-203, tt=7, bal=-2, diff=-5, 22.0s}
37.f4
{0.1s}
37...Bg7
{d=5 uz=163873 oc=-202, tt=4, bal=-2, diff=-4, 20.7s}
38.Qf2
{0.1s}
38...Qc6
{d=5 uz=163395 oc=-207, tt=7, bal=-2, diff=-9, 21.6s}
39.Qe2
{0.1s}
39...Bh6
{d=5 uz=152672 oc=-218, tt=47, bal=-2, diff=-20, 22.8s}
40.Re1
{0.1s}
40...Bxf4
{d=5 uz=135328 oc=-208, tt=15, bal=-2, diff=-10, 19.6s}
41.Qxe7+
{0.1s}
41...Kg8
{d=5 uz=94867 oc=-252, tt=11, bal=-2, diff=-54, 14.5s}
42.Kh1
{0.1s}
42...Qc8
{d=5 uz=315780 oc=-211, tt=82, bal=-2, diff=-13, 32.5s}
43.Qe8+
{0.1s}
43...Qxe8
{d=6 uz=144189 oc=-320, tt=341, bal=-2, diff=-122, 16.3s}
44.Rxe8+
{0.1s}
44...Kf7
{d=7 uz=247960 oc=-302, tt=251, bal=-2, diff=-104, 22.8s}
45.Rc8
{0.1s}
45...Ke6
{d=6 uz=201444 oc=-340, tt=68, bal=-2, diff=-142, 22.0s}
46.Rc6+
{0.1s}
46...Kd5
{d=6 uz=70027 oc=-319, tt=8, bal=-2, diff=-121, 11.0s}
47.Rc5+
{0.0s}
47...Ke4
{d=6 uz=67424 oc=-337, tt=2, bal=-2, diff=-139, 10.8s}
48.Rxc4
{0.1s}
48...Kd3
{d=6 uz=216463 oc=-343, tt=11, bal=-3, diff=-46, 22.6s}
49.Rc6
{0.1s}
49...Bd2
{d=6 uz=155046 oc=-311, tt=13, bal=-3, diff=-14, 17.1s}
50.Rxa6
{0.1s}
50...Bxc3
{d=6 uz=135419 oc=-307, tt=60, bal=-4, diff=+89, 15.6s}
51.Ra7
{0.1s}
51...Bxd4
{d=6 uz=101411 oc=-292, tt=32, bal=-3, diff=+5, 13.1s}
52.Rc7
{0.1s}
52...h6
{d=6 uz=148010 oc=-219, tt=4, bal=-2, diff=-21, 17.5s}
53.Rh7
{0.1s}
53...Be3
{d=6 uz=104887 oc=-203, tt=6, bal=-2, diff=-5, 13.1s}
54.Kh2
{0.1s}
54...Bf4+
{d=6 uz=91367 oc=-196, tt=12, bal=-2, diff=+2, 12.1s}
55.g3
{0.1s}
55...Bg5
{d=6 uz=98523 oc=-202, tt=9, bal=-2, diff=-4, 13.2s}
56.h4
{0.1s}
56...Be3
{d=6 uz=112006 oc=-199, tt=5, bal=-2, diff=-1, 13.3s}
57.Kg2
{0.0s}
57...Ke4
{d=6 uz=157406 oc=-203, tt=10, bal=-2, diff=-5, 16.3s}
58.Re7+
{0.0s}
58...Kd4
{d=6 uz=131280 oc=-216, tt=9, bal=-2, diff=-18, 14.3s}
59.Rc7
{0.1s}
59...Ke4
{d=6 uz=130250 oc=-205, tt=4, bal=-2, diff=-7, 15.0s}
60.Re7+
{0.1s}
60...Kd4
{d=6 uz=131662 oc=-216, tt=9, bal=-2, diff=-18, 14.8s}
61.Rf7
{0.1s}
61...Ke4
{d=6 uz=129813 oc=-205, tt=3, bal=-2, diff=-7, 13.7s}
62.Rc7
{0.1s}
62...Bd4
{d=6 uz=174048 oc=-201, tt=8, bal=-2, diff=-3, 18.4s}
63.Re7+
{0.0s}
63...Kd5
{d=6 uz=151826 oc=-225, tt=10, bal=-2, diff=-27, 16.4s}
64.Kf3
{0.1s}
64...Bc5
{d=6 uz=314045 oc=-295, tt=11, bal=-2, diff=-97, 27.2s}
65.Re8
{0.0s}
65...Ba3
{d=6 uz=239973 oc=-229, tt=224, bal=-2, diff=-31, 22.8s}
66.Ra8
{0.1s}
66...Bc1
{d=6 uz=241979 oc=-209, tt=232, bal=-2, diff=-11, 22.8s}
67.Ra4
{0.1s}
67...Bd2
{d=6 uz=184530 oc=-208, tt=103, bal=-2, diff=-10, 18.2s}
68.Ra2
{0.1s}
68...Bb4
{d=6 uz=216204 oc=-212, tt=206, bal=-2, diff=-14, 20.4s}
69.Ra1
{0.0s}
69...Be7
{d=6 uz=273641 oc=-209, tt=132, bal=-2, diff=-11, 25.6s}
70.Ra6
{0.1s}
70...Bd6
{d=6 uz=146878 oc=-213, tt=91, bal=-2, diff=-15, 15.5s}
71.Ra8
{0.0s}
71...Be5
{d=6 uz=184571 oc=-218, tt=237, bal=-2, diff=-20, 18.0s}
72.Ra6
{0.0s}
72...Bd6
{d=6 uz=162613 oc=-225, tt=138, bal=-2, diff=-27, 16.8s}
73.g4
{0.0s}
73...fxg4+
{d=6 uz=222209 oc=-233, tt=125, bal=-2, diff=-35, 21.3s}
74.Kxg4
{0.1s}
74...Ke5
{d=6 uz=128505 oc=-240, tt=110, bal=-2, diff=-42, 14.4s}
75.Ra4
{0.1s}
75...Bf8
{d=6 uz=218588 oc=-238, tt=123, bal=-2, diff=-40, 20.0s}
76.h5
{0.0s}
76...Bc5
{d=6 uz=89437 oc=-204, tt=21, bal=-2, diff=-6, 12.3s}
77.Ra5
{0.1s}
77...Kd5
{d=6 uz=76000 oc=-225, tt=45, bal=-2, diff=-27, 10.8s}
78.Kf3
{0.1s}
78...Kc6
{d=6 uz=81329 oc=-223, tt=102, bal=-2, diff=-25, 11.3s}
79.Ra6+
{0.1s}
79...Bb6
{d=7 uz=318807 oc=-215, tt=662, bal=-2, diff=-17, 25.7s}
80.Ra8
{0.1s}
80...Bd4
{d=6 uz=171812 oc=-290, tt=331, bal=-2, diff=-92, 17.8s}
81.Rg8
{0.1s}
81...Kd6
{d=6 uz=162845 oc=-317, tt=105, bal=-2, diff=-119, 16.7s}
82.Rg6+
{0.0s}
82...Kd5
{d=6 uz=66819 oc=-321, tt=5, bal=-2, diff=-123, 10.1s}
83.Rxh6
{0.0s}
83...Ke5
{d=6 uz=74022 oc=-329, tt=2, bal=-3, diff=-32, 10.5s}
84.Rg6
{0.1s}
84...Kd5
{d=6 uz=99723 oc=-324, tt=10, bal=-3, diff=-27, 12.0s}
85.h6
{0.1s}
85...Bh8
{d=6 uz=384617 oc=-489, tt=114, bal=-3, diff=-192, 32.1s}
86.Kf4
{0.1s}
86...Be5+
{d=6 uz=139963 oc=-302, tt=15, bal=-3, diff=-5, 15.0s}
87.Kg4
{0.1s}
87...Ke4
{d=6 uz=406269 oc=-489, tt=104, bal=-3, diff=-192, 33.0s}
88.Re6
{0.1s}
88...Kd5
{d=7 uz=184707 oc=-876, tt=18, bal=-3, diff=-579, 20.7s}
89.Rxe5+
{0.1s}
89...Kxe5
{d=8 uz=264737 oc=-893, tt=63, bal=-6, diff=-299, 22.8s}
90.Kg5
{0.0s}
90...Ke4
{d=8 uz=323316 oc=-894, tt=177, bal=-1, diff=-795, 28.3s}
91.h7
{0.0s}
91...Ke5
{d=7 uz=111852 oc=-901, tt=60, bal=-1, diff=-802, 13.7s}
92.Kh5
{0.1s}
92...Kf5
{d=7 uz=85453 oc=-901, tt=85, bal=-1, diff=-802, 11.8s}
93.Kh6
{0.1s}
93...Ke5
{d=7 uz=74582 oc=-902, tt=77, bal=-1, diff=-803, 11.1s}
94.Kh5
{0.0s}
94...Kf5
{d=7 uz=85466 oc=-901, tt=85, bal=-1, diff=-802, 11.7s}
95.Kh6
{0.0s}
95...Ke5
{d=7 uz=74584 oc=-902, tt=77, bal=-1, diff=-803, 11.1s}
96.Kg5
{0.1s}
96...Kd5
{d=7 uz=137757 oc=-902, tt=31, bal=-1, diff=-803, 15.4s}
97.Kf4
{0.0s}
97...Kd4
{d=7 uz=182111 oc=-901, tt=9, bal=-1, diff=-802, 18.7s}
98.Kf3
{0.0s}
98...Ke5
{d=7 uz=147162 oc=-902, tt=19, bal=-1, diff=-803, 16.2s}
99.Ke3
{0.0s}
99...Ke6
{d=7 uz=131772 oc=-902, tt=70, bal=-1, diff=-803, 14.6s}
100.Ke4
{0.0s}
100...Kf6
{d=7 uz=140379 oc=-902, tt=72, bal=-1, diff=-803, 15.9s}
101.h8=Q+
{0.1s}
101...Kf7
{d=6 uz=99626 oc=-943, tt=33, bal=-9, diff=-52, 13.8s}
102.Kf5
{0.0s}
102...Ke7
{d=7 uz=246399 oc=-943, tt=35, bal=-9, diff=-52, 22.7s}
103.Qd4
{0.1s}
103...Kf7
{d=98 uz=328355 oc=-7997, tt=60, bal=-9, diff=-7106, 29.1s}
104.Kg5
{0.0s}
104...Ke6
{d=7 uz=270778 oc=-930, tt=27, bal=-9, diff=-39, 23.9s}
105.Kf4
{0.0s}
*
[/pgn]
The most valuable diagnostic of the whole series is the lost game.
From move 43 to move 104 the engine's own score stayed within -196..-343
while the material balance was -2, and diff stayed in the +-30 band for
[font=courier]forty consecutive moves[/font]. Engine self-assessment and
the independent material count agreed to within a third of a pawn across
an entire endgame. This is the direct payoff of the sign fix and the
capture correction (adding PST[victim] to the material bonus on
captures): no drift in either direction.

The loss itself was a depth problem, not an evaluation problem - the
engine never claimed it was better when it was worse. Two decisive
moments, both beyond the search horizon:
  • move 26-27: a rook trade offered at d=5 with oc=+1 while the
    truth was -98 (diff=-98): micro-Max gave up the exchange because the
    recapture line was one ply beyond its horizon;
  • move 29: Stockfish sacrificed a knight (oc=+196, diff=+196 - the
    engine happily took the bait); one move later the follow-up showed -515.
Against skill 4 at depth 5-6, that is expected. The evaluator did its job:
it reported the truth once it became visible, and never invented a fantasy
plus.

The remaining bottleneck: the isolated pawn penalty

One observation from the games disagrees with first impressions, so here
are the numbers. Pawn activity is fine everywhere except one square:
in the lost game, e7 never moved. The arithmetic explains why - after d7
and f7 leave early, e7 is isolated, and a step e7-e6 costs:

Code: Select all

PST:  e6(14) - e7(8)            = +6
isolation penalty:              -20    <-- dominant term
new shield penalty (single step): -10
total:                          -24
Our shield change returned only 10 of ~24 points; the isolation coefficient
dominates. The engine still found a workaround (g7-g5, Bf8-h6, then O-O -
the shield does not apply to g7, since x^16 of g7 is g8, not the king).

Next experiment, single coefficient: the pawn-structure term

Code: Select all

v -= 20 * ((x-2&M||b[x-2]-u) + (x+2&M||b[x+2]-u) - 1);
goes to

Code: Select all

v -= 10 * (...);
- back toward the original spirit (the constant was 9 before we raised
it). This makes e7-e6 cost -14 instead of -24. The known trade-off: the
phalanx bonus also halves (+20 -> +10), so paired pawn jumps become
slightly less attractive - but with 7 of 8 pawns moving, there is clearly
spare drive.

Summary so far
  • PST sign fix: confirmed working - no rewarded retreats, active
    pawn play, honest endgame calibration over 40 moves.
  • Capture correction: confirmed - diff on capture moves now reflects
    genuine tactics, not bookkeeping residue.
  • First win against Stockfish 15.1 skill 4, including a mate in 1 found
    by the engine itself (171 nodes).
  • Remaining depth limitation documented: sacrifices with a 2-ply
    follow-up are invisible at d=5-6; the evaluator is not at fault.