From bf76537a501e407b63c217e31f971eed3d731919 Mon Sep 17 00:00:00 2001 From: Josh Date: Sun, 10 Aug 2025 15:38:11 -0400 Subject: [PATCH] Add pawn, knight, and king attack move caches --- README.md | 2 +- engine/include/bitboard.h | 50 ++++++++++++++++++++ engine/src/bitboard.c | 61 ++++++++++++++++++++++++ makefile | 46 ++++++++++++++++++ test/__init__.py | 0 test/base.py | 79 +++++++++++++++++++++++++++++++ test/test_piece_attack_cache.py | 83 +++++++++++++++++++++++++++++++++ 7 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 engine/include/bitboard.h create mode 100644 engine/src/bitboard.c create mode 100644 makefile create mode 100644 test/__init__.py create mode 100644 test/base.py create mode 100644 test/test_piece_attack_cache.py diff --git a/README.md b/README.md index 2e16f02..85be545 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -The goal of this project is to build a clean, fast chess engine in C that starts from a FEN string and produces fully legal moves, verified via PERFT for correctness and speed. It uses a bitboard architecture with precomputed attack caches (pawns, knights, king; sliders later) and a small, well-structured codebase that’s easy to extend. The emphasis is on correctness-first (FEN → board → legal movegen → perft), performance through cache-friendly data and simple hot paths, and portability with minimal dependencies. A lightweight Python test suite (via ctypes) checks known attack patterns and guardrails, and the design leaves room to add sliders, make/unmake, search (alpha–beta), and a UCI interface as follow-on milestones. \ No newline at end of file +The goal of this project is to build a clean, fast chess engine in C that starts from a FEN string and produces fully legal moves, verified via PERFT for correctness and speed. It uses a bitboard architecture with precomputed attack caches (pawns, knights, king; sliders later) and a small, well-structured codebase that’s easy to extend. The emphasis is on correctness-first approach (FEN → board → legal movegen → perft). Later, we will incorporate different algorithms and methods of evaluating chess positions to enable an AI player. \ No newline at end of file diff --git a/engine/include/bitboard.h b/engine/include/bitboard.h new file mode 100644 index 0000000..381a371 --- /dev/null +++ b/engine/include/bitboard.h @@ -0,0 +1,50 @@ +#pragma once +#include +#include + +#define FILE_A 0x0101010101010101ULL +#define FILE_B 0x0202020202020202ULL +#define FILE_C 0x0404040404040404ULL +#define FILE_D 0x0808080808080808ULL +#define FILE_E 0x1010101010101010ULL +#define FILE_F 0x2020202020202020ULL +#define FILE_G 0x4040404040404040ULL +#define FILE_H 0x8080808080808080ULL + +#define RANK_1 0x00000000000000FFULL +#define RANK_2 0x000000000000FF00ULL +#define RANK_3 0x0000000000FF0000ULL +#define RANK_4 0x00000000FF000000ULL +#define RANK_5 0x000000FF00000000ULL +#define RANK_6 0x0000FF0000000000ULL +#define RANK_7 0x00FF000000000000ULL +#define RANK_8 0xFF00000000000000ULL + +enum Color { WHITE = 0, BLACK = 1 }; +enum Piece { PAWN, KNIGHT, BISHOP, ROOK, QUEEN, KING, PIECE_N = 6 }; + +enum Castling { + CASTLE_WK = 1 << 0, + CASTLE_WQ = 1 << 1, + CASTLE_BK = 1 << 2, + CASTLE_BQ = 1 << 3 +}; + +struct Board { + uint64_t bb[2][PIECE_N]; // Each set of pieces get a bitboard for each player. + uint64_t occ[2]; // Color occupancy bitboards. + uint64_t occ_both; // occ[WHITE] | occ[BLACK] + + uint64_t king_square[2]; + + uint8_t castling; + uint64_t ep_square; + enum Color side_to_move; + + uint16_t halfmove_clock; + uint16_t fullmove_number; +}; + +void create_knight_attack_cache(void); +void create_pawn_attack_cache(void); +void create_king_attack_cache(void); \ No newline at end of file diff --git a/engine/src/bitboard.c b/engine/src/bitboard.c new file mode 100644 index 0000000..8d97cb0 --- /dev/null +++ b/engine/src/bitboard.c @@ -0,0 +1,61 @@ +#include "bitboard.h" + +uint64_t PAWN_ATTACKS[2][64]; +uint64_t KNIGHT_ATTACKS[64]; +uint64_t KING_ATTACKS[64]; + +void create_knight_attack_cache(void) { + for (int sq = 0; sq < 64; sq++) { + uint64_t b = 1ULL << sq; + uint64_t mask = 0ULL; + + mask |= (b & ~FILE_A) << 15; + mask |= (b & ~FILE_H) << 17; + mask |= (b & ~(FILE_A | FILE_B)) << 6; + mask |= (b & ~(FILE_G | FILE_H)) << 10; + mask |= (b & ~FILE_A) >> 17; + mask |= (b & ~FILE_H) >> 15; + mask |= (b & ~(FILE_A | FILE_B)) >> 10; + mask |= (b & ~(FILE_G | FILE_H)) >> 6; + + KNIGHT_ATTACKS[sq] = mask; + } +} + +void create_pawn_attack_cache(void) { + for (int sq = 0; sq < 64; sq++) { + uint64_t b = 1ULL << sq; + + // White: NE (+9), NW (+7), but never from rank 8 + uint64_t w = ((b & ~FILE_H & ~RANK_8) << 9) | ((b & ~FILE_A & ~RANK_8) << 7); + + // Black: SE (-7), SW (-9), but never from rank 1 + uint64_t bl = ((b & ~FILE_H & ~RANK_1) >> 7) | ((b & ~FILE_A & ~RANK_1) >> 9); + + PAWN_ATTACKS[WHITE][sq] = w; + PAWN_ATTACKS[BLACK][sq] = bl; + } +} + +void create_king_attack_cache(void) { + for (int sq = 0; sq < 64; sq++) { + uint64_t b = 1ULL << sq; + uint64_t mask = 0ULL; + + // North / South + mask |= (b & ~RANK_8) << 8; + mask |= (b & ~RANK_1) >> 8; + + // East / West + mask |= (b & ~FILE_H) << 1; + mask |= (b & ~FILE_A) >> 1; + + // Diagonals + mask |= (b & ~FILE_A & ~RANK_8) << 7; + mask |= (b & ~FILE_H & ~RANK_8) << 9; + mask |= (b & ~FILE_A & ~RANK_1) >> 9; + mask |= (b & ~FILE_H & ~RANK_1) >> 7; + + KING_ATTACKS[sq] = mask; + } +} \ No newline at end of file diff --git a/makefile b/makefile new file mode 100644 index 0000000..d3562c7 --- /dev/null +++ b/makefile @@ -0,0 +1,46 @@ +# Makefile at project root + +CC := gcc +CFLAGS := -O3 -fPIC -Wall -Wextra +LDFLAGS := -shared + +SRCDIR := engine/src +INCDIR := engine/include +BUILDDIR := build +LIBNAME := libchess + +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) + SOEXT := dylib +else + SOEXT := so +endif + +SRC := $(wildcard $(SRCDIR)/*.c) +OBJ := $(patsubst $(SRCDIR)/%.c,$(BUILDDIR)/%.o,$(SRC)) +LIB := $(BUILDDIR)/$(LIBNAME).$(SOEXT) + +.PHONY: all clean clean-pycache test + +all: $(LIB) + +$(BUILDDIR): + @mkdir -p $(BUILDDIR) + +# compile each .c into build/*.o +$(BUILDDIR)/%.o: $(SRCDIR)/%.c | $(BUILDDIR) + $(CC) $(CFLAGS) -I$(INCDIR) -c $< -o $@ + +# link shared library +$(LIB): $(OBJ) + $(CC) $(LDFLAGS) -o $@ $^ + +clean: + @echo "Cleaning Python caches..." + @find . -type d -name "__pycache__" -exec rm -rf {} + + @find . -type d -name ".pytest_cache" -exec rm -rf {} + + @find . -type f \( -name "*.pyc" -o -name "*.pyo" \) -delete + +# run Python unit tests +test: all + python3 -m unittest -v && $(MAKE) clean diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/base.py b/test/base.py new file mode 100644 index 0000000..5162eca --- /dev/null +++ b/test/base.py @@ -0,0 +1,79 @@ +import ctypes +import platform +import unittest +from pathlib import Path + + +WHITE, BLACK = 0, 1 +FILES = {c:i for i,c in enumerate("abcdefgh")} + + +def sq(name: str) -> int: + f = FILES[name[0].lower()] + r = int(name[1]) - 1 + return f + 8 * r + + +def bb_from(*algebraic): + m = 0 + for s in algebraic: + m |= (1 << sq(s)) + return m + + +def popcount(x: int) -> int: + return x.bit_count() + + +def draw_bb(mask: int, origin: int | None = None) -> str: + print("\n") + lines = [] + for r in range(7, -1, -1): + row = [] + for f in range(8): + sqi = r * 8 + f + bit = (mask >> sqi) & 1 + if origin is not None and sqi == origin: + ch = 'O' + elif bit: + ch = 'x' + else: + ch = '.' + row.append(ch) + lines.append(f"{r+1} " + " ".join(row)) + lines.append(" " + " ".join(FILES)) + lines = "\n".join(lines) + print(lines, "\n") + + +class ChessLibTestBase(unittest.TestCase): + @classmethod + def setUpClass(cls): + root = Path(__file__).resolve().parents[1] + libname = "libchess.so" + libpath = root / "build" / libname + cls.lib = ctypes.CDLL(str(libpath)) + + attack_cache_functions = [ + "create_knight_attack_cache", + "create_pawn_attack_cache", + "create_king_attack_cache", + ] + + # init functions + for fn in attack_cache_functions: + getattr(cls.lib, fn).argtypes = [] + getattr(cls.lib, fn).restype = None + + cls.lib.create_knight_attack_cache() + cls.lib.create_pawn_attack_cache() + cls.lib.create_king_attack_cache() + + KnightArr = ctypes.c_uint64 * 64 + KingArr = ctypes.c_uint64 * 64 + PawnRow = ctypes.c_uint64 * 64 + PawnArr = PawnRow * 2 + + cls.KNIGHT_ATTACKS = KnightArr.in_dll(cls.lib, "KNIGHT_ATTACKS") + cls.KING_ATTACKS = KingArr.in_dll(cls.lib, "KING_ATTACKS") + cls.PAWN_ATTACKS = PawnArr.in_dll(cls.lib, "PAWN_ATTACKS") \ No newline at end of file diff --git a/test/test_piece_attack_cache.py b/test/test_piece_attack_cache.py new file mode 100644 index 0000000..0611506 --- /dev/null +++ b/test/test_piece_attack_cache.py @@ -0,0 +1,83 @@ +from test.base import ChessLibTestBase +from test.base import bb_from +from test.base import draw_bb +from test.base import sq +from test.base import BLACK, WHITE + + +class KnightFixedCases(ChessLibTestBase): + def test_knight_a1(self): + expected = bb_from("b3", "c2") + self.assertEqual(int(self.KNIGHT_ATTACKS[sq("a1")]), expected) + + + def test_knight_d4(self): + expected = bb_from("b5","b3","c6","e6","f5","f3","c2","e2") + actual = int(self.KNIGHT_ATTACKS[sq("d4")]) + self.assertEqual(actual, expected) + + + def test_knight_h8(self): + expected = bb_from("f7","g6") + actual = int(self.KNIGHT_ATTACKS[sq("h8")]) + self.assertEqual(actual, expected) + + +class PawnFixedCases(ChessLibTestBase): + def test_white_pawn_a2(self): + expected = bb_from("b3") + actual = int(self.PAWN_ATTACKS[WHITE][sq("a2")]) + self.assertEqual(actual, expected) + + + def test_white_pawn_b2(self): + expected = bb_from("a3", "c3") + actual = int(self.PAWN_ATTACKS[WHITE][sq("b2")]) + self.assertEqual(actual, expected) + + + def test_black_pawn_a2(self): + expected = bb_from("b6") + actual = int(self.PAWN_ATTACKS[BLACK][sq("a7")]) + self.assertEqual(actual, expected) + + + def test_black_pawn_b2(self): + expected = bb_from("a6", "c6") + actual = int(self.PAWN_ATTACKS[BLACK][sq("b7")]) + self.assertEqual(actual, expected) + + +class KingFixedCases(ChessLibTestBase): + def test_king_a1(self): + expected = bb_from("a2", "b1", "b2") + actual = int(self.KING_ATTACKS[sq("a1")]) + self.assertEqual(actual, expected) + + + def test_king_a8(self): + expected = bb_from("a7", "b8", "b7") + actual = int(self.KING_ATTACKS[sq("a8")]) + self.assertEqual(actual, expected) + + + def test_king_h1(self): + expected = bb_from("h2", "g1", "g2") + actual = int(self.KING_ATTACKS[sq("h1")]) + self.assertEqual(actual, expected) + + + def test_king_h8(self): + expected = bb_from("h7", "g7", "g8") + actual = int(self.KING_ATTACKS[sq("h8")]) + self.assertEqual(actual, expected) + + + def test_king_d4(self): + expected = bb_from( + "c3", "d3", "e3", + "c4", "e4", + "c5", "d5", "e5", + ) + actual = int(self.KING_ATTACKS[sq("d4")]) + self.assertEqual(actual, expected) \ No newline at end of file