diff --git a/Chess/nim.cfg b/Chess/nim.cfg index b95609e..f4ac049 100644 --- a/Chess/nim.cfg +++ b/Chess/nim.cfg @@ -2,4 +2,8 @@ -o:"bin/nimfish" -d:danger --passL:"-flto" ---passC:"-Ofast -flto -march=native -mtune=native" +--passC:"-flto -march=native -mtune=native" +--mm:boehm +--stackTrace +--lineTrace +--debugger:native \ No newline at end of file diff --git a/Chess/nimfish/nimfishpkg/bitboards.nim b/Chess/nimfish/nimfishpkg/bitboards.nim index c652f68..0be7202 100644 --- a/Chess/nimfish/nimfishpkg/bitboards.nim +++ b/Chess/nimfish/nimfishpkg/bitboards.nim @@ -302,10 +302,28 @@ func computeKnightBitboards: array[64, Bitboard] {.compileTime.} = result[i] = movements +func computePawnAttacks(color: PieceColor): array[64, Bitboard] {.compileTime.} = + ## Precomputes all the attack bitboards for pawns + ## of the given color + for i in 0'u64..63: + let + pawn = i.toBitboard() + square = Square(i) + file = fileFromSquare(square) + var movements = Bitboard(0) + if file in 1..7: + movements = movements or pawn.forwardLeftRelativeTo(color) + if file in 0..6: + movements = movements or pawn.forwardRightRelativeTo(color) + movements = movements and not pawn + result[i] = movements + const KING_BITBOARDS = computeKingBitboards() KNIGHT_BITBOARDS = computeKnightBitboards() + PAWN_ATTACKS = [computePawnAttacks(White), computePawnAttacks(Black)] func getKingAttacks*(square: Square): Bitboard {.inline.} = KING_BITBOARDS[square.int] func getKnightAttacks*(square: Square): Bitboard {.inline.} = KNIGHT_BITBOARDS[square.int] +func getPawnAttacks*(color: PieceColor, square: Square): Bitboard {.inline.} = PAWN_ATTACKS[color.int][square.int] diff --git a/Chess/nimfish/nimfishpkg/board.nim b/Chess/nimfish/nimfishpkg/board.nim index 4035ba3..2cad726 100644 --- a/Chess/nimfish/nimfishpkg/board.nim +++ b/Chess/nimfish/nimfishpkg/board.nim @@ -161,7 +161,7 @@ proc newChessboardFromFEN*(fen: string): Chessboard = # Backtrack so the space is seen by the # next iteration of the loop dec(index) - result.position.halfMoveClock = parseInt(s).int8 + result.position.halfMoveClock = parseInt(s).uint16 of 5: # Fullmove number var s = "" @@ -603,7 +603,10 @@ proc drawByRepetition*(self: Chessboard): bool = proc hash*(self: Chessboard) = ## Computes the zobrist hash of the current - ## position + ## position. This only needs to be called when + ## a position is loaded the first time, as all + ## subsequent hashes are updated incrementally + ## at every call to doMove() self.position.zobristKey = ZobristKey(0) if self.position.sideToMove == Black: @@ -622,4 +625,4 @@ proc hash*(self: Chessboard) = self.position.zobristKey = self.position.zobristKey xor getQueenSideCastlingKey(Black) if self.position.enPassantSquare != nullSquare(): - self.position.zobristKey = self.position.zobristKey xor getEnPassantKey(fileFromSquare(self.position.enPassantSquare)) \ No newline at end of file + self.position.zobristKey = self.position.zobristKey xor getEnPassantKey(fileFromSquare(self.position.enPassantSquare)) diff --git a/Chess/nimfish/nimfishpkg/movegen.nim b/Chess/nimfish/nimfishpkg/movegen.nim index 3484f35..b50662d 100644 --- a/Chess/nimfish/nimfishpkg/movegen.nim +++ b/Chess/nimfish/nimfishpkg/movegen.nim @@ -349,12 +349,16 @@ proc doMove*(self: Chessboard, move: Move) = fullMoveCount = self.position.fullMoveCount enPassantTarget = nullSquare() + if self.position.enPassantSquare != nullSquare(): + # Unset the previous en passant square in the zobrist key + self.position.zobristKey = self.position.zobristKey xor getEnPassantKey(fileFromSquare(self.position.enPassantSquare)) # Needed to detect draw by the 50 move rule if piece.kind == Pawn or move.isCapture() or move.isEnPassant(): # Number of half-moves since the last reversible half-move halfMoveClock = 0 else: inc(halfMoveClock) + if piece.color == Black: inc(fullMoveCount) @@ -368,13 +372,18 @@ proc doMove*(self: Chessboard, move: Move) = sideToMove: self.position.sideToMove.opposite(), enPassantSquare: enPassantTarget, pieces: self.position.pieces, - castlingAvailability: self.position.castlingAvailability + castlingAvailability: self.position.castlingAvailability, + zobristKey: self.position.zobristKey ) + if self.position.enPassantSquare != nullSquare(): + self.position.zobristKey = self.position.zobristKey xor getEnPassantKey(fileFromSquare(move.targetSquare)) # Update position metadata if move.isEnPassant(): # Make the en passant pawn disappear - self.removePiece(move.targetSquare.toBitboard().backwardRelativeTo(piece.color).toSquare()) + let epPawnSquare = move.targetSquare.toBitboard().backwardRelativeTo(piece.color).toSquare() + self.position.zobristKey = self.position.zobristKey xor self.getPiece(epPawnSquare).getKey(epPawnSquare) + self.removePiece(epPawnSquare) if move.isCastling() or piece.kind == King: # If the king has moved, all castling rights for the side to @@ -382,12 +391,24 @@ proc doMove*(self: Chessboard, move: Move) = self.position.castlingAvailability[piece.color.int] = (false, false) if move.isCastling(): # Move the rook where it belongs + var + source: Square + rook: Piece + target: Square + if move.targetSquare == piece.kingSideCastling(): - let rook = self.getPiece(piece.color.kingSideRook()) - self.movePiece(piece.color.kingSideRook(), rook.kingSideCastling()) - if move.targetSquare == piece.queenSideCastling(): - let rook = self.getPiece(piece.color.queenSideRook()) - self.movePiece(piece.color.queenSideRook(), rook.queenSideCastling()) + source = piece.color.kingSideRook() + rook = self.getPiece(source) + target = rook.kingSideCastling() + + elif move.targetSquare == piece.queenSideCastling(): + source = piece.color.queenSideRook() + rook = self.getPiece(source) + target = rook.queenSideCastling() + + self.movePiece(source, target) + self.position.zobristKey = self.position.zobristKey xor piece.getKey(source) + self.position.zobristKey = self.position.zobristKey xor piece.getKey(target) if piece.kind == Rook: # If a rook on either side moves, castling rights are permanently revoked @@ -400,6 +421,7 @@ proc doMove*(self: Chessboard, move: Move) = if move.isCapture(): # Get rid of captured pieces let captured = self.getPiece(move.targetSquare) + self.position.zobristKey = self.position.zobristKey xor captured.getKey(move.targetSquare) self.removePiece(move.targetSquare) # If a rook has been captured, castling on that side is prohibited if captured.kind == Rook: @@ -409,27 +431,36 @@ proc doMove*(self: Chessboard, move: Move) = self.position.castlingAvailability[captured.color.int].queen = false # Move the piece to its target square - self.movePiece(move) + self.movePiece(move) + self.position.zobristKey = self.position.zobristKey xor piece.getKey(move.startSquare) + self.position.zobristKey = self.position.zobristKey xor piece.getKey(move.targetSquare) if move.isPromotion(): # Move is a pawn promotion: get rid of the pawn # and spawn a new piece self.removePiece(move.targetSquare) + self.position.zobristKey = self.position.zobristKey xor piece.getKey(move.targetSquare) + var spawnedPiece: Piece case move.getPromotionType(): of PromoteToBishop: - self.spawnPiece(move.targetSquare, Piece(kind: Bishop, color: piece.color)) + spawnedPiece = Piece(kind: Bishop, color: piece.color) of PromoteToKnight: - self.spawnPiece(move.targetSquare, Piece(kind: Knight, color: piece.color)) + spawnedPiece = Piece(kind: Knight, color: piece.color) of PromoteToRook: - self.spawnPiece(move.targetSquare, Piece(kind: Rook, color: piece.color)) + spawnedPiece = Piece(kind: Rook, color: piece.color) of PromoteToQueen: - self.spawnPiece(move.targetSquare, Piece(kind: Queen, color: piece.color)) + spawnedPiece = Piece(kind: Queen, color: piece.color) else: # Unreachable discard + self.position.zobristKey = self.position.zobristKey xor spawnedPiece.getKey(move.targetSquare) + self.spawnPiece(move.targetSquare, spawnedPiece) # Updates checks and pins for the (new) side to move self.updateChecksAndPins() - # Update zobrist key - self.hash() + # Last updates to zobrist key + if self.position.castlingAvailability[piece.color.int].king: + self.position.zobristKey = self.position.zobristKey xor getKingSideCastlingKey(piece.color) + if self.position.castlingAvailability[piece.color.int].queen: + self.position.zobristKey = self.position.zobristKey xor getQueenSideCastlingKey(piece.color) discard self.drawByRepetition() @@ -450,13 +481,11 @@ proc makeMove*(self: Chessboard, move: Move): Move {.discardable.} = proc unmakeMove*(self: Chessboard) = - ## Reverts to the previous board position, - ## if one exists + ## Reverts to the previous board position + if self.positions.len() == 0: + return self.position = self.positions.pop() self.update() - self.hash() - - ## Testing stuff @@ -477,7 +506,7 @@ proc testPieceBitboard(bitboard: Bitboard, squares: seq[Square]) = if i != squares.len(): doAssert false, &"bitboard.len() ({i}) != squares.len() ({squares.len()})" - +## Tests const testFens = staticRead("../../tests/all.txt").splitLines() diff --git a/Chess/nimfish/nimfishpkg/moves.nim b/Chess/nimfish/nimfishpkg/moves.nim index a6ac363..1473d11 100644 --- a/Chess/nimfish/nimfishpkg/moves.nim +++ b/Chess/nimfish/nimfishpkg/moves.nim @@ -100,6 +100,7 @@ func createMove*(startSquare, targetSquare: Square, flags: varargs[MoveFlag]): M for flag in flags: result.flags = result.flags or flag.uint16 + proc createMove*(startSquare, targetSquare: string, flags: varargs[MoveFlag]): Move = result = createMove(startSquare.toSquare(), targetSquare.toSquare(), flags) @@ -113,6 +114,7 @@ func createMove*(startSquare: Square, targetSquare: SomeInteger, flags: varargs[ func nullMove*: Move {.inline.} = createMove(nullSquare(), nullSquare()) + func isPromotion*(move: Move): bool {.inline.} = ## Returns whether the given move is a ## pawn promotion @@ -168,6 +170,8 @@ func getFlags*(move: Move): seq[MoveFlag] = func `$`*(self: Move): string = ## Returns a string representation ## for the move + if self == nullMove(): + return "null" result &= &"{self.startSquare}{self.targetSquare}" let flags = self.getFlags() if len(flags) > 0: @@ -181,6 +185,8 @@ func `$`*(self: Move): string = func toAlgebraic*(self: Move): string = + if self == nullMove(): + return "null" result &= &"{self.startSquare}{self.targetSquare}" if self.isPromotion(): case self.getPromotionType(): diff --git a/Chess/nimfish/nimfishpkg/position.nim b/Chess/nimfish/nimfishpkg/position.nim index 2fbf9da..b224f52 100644 --- a/Chess/nimfish/nimfishpkg/position.nim +++ b/Chess/nimfish/nimfishpkg/position.nim @@ -18,8 +18,7 @@ import zobrist type - - Position* = ref object + Position* = object ## A chess position # Castling availability. This just keeps track @@ -34,13 +33,12 @@ type # Number of half moves since # last piece capture or pawn movement. # Used for the 50-move rule - halfMoveClock*: int8 + halfMoveClock*: uint16 # Full move counter. Increments # every 2 ply (half-moves) fullMoveCount*: uint16 # En passant target square (see https://en.wikipedia.org/wiki/En_passant) enPassantSquare*: Square - # The side to move sideToMove*: PieceColor # Positional bitboards for all pieces diff --git a/Chess/nimfish/nimfishpkg/search.nim b/Chess/nimfish/nimfishpkg/search.nim index 8fbd723..ec35871 100644 --- a/Chess/nimfish/nimfishpkg/search.nim +++ b/Chess/nimfish/nimfishpkg/search.nim @@ -30,7 +30,8 @@ type SearchManager* = ref object ## A simple state storage ## for our search - stopFlag*: Atomic[bool] # Can be used to cancel the search from another thread + searching: Atomic[bool] + stopFlag: Atomic[bool] # Can be used to cancel the search from another thread board: Chessboard bestMoveRoot: Move bestRootScore: Score @@ -51,6 +52,19 @@ proc newSearchManager*(board: Chessboard, transpositions: TTable): SearchManager result.transpositionTable = transpositions +proc isSearching*(self: SearchManager): bool = + ## Returns whether a search for the best + ## move is in progress + result = self.searching.load() + + +proc stop*(self: SearchManager) = + ## Stops the search if it is + ## running + if self.isSearching(): + self.stopFlag.store(true) + + proc getEstimatedMoveScore(self: SearchManager, move: Move): Score = ## Returns an estimated static score for the move result = Score(0) @@ -84,7 +98,7 @@ proc getEstimatedMoveScore(self: SearchManager, move: Move): Score = if self.board.getPawnAttacks(move.targetSquare, nonSideToMove) != 0: # Moving on a square attacked by an enemy pawn is _usually_ a very bad # idea. Assume the piece is lost and give a malus based on the fact that - # losing a piece this way is a very poor move + # losing a piece this way is dumb result -= self.board.getPieceScore(move.startSquare) * 2 @@ -159,8 +173,9 @@ proc qsearch(self: SearchManager, ply: uint8, alpha, beta: Score): Score = ## or sacking pieces for apparently no reason: the reason is that it ## did not look at the opponent's responses, because it stopped earlier. ## That's the horizon). To address this, we look at all possible captures - ## in the current position and make sure that a position is not evaluated as - ## bad if only bad capture moves exist, if good non-capture moves do + ## in the current position and make sure that a position is evaluated as + ## bad if only bad capture moves are possible, even if good non-capture moves + ## exist if self.shouldStop(): return if ply == 127: @@ -202,7 +217,10 @@ proc qsearch(self: SearchManager, ply: uint8, alpha, beta: Score): Score = proc search(self: SearchManager, depth, ply: int, alpha, beta: Score): Score {.discardable.} = ## Simple negamax search with alpha-beta pruning - if self.shouldStop(): + if depth > 1 and self.shouldStop(): + # We do not let ourselves get cancelled at depth + # one because then we wouldn't have a move to return. + # In practice this should not be a problem return when defined(useTT): let query = self.transpositionTable.get(self.board.position.zobristKey, depth.uint8) @@ -219,8 +237,10 @@ proc search(self: SearchManager, depth, ply: int, alpha, beta: Score): Score {.d if depth == 0: # Quiescent search gain: 264.8 +/- 71.6 return self.qsearch(0, alpha, beta) - var moves = newMoveList() - var depth = depth + var + moves = newMoveList() + depth = depth + bestMove = nullMove() self.board.generateMoves(moves) self.reorderMoves(moves) if moves.len() == 0: @@ -265,7 +285,7 @@ proc search(self: SearchManager, depth, ply: int, alpha, beta: Score): Score {.d # When a search is cancelled or times out, we need # to make sure the entire call stack unwinds back # to the root move. This is why the check is duplicated - if self.shouldStop(): + if depth > 1 and self.shouldStop(): return bestScore = max(score, bestScore) let nodeType = if score >= beta: LowerBound elif score <= alpha: UpperBound else: Exact @@ -277,6 +297,7 @@ proc search(self: SearchManager, depth, ply: int, alpha, beta: Score): Score {.d break if score > alpha: alpha = score + bestMove = move if ply == 0: self.bestMoveRoot = move self.bestRootScore = bestScore @@ -291,7 +312,11 @@ proc findBestMove*(self: SearchManager, maxSearchTime, maxDepth: int, maxNodes: ## is picked). If maxNodes is supplied and is nonzero, ## search will stop once it has analyzed the given number ## of nodes. If searchMoves is provided and is not empty, - ## search will be restricted to the moves in the list + ## search will be restricted to the moves in the list. Note + ## that regardless of any time limitations, the search will + ## not be cancelled until it has at least clear depth one + ## (this is to make sure that there is always a best move to + ## return) self.bestMoveRoot = nullMove() result = self.bestMoveRoot self.maxNodes = maxNodes @@ -301,6 +326,7 @@ proc findBestMove*(self: SearchManager, maxSearchTime, maxDepth: int, maxNodes: var maxDepth = maxDepth if maxDepth == -1: maxDepth = 30 + self.searching.store(true) # Iterative deepening loop for i in 1..maxDepth: # Search the previous best move first @@ -316,4 +342,5 @@ proc findBestMove*(self: SearchManager, maxSearchTime, maxDepth: int, maxNodes: self.log(i - 1) break else: - self.log(i) \ No newline at end of file + self.log(i) + self.searching.store(false) \ No newline at end of file diff --git a/Chess/nimfish/nimfishpkg/uci.nim b/Chess/nimfish/nimfishpkg/uci.nim index 87f3d8d..bd8d6c0 100644 --- a/Chess/nimfish/nimfishpkg/uci.nim +++ b/Chess/nimfish/nimfishpkg/uci.nim @@ -18,6 +18,7 @@ import std/strformat import std/atomics + import board import movegen import search @@ -28,8 +29,7 @@ type UCISession = ref object debug: bool board: Chessboard - searching: bool - currentSearch: SearchManager + currentSearch: Atomic[SearchManager] hashTableSize: uint64 transpositionTable: TTable @@ -292,38 +292,37 @@ proc parseUCICommand(session: UCISession, command: string): UCICommand = proc bestMove(args: tuple[session: UCISession, command: UCICommand]) {.thread.} = ## Finds the best move in the current position + setControlCHook(proc () {.noconv.} = quit(0)) + {.cast(gcsafe).}: - setControlCHook(proc () {.noconv.} = quit(0)) # Yes yes nim sure this isn't gcsafe. Now stfu and spawn a thread var session = args.session - if session.transpositionTable.isNil(): - if session.debug: - echo &"info string created {session.hashTableSize} MiB TT" - session.transpositionTable = newTranspositionTable(session.hashTableSize * 1024 * 1024) + when defined(useTT): + if session.transpositionTable.isNil(): + if session.debug: + echo &"info string created {session.hashTableSize} MiB TT" + session.transpositionTable = newTranspositionTable(session.hashTableSize * 1024 * 1024) var command = args.command - session.searching = true - session.currentSearch = newSearchManager(session.board, session.transpositionTable) + var searcher = newSearchManager(session.board.deepCopy(), session.transpositionTable) + session.currentSearch.store(searcher) var timeRemaining = (if session.board.position.sideToMove == White: command.wtime else: command.btime) increment = (if session.board.position.sideToMove == White: command.winc else: command.binc) maxTime = (timeRemaining div 20) + (increment div 2) if maxTime == 0: maxTime = int32.high() - else: - # Buffer to avoid losing on time - maxTime -= 100 if command.moveTime != -1: maxTime = command.moveTime + # Apparently negative remaining time is a thing. Welp + maxTime = max(1, maxTime) if session.debug: echo &"info string starting search to depth {command.depth} for at most {maxTime} ms and {command.nodes} nodes" if session.debug and command.searchmoves.len() > 0: echo &"""info string restricting search to: {command.searchmoves.join(" ")}""" - var move = session.currentSearch.findBestMove(maxTime, command.depth, command.nodes, command.searchmoves) - session.searching = false + var move = searcher.findBestMove(maxTime, command.depth, command.nodes, command.searchmoves) echo &"bestmove {move.toAlgebraic()}" - proc startUCISession* = ## Begins listening for UCI commands echo "id name Nimfish 0.1" @@ -361,8 +360,7 @@ proc startUCISession* = var thread: Thread[tuple[session: UCISession, command: UCICommand]] createThread(thread, bestMove, (session, cmd)) of Stop: - if session.searching: - session.currentSearch.stopFlag.store(true) + session.currentSearch.load().stop() of SetOption: case cmd.name: of "Hash":