From 1b62d13acdc455bb3085293d7c300cb1b601b1d1 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 19 Aug 2025 15:22:06 -0400 Subject: [PATCH 1/6] Add legal move filter with test --- engine/src/bitboard.c | 192 ++++++++++++++++++++++++++++++++++++ test/chess_ffi.py | 6 ++ test/test_legal_move_gen.py | 68 +++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 test/test_legal_move_gen.py diff --git a/engine/src/bitboard.c b/engine/src/bitboard.c index cf862db..c976bf3 100644 --- a/engine/src/bitboard.c +++ b/engine/src/bitboard.c @@ -575,6 +575,198 @@ bool square_attacked(const struct Board *board, int sq, enum Color by) { return attackers_to(board, sq, by) != 0; } +int piece_at(const struct Board *board, int sq) { + uint64_t mask = 1ULL << sq; + for (int piece = 0; piece < 12; ++piece) + if (board->pieces[piece] & mask) return piece; + return -1; +} + +void rebuild_occ(struct Board *board) { + uint64_t white=0; + uint64_t black=0; + + for (int p = P; p <= K; ++p) white |= board->pieces[p]; + for (int p = p; p <= k; ++p) black |= board->pieces[p]; + + board->occ[WHITE] = white; + board->occ[BLACK] = black; + board->occ[BOTH] = white | black; +} + +uint8_t castlerights_without(uint8_t castlerights, uint8_t mask) { + // Update castle rights by removing mask. + return castlerights & ~mask; +} + +uint8_t update_castlerights_after_move( + uint8_t cr, int moved_pid, int from, int to, int cap_pid +) { + // King moved: lose both + if (moved_pid == K) cr &= ~(CASTLE_WK | CASTLE_WQ); + if (moved_pid == k) cr &= ~(CASTLE_BK | CASTLE_BQ); + + // Rook moved from its home square + if (moved_pid == R) { + if (from == H1) cr &= ~CASTLE_WK; + if (from == A1) cr &= ~CASTLE_WQ; + } else if (moved_pid == r) { + if (from == H8) cr &= ~CASTLE_BK; + if (from == A8) cr &= ~CASTLE_BQ; + } + + // Captured a rook on its home square. + // Obviously if a rook has moved and is captured, + // the prior check accounts for that. + if (cap_pid == R) { + if (to == H1) cr &= ~CASTLE_WK; + if (to == A1) cr &= ~CASTLE_WQ; + } else if (cap_pid == r) { + if (to == H8) cr &= ~CASTLE_BK; + if (to == A8) cr &= ~CASTLE_BQ; + } + return cr; +} + +void put_piece(struct Board *board, int pid, int sq) { + board->pieces[pid] |= 1ULL << sq; +} + +void del_piece(struct Board *board, int pid, int sq) { + board->pieces[pid] &= ~(1ULL << sq); +} + +// Create a copy of the board to simulate move and test state. +bool apply_move_on_copy(struct Board *in, struct Board *out, struct Move m) { + *out = *in; + const enum Color us = in->side_to_move; + const enum Color opp = us ^ 1; + + // Reset enpassant. + out->ep_square = -1; + + // Handle capture / EP capture + int captured_pid = -1; + if (m.flags & MF_ENPASSANT) { + int cap_sq = (us == WHITE) ? (m.to - 8) : (m.to + 8); + captured_pid = piece_at(out, cap_sq); + if (captured_pid < 0) return false; + del_piece(out, captured_pid, cap_sq); + } else if (m.flags & MF_CAPTURE) { + captured_pid = piece_at(out, m.to); + if (captured_pid < 0) { + return false; + }; + del_piece(out, captured_pid, m.to); + } + + // Move (with promotion) + del_piece(out, m.piece, m.from); + // move.promo is the promotion piece. + int placed = (m.flags & MF_PROMO) ? m.promo : m.piece; + put_piece(out, placed, m.to); + + // Castling: move rook + if (m.flags & MF_CASTLE) { + if (us == WHITE) { + if (m.to == WK_TO) { + del_piece(out, R, H1); + put_piece(out, R, F1); + } + else { + del_piece(out, R, A1); + put_piece(out, R, D1); + } + } else { + if (m.to == BK_TO) { + del_piece(out, r, H8); + put_piece(out, r, F8); + } + else { + del_piece(out, r, A8); + put_piece(out, r, D8); + } + } + } + + // Double pawn push -> set EP square + if (m.flags & MF_DOUBLE_PUSH) out->ep_square = (us == WHITE) ? (m.to - 8) : (m.to + 8); + + // Update castling rights + out->castling_rights = update_castlerights_after_move( + in->castling_rights, m.piece, m.from, m.to, captured_pid + ); + + // Side to move flips + out->side_to_move = opp; + + rebuild_occ(out); + return true; +} + +bool castle_path_safe(const struct Board *board, enum Color side, int king_to) { + enum Color opp = side ^ 1; + if (side == WHITE) { + if (king_to == WK_TO) { + // E1-F1-G1 + if (square_attacked(board, E1, opp)) return false; + if (square_attacked(board, F1, opp)) return false; + } else { + // E1-D1-C1 + if (square_attacked(board, E1, opp)) return false; + if (square_attacked(board, D1, opp)) return false; + } + } else { + if (king_to == BK_TO) { + // E8-F8-G8 + if (square_attacked(board, E8, opp)) return false; + if (square_attacked(board, F8, opp)) return false; + } else { + // E8-D8-C8 + if (square_attacked(board, E8, opp)) return false; + if (square_attacked(board, D8, opp)) return false; + } + } + return true; +} + +bool move_is_legal(struct Board *board, struct Move move) { + enum Color us = board->side_to_move; + enum Color opp = us ^ 1; + + // Castle-through-check rule + if (move.flags & MF_CASTLE) { + if (!castle_path_safe(board, us, move.to)) { + return false; + } + } + + struct Board after; + if (!apply_move_on_copy(board, &after, move)) { + return false; + } + + + // Our king must not be in check after the move + uint64_t king_bb = (us == WHITE) ? after.pieces[K] : after.pieces[k]; + + if (!king_bb) return false; + + int king_sq = first_set_index(king_bb); + return !square_attacked(&after, king_sq, opp); +} + +int get_legal_moves(struct Board *board, struct Move *out) { + struct Move tmp[256]; + int n = gen_pseudo_moves(board, tmp, false); + + int count = 0; + for (int i = 0; i < n; ++i) + if (move_is_legal(board, tmp[i])) + out[count++] = tmp[i]; + return count; +} + void print_board(const struct Board *board) { const char PIECE_CH[12] = { 'P','N','B','R','Q','K', diff --git a/test/chess_ffi.py b/test/chess_ffi.py index 1c10502..7ad92ca 100644 --- a/test/chess_ffi.py +++ b/test/chess_ffi.py @@ -91,10 +91,12 @@ gen_king_moves = _bind_opt("gen_king_moves", *PIECE_SIG) ATTACKED_SIG = (C.POINTER(Board), C.c_int, C.c_int) INCHECK_ARGS = (C.POINTER(Board), C.c_int) ATTACKERS_TO = (C.POINTER(Board), C.c_int, C.c_int) +GEN_LEGAL_MOVES = (C.POINTER(Board), C.POINTER(Move)) square_attacked = _bind_opt("square_attacked", ATTACKED_SIG, C.c_bool) in_check = _bind_opt("in_check", INCHECK_ARGS, C.c_bool) attackers_to = _bind_opt("attackers_to", ATTACKERS_TO, C.c_uint64) +get_legal_moves = _bind_opt("get_legal_moves", GEN_LEGAL_MOVES, C.c_int) # Attack cache tables. @@ -126,6 +128,10 @@ def gen_moves(board, captures_only=False, cap=256): return buf, n +def gen_legal_moves(board, out): + return int(get_legal_moves(C.byref(board), out)) + + def is_square_attacked(board, sq, by): return bool(square_attacked(C.byref(board), int(sq), int(by))) diff --git a/test/test_legal_move_gen.py b/test/test_legal_move_gen.py new file mode 100644 index 0000000..e85ea1b --- /dev/null +++ b/test/test_legal_move_gen.py @@ -0,0 +1,68 @@ +from test.base import ChessLibTestBase +from test.chess_ffi import Move +from test.chess_ffi import Board +from test.chess_ffi import sq_to_coord +from test.chess_ffi import gen_legal_moves + + +MAX_MOVES = 256 + + +class TestLegalMoveGen(ChessLibTestBase): + def _gen_legal(self, board): + """Support either return-count or out-parameter signatures.""" + moves = (Move * MAX_MOVES)() + n = gen_legal_moves(board, moves) + return n, moves + + + def test_start_position(self): + # 20 legal moves from the initial chess position + fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + b = Board() + self.load_fen(fen, board=b) + + n, _ = self._gen_legal(b) + self.assertEqual(n, 20, "Start position must have 20 legal moves for White") + + + def test_kiwipete_depth1_count(self): + # Kiwipete: perft(1) = 48 + # position 2: https://www.chessprogramming.org/Perft_Results + fen = "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1" + b = Board(); self.load_fen(fen, board=b) + + n, moves = self._gen_legal(b) + self.assertEqual(n, 48, "Kiwipete perft(1) should be 48") + + + def test_perft_position_3(self): + fen = "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1" + b = Board(); self.load_fen(fen, board=b) + + n, moves = self._gen_legal(b) + self.assertEqual(n, 14) + + + def test_perft_position_4(self): + fen = "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1" + b = Board(); self.load_fen(fen, board=b) + + n, moves = self._gen_legal(b) + self.assertEqual(n, 6) + + + def test_perft_position_5(self): + fen = "rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8" + b = Board(); self.load_fen(fen, board=b) + + n, moves = self._gen_legal(b) + self.assertEqual(n, 44) + + + def test_pert_position_5(self): + fen = "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10" + b = Board(); self.load_fen(fen, board=b) + + n, moves = self._gen_legal(b) + self.assertEqual(n, 46) \ No newline at end of file -- 2.34.1 From 5b4a901f8d56ea6a82605d8fe6757cb261916765 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 19 Aug 2025 15:29:56 -0400 Subject: [PATCH 2/6] Add all legal pawn promotions Initially, I only included Queen promos, but this is an issue with perft testing, since the community counts all legal promotions as moves. Debugging will be really difficult if I don't include this now. --- engine/src/bitboard.c | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/engine/src/bitboard.c b/engine/src/bitboard.c index c976bf3..d1e5c9f 100644 --- a/engine/src/bitboard.c +++ b/engine/src/bitboard.c @@ -220,6 +220,9 @@ void gen_white_pawn_push_promotions(const struct Board *board, struct Move *out, int to = pop_lsb_index(&promos); int from = to - 8; push_move(out, count, from, to, P, Q, MF_PROMO); + push_move(out, count, from, to, P, R, MF_PROMO); + push_move(out, count, from, to, P, B, MF_PROMO); + push_move(out, count, from, to, P, N, MF_PROMO); } } @@ -235,6 +238,9 @@ void gen_black_pawn_push_promotions(const struct Board *board, struct Move *out, int to = pop_lsb_index(&promos); int from = to + 8; push_move(out, count, from, to, p, q, MF_PROMO); + push_move(out, count, from, to, p, r, MF_PROMO); + push_move(out, count, from, to, p, b, MF_PROMO); + push_move(out, count, from, to, p, n, MF_PROMO); } } @@ -245,14 +251,20 @@ void gen_white_pawn_capture_promotions(const struct Board *board, struct Move *o while (left) { int to = pop_lsb_index(&left); int from = to - 7; - push_move(out, count,from, to, P, Q, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, P, Q, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, P, R, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, P, B, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, P, N, MF_CAPTURE | MF_PROMO); } // right capture: +9, mask off file H uint64_t right = ((board->pieces[P] & ~FILE_H) << 9) & opp & RANK_8; while (right) { int to = pop_lsb_index(&right); int from = to - 9; - push_move(out, count,from, to, P, Q, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, P, Q, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, P, R, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, P, B, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, P, N, MF_CAPTURE | MF_PROMO); } } @@ -263,14 +275,20 @@ void gen_black_pawn_capture_promotions(const struct Board *board, struct Move *o while (left) { int to = pop_lsb_index(&left); int from = to + 7; - push_move(out, count,from, to, p, q, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, p, q, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, p, r, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, p, b, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, p, n, MF_CAPTURE | MF_PROMO); } // “right” is -9 (mask off file A) uint64_t right = ((board->pieces[p] & ~FILE_A) >> 9) & opp & RANK_1; while (right) { int to = pop_lsb_index(&right); int from = to + 9; - push_move(out, count,from, to, p, q, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, p, q, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, p, r, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, p, b, MF_CAPTURE | MF_PROMO); + push_move(out, count, from, to, p, n, MF_CAPTURE | MF_PROMO); } } -- 2.34.1 From 1ba87368b519b903cc41f4cf44fea0e46a2b5eb3 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 19 Aug 2025 15:32:56 -0400 Subject: [PATCH 3/6] Add legal move tests and fix pawn promo tests --- test/test_legal_move_gen.py | 2 +- test/test_movegen.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/test/test_legal_move_gen.py b/test/test_legal_move_gen.py index e85ea1b..b21c561 100644 --- a/test/test_legal_move_gen.py +++ b/test/test_legal_move_gen.py @@ -60,7 +60,7 @@ class TestLegalMoveGen(ChessLibTestBase): self.assertEqual(n, 44) - def test_pert_position_5(self): + def test_pert_position_6(self): fen = "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10" b = Board(); self.load_fen(fen, board=b) diff --git a/test/test_movegen.py b/test/test_movegen.py index c809061..0850531 100644 --- a/test/test_movegen.py +++ b/test/test_movegen.py @@ -253,7 +253,7 @@ class TestPseudoMoveGeneration(ChessLibTestBase): def test_quiet_pawn_promotions_white(self): cases = [ - ("8/PPPPPPPP/8/8/8/8/8/8 w - - 0 1", 8, "all open on 7th rank"), + ("8/PPPPPPPP/8/8/8/8/8/8 w - - 0 1", 32, "all open on 7th rank"), ("8/8/7P/8/8/8/8/8 w - - 0 1", 0, "no promotions"), ("7n/7P/8/8/8/8/8/8 w - - 0 1", 0, "blocked by enemy"), ("7N/7P/8/8/8/8/8/8 w - - 0 1", 0, "blocked by friendly"), @@ -263,8 +263,8 @@ class TestPseudoMoveGeneration(ChessLibTestBase): def test_capture_pawn_promotions_white(self): cases = [ - ("6n1/7P/8/8/8/8/8/8 w - - 0 1", 1, "one capture"), - ("5n1n/6P1/8/8/8/8/8/8 w - - 0 1", 2, "two captures"), + ("6n1/7P/8/8/8/8/8/8 w - - 0 1", 4, "one capture"), + ("5n1n/6P1/8/8/8/8/8/8 w - - 0 1", 8, "two captures"), ("8/7P/8/8/8/8/8/8 w - - 0 1", 0, "no capture"), ] self.run_subtests(cases, gen_white_pawn_capture_promotions) -- 2.34.1 From f0a2087bdd5b1029f17eae9a2582320dcb9937c6 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 19 Aug 2025 15:48:40 -0400 Subject: [PATCH 4/6] Add some additional sanity checks for move gen --- test/test_legal_move_gen.py | 46 +++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/test/test_legal_move_gen.py b/test/test_legal_move_gen.py index b21c561..1cf1cbf 100644 --- a/test/test_legal_move_gen.py +++ b/test/test_legal_move_gen.py @@ -1,9 +1,11 @@ from test.base import ChessLibTestBase from test.chess_ffi import Move from test.chess_ffi import Board -from test.chess_ffi import sq_to_coord +from test.chess_ffi import BLACK +from test.chess_ffi import WHITE +from test.chess_ffi import is_in_check from test.chess_ffi import gen_legal_moves - +from test.chess_ffi import sq MAX_MOVES = 256 @@ -60,9 +62,45 @@ class TestLegalMoveGen(ChessLibTestBase): self.assertEqual(n, 44) - def test_pert_position_6(self): + def test_perft_position_6(self): fen = "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10" b = Board(); self.load_fen(fen, board=b) n, moves = self._gen_legal(b) - self.assertEqual(n, 46) \ No newline at end of file + self.assertEqual(n, 46) + + + def test_stalemate_black_to_move(self): + # Classic stalemate: Black to move, no legal moves, not in check + fen = "7k/5Q2/6K1/8/8/8/8/8 b - - 0 1" + b = Board() + self.load_fen(fen, board=b) + + moves = (Move * MAX_MOVES)() + n = gen_legal_moves(b, moves) + + self.assertEqual(n, 0, "Stalemate should have 0 legal moves") + self.assertFalse(is_in_check(b, BLACK), "Side to move must not be in check in stalemate") + + + def test_simple_checkmate(self): + # Black to move; mated (Qg7# with Kg6 support) + fen = "7k/6Q1/6K1/8/8/8/8/8 b - - 0 1" + b = Board(); self.load_fen(fen, board=b) + moves = (Move * MAX_MOVES)() + n = gen_legal_moves(b, moves) + + self.assertEqual(n, 0, "Checkmated side should have 0 legal moves") + self.assertTrue(is_in_check(b, BLACK), "Mated side must be in check") + + + def test_pinned_piece_cannot_move(self): + # White knight e2 pinned by rook e8 vs king e1 + fen = "4r3/8/8/8/8/8/4N3/4K3 w - - 0 1" + b = Board(); self.load_fen(fen, board=b) + moves = (Move * MAX_MOVES)() + n = gen_legal_moves(b, moves) + + for move in moves: + # Pinned knight cannot move from it's square. + self.assertFalse(getattr(move, "from") == sq("e2")) \ No newline at end of file -- 2.34.1 From f6c796730ee16d40231656c4af56f81cc39bc302 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 19 Aug 2025 15:55:40 -0400 Subject: [PATCH 5/6] Add perft testing links --- test/test_legal_move_gen.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/test_legal_move_gen.py b/test/test_legal_move_gen.py index 1cf1cbf..7dd6f5a 100644 --- a/test/test_legal_move_gen.py +++ b/test/test_legal_move_gen.py @@ -20,6 +20,7 @@ class TestLegalMoveGen(ChessLibTestBase): def test_start_position(self): # 20 legal moves from the initial chess position + # https://www.chessprogramming.org/Perft_Results#Initial_Position fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" b = Board() self.load_fen(fen, board=b) @@ -30,7 +31,7 @@ class TestLegalMoveGen(ChessLibTestBase): def test_kiwipete_depth1_count(self): # Kiwipete: perft(1) = 48 - # position 2: https://www.chessprogramming.org/Perft_Results + # https://www.chessprogramming.org/Perft_Results#Position_2 fen = "r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1" b = Board(); self.load_fen(fen, board=b) @@ -39,6 +40,7 @@ class TestLegalMoveGen(ChessLibTestBase): def test_perft_position_3(self): + # https://www.chessprogramming.org/Perft_Results#Position_3 fen = "8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1" b = Board(); self.load_fen(fen, board=b) @@ -47,6 +49,7 @@ class TestLegalMoveGen(ChessLibTestBase): def test_perft_position_4(self): + # https://www.chessprogramming.org/Perft_Results#Position_4 fen = "r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1" b = Board(); self.load_fen(fen, board=b) @@ -55,6 +58,7 @@ class TestLegalMoveGen(ChessLibTestBase): def test_perft_position_5(self): + # https://www.chessprogramming.org/Perft_Results#Position_5 fen = "rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8" b = Board(); self.load_fen(fen, board=b) @@ -63,6 +67,7 @@ class TestLegalMoveGen(ChessLibTestBase): def test_perft_position_6(self): + # https://www.chessprogramming.org/Perft_Results#Position_6 fen = "r4rk1/1pp1qppp/p1np1n2/2b1p1B1/2B1P1b1/P1NP1N2/1PP1QPPP/R4RK1 w - - 0 10" b = Board(); self.load_fen(fen, board=b) -- 2.34.1 From 134eafa135603f0f6c84163c1d2a2b4847673e35 Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 19 Aug 2025 15:56:06 -0400 Subject: [PATCH 6/6] Remove variable import --- test/test_legal_move_gen.py | 1 - 1 file changed, 1 deletion(-) diff --git a/test/test_legal_move_gen.py b/test/test_legal_move_gen.py index 7dd6f5a..88073bb 100644 --- a/test/test_legal_move_gen.py +++ b/test/test_legal_move_gen.py @@ -2,7 +2,6 @@ from test.base import ChessLibTestBase from test.chess_ffi import Move from test.chess_ffi import Board from test.chess_ffi import BLACK -from test.chess_ffi import WHITE from test.chess_ffi import is_in_check from test.chess_ffi import gen_legal_moves from test.chess_ffi import sq -- 2.34.1